Files
podman-mvp/podman-helper-rationale.md
kodi 4404c02967 docs: update AGENTS/SAFE_FILES/rationale na D-Bus verwijdering
- AGENTS.md: run-commando bijgewerkt (verwijder brede /run/user/1000
  mount en DBUS_SESSION_BUS_ADDRESS); notitie D-Bus niet meer vereist
- SAFE_FILES.md: verwijder DBUS_SESSION_BUS_ADDRESS; beschrijf
  concrete mounts (Podman socket + helper directory)
- podman-helper-rationale.md: daemon-reload sectie bijgewerkt —
  gaat nu via helper ipv D-Bus; samenvattingstabel gecorrigeerd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 12:28:22 +01:00

297 lines
10 KiB
Markdown

# De podman-helper: waarom, hoe en wat het oplost
## Inleiding
Dit document beschrijft de technische achtergrond en motivatie voor de `podman-helper` service in het Podman MVP project. Het legt uit welk fundamenteel probleem er was, waarom meerdere eerdere oplossingen faalden, en hoe de helper dit definitief oplost.
---
## Het probleem: systemd units beheren vanuit een rootless container
### Wat we wilden
Een gebruiker moet vanuit de webui een quadlet service kunnen starten, stoppen en herstarten. Een quadlet service is een systemd user service die gegenereerd wordt door Podman's quadlet generator op basis van een `.container` of `.kube` bestand. Voorbeelden:
- `sonarr.container` → systemd genereert `sonarr.service`
- `test-web.container` → systemd genereert `test-web.service`
De bedoeling was:
```
Gebruiker klikt "stop" in de webui
→ webui stuurt POST /api/system/unit/sonarr.service/stop
→ API container voert de stop uit
→ sonarr stopt
```
### Waarom dit niet triviaal is
De API container is een rootless Podman container. Hij draait als gewone gebruiker (kodi, UID 1000) maar hij draait **in een geïsoleerde namespace**. Systemd user services draaien in de **host user session** van UID 1000. Die twee werelden zijn niet zomaar uitwisselbaar.
---
## Poging 1: D-Bus StopUnit vanuit de container
### Aanpak
Systemd is bereikbaar via D-Bus. De rootless D-Bus session bus is beschikbaar op:
```
unix:path=/run/user/1000/bus
```
Door deze socket als volume te mounten in de container en de `DBUS_SESSION_BUS_ADDRESS` omgevingsvariabele in te stellen, leek het mogelijk om via `dbus-send` systemd commando's te sturen:
```python
_dbus_call("StopUnit", f"string:{unit_name}", "string:replace")
```
### Waarom dit faalde
**D-Bus StopUnit werkt anders dan systemctl stop.**
Wanneer `StopUnit` via D-Bus wordt aangeroepen:
1. Systemd stuurt een stopsignaal naar de container
2. De container ontvangt SIGTERM maar de applicatie reageert niet snel genoeg
3. Na de TimeoutStopSec (standaard 10 seconden) stuurt systemd SIGKILL
4. De container sterft met **exit code 137** (128 + 9 = SIGKILL)
Het probleem zit in wat er daarna gebeurt. De quadlet-gegenereerde service heeft standaard `Restart=on-failure` in de gegenereerde unit. Exit code 137 wordt door systemd beschouwd als een **failure** — en systemd herstart de service automatisch.
**`systemctl --user stop` werkt wel** omdat het intern een `prevent_restart` flag zet die de automatische herstart onderdrukt. D-Bus `StopUnit` heeft deze flag niet.
**Resultaat:** De service lijkt gestopt maar herstart zichzelf binnen enkele seconden. Vanuit de webui is dit onzichtbaar — de gebruiker ziet "gestopt" maar de service draait gewoon door.
### Bewijs
```bash
# Handmatige test toonde dit gedrag:
curl -s -X POST http://api/system/unit/sonarr.service/stop
# Response: {"message": "sonarr.service gestopt"} ← misleidend
sleep 5
systemctl --user is-active sonarr.service
# Output: active ← service draait gewoon door
```
---
## Poging 2: podman stop + D-Bus StopUnit
### Aanpak
Het idee: als we eerst `podman stop` aanroepen (wat SIGTERM stuurt en de container netjes laat stoppen met exit 0), en daarna D-Bus StopUnit aanroepen voor de systemd cleanup, zou de combinatie moeten werken.
```python
# Stap 1: container netjes stoppen
await _request("POST", f"/containers/{container_name}/stop")
# Stap 2: systemd opruimen
_dbus_call("StopUnit", f"string:{unit_name}", "string:replace")
```
### Waarom dit faalde
De `StopUnit` D-Bus call na `podman stop` **blokkeerde de opvolgende StartUnit**. Wanneer we daarna probeerden de service te starten:
```python
_dbus_call("StartUnit", f"string:{unit_name}", "string:replace")
```
Returde D-Bus wel een job pad (wat suggereert dat het gelukt is), maar systemd voerde de job niet daadwerkelijk uit. De service bleef op `inactive`.
De oorzaak: de extra `StopUnit` call zette de unit in een transitie state waaruit systemd de `StartUnit` job annuleerde.
---
## Poging 3: podman stop + wachten op inactive + StartUnit
### Aanpak
Misschien was het timing. We voegden een wachtfunctie toe die wacht totdat de unit echt `inactive` is voordat we `StartUnit` aanroepen:
```python
async def _wait_for_unit_state(unit_name, target="inactive", timeout=15):
for _ in range(timeout):
state = _get_unit_active_state(unit_name)
if state == target:
return True
await asyncio.sleep(1)
return False
# Gebruik:
await _wait_for_unit_state(unit_name, "inactive")
await asyncio.sleep(2) # extra buffer
_dbus_call("StartUnit", ...)
```
### Waarom dit ook faalde
Zelfs als `ActiveState == inactive` is, is systemd intern nog bezig met de deactivatie cleanup. De `StartUnit` job wordt aangemaakt (D-Bus geeft een job pad terug) maar systemd annuleert hem intern omdat de unit nog niet volledig gestopt is.
Uitgebreide tests toonden aan:
- `ActiveState` was al `inactive`
- `SubState` was al `dead`
- Maar `StartUnit` via D-Bus vanuit de container resulteerde niet in een daadwerkelijke start
**Hetzelfde commando direct op de host werkte wél:**
```bash
# Op de host:
dbus-send --session --print-reply \
--dest=org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager.StartUnit \
string:test-web.service string:replace
# → active ✓
# Vanuit de container (zelfde commando):
podman exec podman-api dbus-send --session --print-reply \
--dest=org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager.StartUnit \
string:test-web.service string:replace
# → D-Bus geeft OK maar service blijft inactive ✗
```
Dit bevestigde dat het probleem fundamenteel is: D-Bus vanuit een container context en D-Bus vanuit de host user session zijn niet equivalent, ook al communiceren ze over dezelfde socket.
---
## De root cause: container namespace isolatie
De kern van het probleem is dat een Podman rootless container draait in een **user namespace**. Hoewel de D-Bus socket gemount is en communicatie technisch mogelijk is, heeft systemd een interne controle op de **peer credentials** van de D-Bus verbinding.
Wanneer een D-Bus bericht aankomt van een process in een andere user namespace (de container), ziet systemd dit als een andere security context dan een bericht van de host user session. Bepaalde operaties — met name het daadwerkelijk uitvoeren van StartUnit/StopUnit jobs — worden door systemd intern anders behandeld of afgekapt.
Dit is geen bug maar een bewuste beveiligingsboundary in Linux: een process in een user namespace mag niet zomaar services beheren in de parent namespace.
**`systemctl --user stop` werkt** omdat systemctl de D-Bus verbinding opbouwt vanuit de host user session met de juiste credentials. Het zet ook de `prevent_restart` flag die D-Bus `StopUnit` mist.
---
## De oplossing: podman-helper
### Ontwerp
De `podman-helper` is een kleine Python service die **direct op de host** draait als de `kodi` gebruiker. Hij luistert op een Unix socket en voert `systemctl --user` commando's uit namens de API container.
```
API container
│ Unix socket: /run/user/1000/podman-helper.sock
│ (gemount in de container als /run/podman-helper.sock)
podman-helper.py (draait op de HOST als kodi user)
│ subprocess: systemctl --user start|stop|restart <unit>
systemd user session (host)
```
### Protocol
Eenvoudig JSON over de Unix socket:
**Verzoek:**
```json
{"action": "start", "unit": "test-web.service"}
```
**Antwoord bij succes:**
```json
{"ok": true, "output": "test-web.service start geslaagd"}
```
**Antwoord bij fout:**
```json
{"ok": false, "error": "Actie 'kill' niet toegestaan. Gebruik: restart, start, stop"}
```
### Beveiliging
De helper heeft een strikte whitelist:
```python
ALLOWED_ACTIONS = {"start", "stop", "restart"}
UNIT_PATTERN = re.compile(r'^[a-zA-Z0-9._\-]+\.service$')
```
- Alleen `start`, `stop` en `restart` zijn toegestaan
- Unit namen mogen alleen veilige tekens bevatten
- Geen shell injection mogelijk — `systemctl` wordt direct aangeroepen via `subprocess`, niet via een shell
- De Unix socket heeft permissie `0o600` — alleen de eigenaar (kodi) kan ermee communiceren
### Gelijktijdige verbindingen
De helper gebruikt Python `asyncio` en `asyncio.start_unix_server`. Dit betekent dat meerdere gelijktijdige verzoeken zonder problemen verwerkt worden — de event loop handelt ze af zonder blocking.
Test bewees dit:
```bash
# 5 gelijktijdige restart verzoeken
for i in {1..5}; do
echo '{"action": "restart", "unit": "test-web.service"}' | \
socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/podman-helper.sock &
done
wait
# Resultaat: alle 5 geslaagd, service actief ✓
```
---
## Wat nu wél werkt
### Stop
```
webui klikt stop
→ POST /api/system/unit/test-web.service/stop
→ API verbindt met helper socket
→ helper voert: systemctl --user stop test-web.service
→ systemd stopt de service met prevent_restart flag
→ container verdwijnt, cidfile opgeruimd
→ service is inactive ✓
```
### Start
```
webui klikt start
→ POST /api/system/unit/test-web.service/start
→ API verbindt met helper socket
→ helper voert: systemctl --user start test-web.service
→ quadlet generator heeft al een .service gegenereerd
→ systemd start de container
→ service is active ✓
```
### Restart
```
webui klikt restart
→ POST /api/system/unit/test-web.service/restart
→ API verbindt met helper socket
→ helper voert: systemctl --user restart test-web.service
→ systemd stopt en herstart de container
→ service is active (na ~8-10 seconden) ✓
```
---
## daemon-reload via de helper
`daemon-reload` gaat inmiddels ook via de helper. Oorspronkelijk werkte `Manager.Reload` via D-Bus vanuit de container, maar om de D-Bus socket en `DBUS_SESSION_BUS_ADDRESS` mount volledig te kunnen verwijderen is daemon-reload als actie toegevoegd aan de helper.
De helper bouwt het commando zonder unit-argument: `systemctl --user daemon-reload`.
---
## Samenvatting
| Operatie | Via D-Bus vanuit container | Via helper op host |
|---|---|---|
| daemon-reload | ❌ Niet meer via D-Bus | ✅ Werkt |
| Unit status opvragen | ✅ Werkt (read-only) | — |
| Unit stoppen | ❌ Herstart zichzelf | ✅ Werkt |
| Unit starten | ❌ Job wordt genegeerd | ✅ Werkt |
| Unit herstarten | ❌ Blijft inactive | ✅ Werkt |
De helper is de minimale, veilige oplossing voor het fundamentele probleem dat een process in een user namespace niet dezelfde rechten heeft als een process in de host user session — ook niet als ze via dezelfde D-Bus socket communiceren.