fix(ollama): toggle the NixOS system service on .27, not the deleted user unit

The ollama card was wired to 'systemctl --user', but the .27 user unit was
removed when Ollama moved to services.ollama (NixOS system service). The panel
therefore reported Stopped while the service was Running and its buttons failed.

- new controller kind 'systemd-system' -> sudo -n <canonical systemctl> ...
- resolve systemctl to its store path first: NixOS puts a symlink on PATH but
  the NOPASSWD sudoers rule names the store path, so bare 'sudo systemctl' does
  not match (verified: full path exit=0, bare name 'a password is required')
- _remote() now shlex.join()s the remote command; ssh joins trailing argv with
  spaces, which was word-splitting the 'sh -c' wrapper and failing with a sudo
  usage error
- services.toml: ollama -> kind=systemd-system, target=ollama (matches the
  sudoers rule verbatim), default_state=running
- docs/portal-architecture.md: document the three controller kinds + gotchas

Verified end-to-end from .13: start->running, stop->stopped, start->running,
real systemctl agrees, and /v1/models lists both qwen2.5 models over the LAN.
This commit is contained in:
2026-10-12 16:29:10 +11:00
parent a661fb3b2e
commit d7802bddb1
3 changed files with 85 additions and 20 deletions

View File

@@ -19,6 +19,7 @@ from __future__ import annotations
import json
import os
import shlex
import subprocess
import threading
import time
@@ -84,29 +85,62 @@ def _container_state(target: str) -> str:
return out or ("unknown" if r.returncode != 0 else "unknown")
def _systemd_user_state(target: str, remote: str | None = None) -> str:
cmd = ["systemctl", "--user", "is-active", target]
if remote:
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes", remote] + cmd
r = subprocess.run(cmd, capture_output=True, text=True)
SSH_OPTS = ["-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes"]
def _remote(cmd: list[str], remote: str | None) -> list[str]:
"""Wrap a command in ssh when the target service lives on another host.
ssh joins its trailing arguments with spaces before the remote shell parses
them, so the command is quoted as one string first — otherwise remote
commands containing quotes or shell syntax (e.g. the `sh -c` used by
systemd-system) get word-split and fail.
"""
return ["ssh", *SSH_OPTS, remote, shlex.join(cmd)] if remote else cmd
def _systemd_state(target: str, remote: str | None = None, system: bool = False) -> str:
"""`is-active` needs no privilege, so this never uses sudo."""
cmd = ["systemctl", "is-active", target] if system else ["systemctl", "--user", "is-active", target]
r = subprocess.run(_remote(cmd, remote), capture_output=True, text=True)
out = (r.stdout.strip() or "inactive").lower()
if out == "active":
return "running"
return out
return "running" if out == "active" else out
def _do(kind: str, action: str, target: str, remote: str | None = None) -> str:
"""Run start/stop. Returns the state after a short settle."""
"""Run start/stop. Returns the state after a short settle.
Kind semantics:
container -> docker <action> <target> (sam is in the docker group)
systemd-user -> systemctl --user <action> <target> (local user units on .13)
systemd-system -> sudo -n <canonical systemctl> <action> <target>
(system units; `remote` sends it over ssh)
`systemd-system` requires a NOPASSWD sudoers rule for that exact
command+argument on the target host. On .27 this is granted for
`systemctl start|stop|restart ollama` (see hosts/sam-4screen-desktop),
where `target` must match the rule verbatim: "ollama", not "ollama.service".
Note: sudo matches the *literal* PATH-resolved path, and NixOS puts a
symlink (`/run/current-system/sw/bin/systemctl`) on PATH, so a bare
`sudo systemctl ...` does not match a store-path rule. We therefore resolve
`systemctl` to its canonical store path first. This also keeps working after
a nixpkgs bump changes the systemd store hash.
"""
if kind == "container":
subprocess.run(["docker", action, target], capture_output=True, text=True)
time.sleep(1.5)
return _container_state(target)
cmd = ["systemctl", "--user", action, target]
if remote:
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes", remote] + cmd
subprocess.run(cmd, capture_output=True, text=True)
if kind == "systemd-system":
shell = f'sudo -n "$(readlink -f "$(command -v systemctl)")" {action} {target}'
cmd = ["sh", "-c", shell]
else:
cmd = ["systemctl", "--user", action, target]
res = subprocess.run(_remote(cmd, remote), capture_output=True, text=True)
if res.returncode != 0 and kind == "systemd-system":
audit(f"WARN {kind} {action} {target} exit={res.returncode} {res.stderr.strip()[:160]}")
time.sleep(1.5)
return _systemd_user_state(target, remote=remote)
return _systemd_state(target, remote=remote, system=(kind == "systemd-system"))
def _targets(s: dict[str, Any]) -> list[str]:
@@ -128,7 +162,7 @@ def _probe(s: dict[str, Any]) -> tuple[str, list[str]]:
if kind == "container":
states.append(_container_state(tgt))
else:
states.append(_systemd_user_state(tgt, remote=remote))
states.append(_systemd_state(tgt, remote=remote, system=(kind == "systemd-system")))
n_ok = sum(1 for st in states if st in ("running", "active", "started"))
n_none = sum(1 for st in states if st in ("unknown",)) or 0
if n_ok == len(states):