if not self._sd_card_rate_ok(): # piha/saturn — limit 24 h
return
self._prune_dangling_images()
self._prune_stopped_containers()
self._mark_cleanup_done()
return
# ai_node (solaria) i standard (vps): BEZ ŻADNEGO rate-limitu
self._prune_dangling_images()
self._prune_stopped_containers()
self._prune_build_cache()
```
```python
# linia 1071 — run_safe_cleanup w każdym cyklu pętli
def run_once(self):
...
self.check_containers()
self.run_safe_cleanup()
...
# linia 1101: loop(interval=HEALTH_CHECK_INTERVAL), CHECK_INTERVAL=60 (override solarii)
```
Trzy niezależne defekty składają się na incydent:
1.**`containers.prune()` bez filtrów.** docker-py przekazuje to 1:1 do
`POST /containers/prune`. Docker usuwa **wszystkie** kontenery w stanie innym niż `running`.
API nie zna pojęcia „zatrzymany celowo": nie patrzy na `RestartPolicy`, nie patrzy na labele
`com.docker.compose.*`. `restart: unless-stopped` to dosłownie zapisana intencja operatora
(„zatrzymany świadomie — nie ruszaj") i prune ją ignoruje.
2.**Brak rate-limitu dla `ai_node`/`standard`.** Rate-limit 24 h (`CLEANUP_INTERVAL_SECS = 86_400`)
istnieje **wyłącznie** dla `sd_card`. Na SOLARII prune leci co 60 s — okno na uratowanie
zatrzymanego kontenera to maksymalnie jeden cykl. Logi dowodzą, że ta częstotliwość jest
zresztą bezużyteczna: `0 MB reclaimed` w praktycznie każdym cyklu.
3.**Logowanie nie mówi, co usunięto.**`prune_containers()` zwraca `ContainersDeleted`
(lista ID) obok `SpaceReclaimed`, ale kod loguje tylko megabajty. Dlatego usunięcie
`ollamy` było w logu niewidzialne — wyglądało jak każdy inny wiersz „Pruned stopped containers".
Gdyby logowano nazwy, diagnoza trwałaby 30 sekund zamiast całej sesji.
**Dlaczego dopiero teraz.** Funkcja pochodzi z `01b7758 feat(node-agent): implement health monitor
and safe cleanup policy` — jest w repo od początku istnienia agenta. Na SOLARII nie strzelała,
bo `node-agent` startował z `Docker unavailable: Permission denied` na `/var/run/docker.sock`
(host ma docker GID **996**, base compose zakładał 999) — `self.docker_client` był `None`
i `_prune_stopped_containers()` wychodziło pierwszym `return`. Commit `ddae57c`
(2026-07-29 19:20) dołożył `group_add: ["996"]` w `hosts/solaria/runtime/node-agent/docker-compose.override.yml`.
Naprawiając monitoring, uzbroił prune. Pierwszy `Pruned stopped containers` na SOLARII:
2026-07-30 15:59:06 CEST — **2 h 46 min przed incydentem**.
---
## 5. Hipotezy wykluczone
| # | Hipoteza | Werdykt | Dowód |
|---|---|---|---|
| 1 | `docker events` / journal wskaże inicjatora | **Bez danych** (nie wyklucza ani nie potwierdza) | bufor `docker events` sięga tylko 19:42 (in-memory ring); `journalctl -u docker` w oknie 17:00–19:30 to **3 linie**, jedyna istotna to `sbJoin … ep=ollama` z 18:55:39, czyli już odtworzenie. Daemon na poziomie `info` nie loguje usunięć kontenerów. |
| 2 | Remediation pipeline (akcja z control-plane wykonana przez node-agent) | **Wykluczone** | `/opt/homelab/actions/dispatch/solaria/`**pusty**; w logach node-agent zero linii wykonania akcji; whitelist `ALLOWED_DISPATCH_ACTION_TYPES = {"container_restart"}` (linia 105, egzekwowana w 951) nie zawiera niczego, co usuwa kontener — a `container_restart` i tak by go nie skasował. |
| 4 | Config compose (auto-remove / `compose down`) | **Wykluczone** | `docker inspect`: `AutoRemove=false`, `RestartPolicy={"Name":"unless-stopped"}`; brak `--rm` w `services/ollama/docker-compose.yml`; brak `docker compose down` w `~/.zsh_history` w okolicy okna. **Uwaga poboczna:**`hosts/solaria/runtime/ollama/docker-compose.override.yml`**nie istnieje** — komenda odtwarzająca miała guard `test -f`, więc po cichu użyła samego base compose. |
| 5 | Cron / systemd timer z `docker prune` | **Wykluczone** | crontab root (tylko `@reboot /root/update.sh`), crontab oskar (pusty), `/etc/cron.{d,daily,hourly}` + `/etc/crontab` — jedyne trafienia `prune` to `find -prune` w `/etc/cron.daily/apport`. 19 systemd timerów + 4 user timery — żaden nie dotyka Dockera. |
---
## 6. Zasięg (blast radius)
Wyliczony z kodu (`_resolve_node_type`, linie 76–78 + `run_safe_cleanup`), **nie weryfikowany
na żywo na innych nodach** — SOLARIA to jedyny node, do którego ta sesja miała dostęp.
| Node | node_type | Zachowanie | Ryzyko |
|---|---|---|---|
| **SOLARIA** | `ai_node` | prune bez filtrów **co 60 s** | **Krytyczne** — potwierdzone w boju |
| **VPS** | `standard` (fallback) | prune bez filtrów, **bez rate-limitu**, cykl = `CHECK_INTERVAL` | **Krytyczne** — ta sama ścieżka kodu, w tym npm/outline/joplin/ai-cluster |
| **PIHA, SATURN** | `sd_card` | ten sam prune bez filtrów, ale gated 24 h | Wysokie, ale okno wąskie (1 strzał/dobę) |
| **CHELSTY-INFRA/HA** | `lte_node` | `return` przed cleanupem | Brak |
Konsekwencja operacyjna: na 4 z 6 nodów `docker stop <serwis>` — standardowy ruch
diagnostyczny i rollbackowy — jest operacją niszczącą. Na VPS dodatkowo dotyczy to
kontenerów spoza repo (`humanai-mailer`, `humanai-landing` odtwarzane ręcznie z `docker inspect`,
bez pliku compose) — tam utrata kontenera oznacza utratę jedynej definicji konfiguracji.
---
## 7. Rekomendacje (świadomie NIE zaimplementowane w tej sesji)
Zgodnie ze zleceniem: root-cause wskazał kod w repo → opis fixa, bez zmiany kodu.
**R1 (krytyczne) — `_prune_stopped_containers` nie może kasować kontenerów zarządzanych.**
`containers.prune()` bez filtrów jest nie do uratowania parametrami (`until` filtruje po czasie
utworzenia, nie po czasie zatrzymania — nie chroni długo żyjącego serwisu). Zamiast tego jawna enumeracja:
```python
for c in self.docker_client.containers.list(all=True, filters={"status": "exited"}):
if c.attrs["HostConfig"]["RestartPolicy"]["Name"] in ("unless-stopped", "always", "on-failure"):
continue # intencja operatora — nie ruszaj
if c.labels.get("com.docker.compose.project"):
continue # zarządzane przez compose
# dopiero teraz: c.remove()
```
Uzasadnienie: docstring modułu już deklaruje listę „NEVER TOUCHED" (`data/`, `config/`,
`state/`, żywa kolejka akcji). Kontener z `restart: unless-stopped` należy do tej samej klasy —
jest zapisaną intencją, nie śmieciem. Prune dangling images i build cache może zostać bez zmian.
**R2 (wysokie) — rate-limit dla `ai_node` i `standard`.** Obecnie `CLEANUP_INTERVAL_SECS`
działa wyłącznie dla `sd_card`. Prune co 60 s odzyskuje `0 MB` w praktycznie każdym cyklu —
to czysty koszt I/O i 1440 okazji dziennie na przypadkowe skasowanie. Ten sam guard
(`_sd_card_rate_ok` → przemianować na `_cleanup_rate_ok`) powinien objąć wszystkie typy nodów.
**R3 (średnie) — logować, co zostało usunięte.** `result.get("ContainersDeleted")` do linii
logu, poziom WARNING gdy lista niepusta. Bez tego każde następne takie zdarzenie znów będzie
niewidzialne.
**R4 (proces) — osobny task w backlogu.** Ten incydent **nie należy** do