Compare commits

..

32 commits

Author SHA1 Message Date
oskar ce75a48190 chore(kb): usuniecie docs/questions.md
Stub z 2026-04-15 (65 linii) z pierwszego reconu, gdy homelab byl jednym
RPi5. Wszystkie "Unknown / needs clarification" dawno odpowiedziane przez
kb/subsystems/fleet-inventory.md i hosts/<node>/capabilities.yaml.

Kasowany, a nie migrowany, bo niesie publiczne IPv4/IPv6 i Tailscale IP
VPS-a przy zerowej wartosci merytorycznej — migracja oznaczalaby
przeniesienie tych danych do KB bez zadnego zysku.

Zero odwolan w repo. Tresc pozostaje w historii gita.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 6f79a008f6 fix(kb): README-wskazniki dla services i hosts + wyjatek ken-legacy
Naprawa kontraktu CLAUDE.md §Service Structure (opcja b). Migracja do KB
zabrala README z katalogow serwisow i hostow, przez co 0/26 katalogow
services/ spelnialo wymagany layout. Wskazniki przywracaja nawigacje,
nie duplikujac tresci.

31 wskaznikow, jednolity format, dokladnie 5 linii:

    # <nazwa>

    <jedno zdanie opisu>

    Dokumentacja: [kb/...](../../kb/...)

Opis nie jest pisany od zera — wyciagany z kb-doca: pierwsze pelne zdanie
pierwszego akapitu (sklejane z zawinietych linii, ciete tylko tam, gdzie
backticki i nawiasy sa zbilansowane), a dla node'ow czlon tytulu H1 po
myslniku. Dla ha-mcp opis z H1, bo pierwszy akapit zaczyna sie od markera
statusu. Wiodace markery "**Status: ...**" sa zdejmowane.

26 x services/<svc>/README.md, 5 x hosts/<node>/README.md.

WYJATEK services/home-assistant/config/ken-legacy/README.md: pelne
ostrzezenie "historical archive, do not deploy" przywrocone doslownie
z historii (odzyskane z drzewa sprzed migracji) + link do kb-doca.
Ostrzezenie musi stac tam, gdzie chroni — w katalogu archiwum, nie tylko
w KB. Odwolanie do services/home-assistant/DESIGN.md przepiete na
kb/decisions/ha-configs-as-code.md + kb/incidents/2026-07-22-ha-dwie-instancje.md.

check_okf.py: POINTER_GLOBS + is_pointer() wykluczaja wskazniki ze scope'u
lintu. Wskazniki celowo NIE maja frontmattera OKF — to nawigacja, nie
dokumenty KB. Wykluczenie zapisane wprost, zeby poszerzenie SCOPE nie
zaczelo ich nagle walidowac.

Bez wskaznikow: hosts/chelsty-ha/ i hosts/lustro/ — nie maja dokumentow
w kb/nodes/ (luka odnotowana juz w reconie etapu 1). Utworzenie ich
wymagaloby napisania nowej dokumentacji, czyli wyjscia poza konwersje.

Lint: 190/190 ZGODNE. Weryfikacja 822 plikow: 0 martwych linkow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 01db57ab82 fix(kb): przepiecie wszystkich odwolan wewnetrznych po migracji
126 plikow (md, yaml, sh, py) odwolywalo sie do sciezek sprzed migracji.

  15  markdown-linkow [..](..) -> policzona sciezka WZGLEDNA wobec pliku
      odsylajacego (wczesniej czesc z nich byla repo-root-relative i nie
      rozwiazywala sie z katalogu, w ktorym lezala)
 200  odwolan tekstowych (backticki, proza, yaml, importy w kodzie)
      -> nowa sciezka repo-root-relative, zgodnie z konwencja repo
   5  linkow rodzenstwa (gole nazwy plikow, np. "](DEPLOY.md)") — dzialaly
      tylko w starym katalogu; przeliczone recznie

Objete m.in.: CLAUDE.md (scripts/onboard/README.md -> kb/runbooks/
node-onboarding-tool.md, docs/backlog.md -> kb/phases/backlog.md),
README.md, .claude/skills/, 20 session logow, kod jobow.

Ostatnie 5 odwolan pochodzi z tresci wciagnietej rebasem z origin/master
(session log 2026-07-31, override node-agenta na SOLARII, dwie pozycje
backlogu) — wskazywaly na docs/incidents/, docs/kb/modules/ i
services/narty27/README.md sprzed migracji.

Dodany wzajemny link miedzy kb/services/control-plane.md (stub kodu)
a kb/subsystems/control-plane.md (opis, deprecated) — dwa dokumenty o tym
samym systemie, latwe do pomylenia.

Weryfikacja na 790 plikach: 0 odwolan do starych sciezek,
0 martwych linkow markdown. Lint OKF: 190/190 plikow ZGODNE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar e87bef4cf2 feat(kb): SPLIT backlog.md (72 KB) -> 19 dokumentow + cienki indeks
Rozstrzygniecie 1. Monolit mieszal cztery typy OKF. Rozbity PER TYP po
granicy sekcji `##`:

  10 x decision  — pozycje backlogu (w tym backlog-aktywne 28 KB
                   i backlog-zamkniete 12 KB, ktore zostaja calosciami)
   7 x incident  — bugi/awarie dotad wtopione w backlog: cutover HA ken,
                   checkpoint observera, ha-diag-agent node=unknown,
                   deploy-local ghost-kontenery, paperless-worker config,
                   deploy-node nie przebudowuje obrazu, ollama bez sterownika
   2 x phase     — HA configs-as-code, monitoring floty Prometheus

kb/phases/backlog.md zostaje jako cienki indeks (type: phase, status: active):
oryginalna preambula + wygenerowany spis linkow do wszystkich 19 elementow.
23 przychodzace odwolania zostaja przepiete na ta sciezke w grupie 7.

NIE rozbijano po `###` (38 pozycji w "Aktywne" + 13 w "Zamkniete" = 51
plikow). Rozstrzygniecie mowi "rozbij per typ", a nie per pozycja;
rozdrobnienie do 51 plikow rozerwaloby czytelnosc backlogu.

Kontrola: preambula + 19 sekcji == oryginal z HEAD (multizbior niepustych
linii). Tresc pozycji nietknieta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar fecfa7049f feat(kb): 5 audytow/reconow -> kb/audits/ (type: audit, as_of)
czujniki-2026-07-30 (node-agent vs stability-agent)
lustro-shipping-2026-07-16 (event=dead prom=up, 1507 mismatchy)
prometheus-cutover-2026-07-06 (recon starego toru livenesci)
piha-slim-2026-07-02 (audyt odchudzania PIHA)
vps-stacki-2026-07-27 (audyt niezarzadzanych stackow na VPS)

ODSTEPSTWO OD RECONU — swiadome. Recon typowal te 5 plikow jako SPLIT
(audit+decision / audit+incident / audit+phase). Rozstrzygniecie 2 wprowadza
typ `audit` z polem as_of i mapuje kazdy z nich na JEDNA sciezke
kb/audits/<obszar>-<data>.md. Audyt jest spojna migawka stanu z konkretna
data — rozbicie go na "ustalenia" i "rekomendacje" rozerwaloby ten kontekst
i wymagaloby redakcji tresci, czego etap 2 zabrania. Zostaja w calosci.

Efekt: 29 SPLIT-ow z reconu realizowane jako 24 (10 service+runbook,
14 wielotypowych), 5 zamienionych na caloscowe dokumenty type: audit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 0da51c7a49 feat(kb): SPLIT kb-00-overview -> subsystem + decision
kb/subsystems/kb-overview.md — architektura 4 warstwy x 4 filary, zasady
  przekrojowe, kolejnosc projektow, stan per zrodlo, konwencje katalogow
kb/decisions/kb-log-decyzji.md — sekcja "Decyzje — zamkniete vs otwarte"

Tresc sekcji nietknieta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 50d8b9e501 feat(kb): SPLIT stability-agent-rollout -> runbook + subsystem
kb/runbooks/stability-agent-rollout.md — Deployment, Verification, Troubleshooting
kb/subsystems/stability-agent-architektura.md — Architecture Summary +
  "Why UI only showed CHELSTY"

Tresc sekcji nietknieta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar b83859eb7f feat(kb): SPLIT vps-control-plane -> subsystem (deprecated) + runbook
kb/subsystems/control-plane.md — status: deprecated,
  superseded_by: "przepisany tor redeploy, commity da151fc/79bfe8c 2026-08-03"
kb/runbooks/control-plane-deploy-recovery.md — Deployment + Recovery

Rozstrzygniecie 3: dokument NIE jest odswiezany, tylko oznaczony jako
nieaktualny. Ostatnia zmiana tresci 2026-05-27, czyli przed przepisaniem
toru redeployu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 2553e760e3 feat(kb): SPLIT lifecycle -> subsystem + runbook
kb/subsystems/service-lifecycle.md (visibility private wg rozstrzygniecia 6)
kb/runbooks/service-operational-recovery.md — Operational Recovery

Tresc sekcji nietknieta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 3c81ab3219 feat(kb): SPLIT deployment -> subsystem + incident
kb/subsystems/deployment.md — konwencje deployu
kb/incidents/deploy-sh-vps-niszczy-control-plane.md — sekcja
  "ZNANY BUG — deploy.sh vps niszczy control-plane (2026-06-25)"

UWAGA: "Recovery Workflow" to ### zagniezdzone w "Staged Deployment
Framework". Split mechaniczny tnie wylacznie po ##, a wyciagniecie tego
fragmentu wymagaloby przebudowy tresci — zostaje w dokumencie glownym.
Do rozwazenia jako osobny runbook w etapie redakcyjnym.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 85f20db1e7 feat(kb): SPLIT chelsty-runtime -> node + runbook
kb/nodes/chelsty-infra.md — runtime layout, SLZB-06U, ograniczenia sieciowe,
  lokalizacja configu Z2M, chelsty-ha bez node-agenta, backup sets
kb/runbooks/chelsty-deploy-recovery.md — Deployment Flow + Recovery Procedures

Tresc sekcji nietknieta; kontrola multizbioru linii == oryginal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 4d69cf7f8c feat(kb): SPLIT documents-ingest (30 KB) -> service + phase + runbook
kb/services/job-documents-ingest.md — opis jobu i mechanizmow
  (candidate selection, matching, consume/, idempotency, dry-run)
kb/phases/kb-m5-documents-ingest-fazy.md — faza 2, faza 2 krok 6,
  faza 3 krok 4, faza 3 krok 5 (4 sekcje fazowe wtopione w README)
kb/runbooks/documents-ingest-run.md — Usage, Verifying in Paperless, Tests

Najwiekszy README w repo. Tresc sekcji nietknieta; kontrola multizbioru
linii == oryginal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar f00893a414 feat(kb): SPLIT home-assistant/DESIGN.md -> decision + incident
kb/decisions/ha-configs-as-code.md — decyzje projektowe HA configs-as-code
kb/incidents/2026-07-22-ha-dwie-instancje.md — sekcja "Incident log":
dwie instancje HA sterujace domem rownolegle po migracji

Incydent byl dotad wtopiony w dokument decyzyjny; teraz jest adresowalny
jako osobny wpis type: incident. Tresc nietknieta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 0a441fc97c feat(kb): SPLIT deploy-runner -> service + decision + runbook
kb/services/job-deploy-runner.md (How it works now)
kb/decisions/deploy-runner-uzasadnienie.md (What was broken)
kb/runbooks/deploy-runner-install.md (Install per node, Operating it, Tests)

Tresc sekcji nietknieta; kontrola multizbioru linii == oryginal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar a573b7e394 feat(kb): SPLIT paperless -> service + decision + runbook
kb/services/paperless.md (Stack, OIDC, Storage i backup, RAM na PIHA)
kb/decisions/paperless-split-ocr.md (split OCR serwis@PIHA + worker@SOLARIA)
kb/runbooks/paperless-cutover.md (cutover checklist)

Tresc sekcji nietknieta; kontrola multizbioru linii == oryginal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 14473c5108 feat(kb): SPLIT nextcloud -> service + decision + runbook
kb/services/nextcloud.md (Stack, WebDAV dla ingestu, Storage i backup)
kb/decisions/nextcloud-host-piha.md (decyzja: host = PIHA)
kb/runbooks/nextcloud-cutover.md (OIDC przez Forgejo + cutover checklist)

Tresc sekcji nietknieta; kontrola multizbioru linii == oryginal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 4d4ede9e92 feat(kb): SPLIT gokapi -> service + decision + runbook
kb/services/gokapi.md (Stack, Backup, Rejestracja w repo)
kb/decisions/gokapi-storage-e2e-siec.md (storage lokalny zamiast S3,
szyfrowanie E2E, bind tylko na Tailscale)
kb/runbooks/gokapi-cutover.md (cutover checklist)

Tresc sekcji nietknieta; kontrola multizbioru linii == oryginal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 4231d09185 feat(kb): SPLIT fleet-prometheus -> service + decision + runbook
kb/services/fleet-prometheus.md (Stack, Placement & exposure, Data, Next steps)
kb/decisions/fleet-prometheus-osobny-od-prom.md ("Why separate from the home prom")
kb/runbooks/fleet-prometheus-deploy.md (Configuration, Verify)

Wzajemne links. Tresc sekcji nietknieta; kontrola: multizbior niepustych
linii czesci == oryginal z HEAD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 3292ab54e2 feat(kb): SPLIT service+runbook — 10 serwisow -> 20 dokumentow
Wzorzec mechaniczny: sekcje deploy/verify/install/testy wycinane do
kb/runbooks/<serwis>-*.md, reszta zostaje dokumentem type: service.
Wzajemne `links` w obie strony. Tresc sekcji nietknieta — przenoszone
doslownie, dodany wylacznie naglowek H1 nowego runbooka.

kb-query, paperless-worker, planner-agent, ha-diag-agent, ollama-piha,
narty27, home-assistant, ha-mcp, job-gmail-header-backfill, job-mail-body-ingest.

Weryfikacja: dla kazdego pliku multizbior niepustych linii
(main + runbook) == oryginal z HEAD. Zero zgubionych, zero dodanych.

Recon szacowal 13 splitow service+runbook; faktycznie 2-typowych jest 10,
pozostale 5 (paperless, nextcloud, gokapi, fleet-prometheus, deploy-runner)
sa 3-typowe i ida osobno jako splity wielotypowe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar cb3fa95801 feat(kb): stuby dla katalogow z kodem bez README (8 plikow)
Rozstrzygniecie 5: katalogi z kodem, ktore nie mialy README, dostaja stub
(frontmatter + stub: true + opis jednozdaniowy). NIE pisana pelna dokumentacja.

kb/services/: control-plane, node-agent, brain-watchdog, node-exporter,
job-gmail-bulk-import, pkg-kb-mail, pkg-kb-retrieval.

Kazdy stub podaje zrodlo opisu (service.yaml / docstring / CLAUDE.md /
pyproject.toml). Dla gmail-bulk-import i kb-retrieval zrodla brak — stub
mowi o tym wprost zamiast zmyslac opis.

Dodatkowo kb/subsystems/repo-operating-contract.md — wskaznik na CLAUDE.md
(rozstrzygniecie 4). CLAUDE.md zostaje w korzeniu jako zywa konfiguracja
narzedzia; kb-doc niesie pole contradicts: brak katalogow services/joplin,
services/outline, services/ai-cluster deklarowanych w sekcji
"Repo-managed services on VPS". Kontekst PR2 feat/vps-service-migration,
swiadomie nienaprawiane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar c2e5f84410 feat(kb): przenosiny type=audit do kb/audits/ (2 pliki, bez SPLIT)
Nowy typ `audit` (rozstrzygniecie 2) — migawka stanu z pola `as_of`,
nie opis stanu biezacego.

monitoring-coverage-2026-07-14, ha-automatyzacje-2026-07-23.
Pozostale 5 audytow/reconow jest wielotypowych — wychodza w grupie SPLIT-ow.

git mv + frontmatter, tresc nietknieta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 8beb28ac4c feat(kb): przenosiny type=phase do kb/phases/ (14 plikow, bez SPLIT)
Plany faz KB (modul 5 fazy 2/3/4/mailowa, moduly 0/2/3/4, documents-ingest,
raport fallback-dedup, eval retrieval-pilot) oraz prometheus-cutover-etap2,
subsystem-a-naprawa, okit-cloudflare.

git mv + frontmatter, tresc nietknieta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 5169d4dd56 feat(kb): przenosiny type=runbook do kb/runbooks/ (5 plikow, bez SPLIT)
ollama-solaria-cutover, node-onboarding-tool (ze scripts/onboard/README.md),
ha-diag-agent-deploy, npm-api (ze scripts/npm/README.md),
node-onboarding (public).

UWAGA: scripts/onboard/README.md jest linkowany z CLAUDE.md — odwolanie
naprawiane w grupie 7.

git mv + frontmatter, tresc nietknieta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 1ef193dc5f feat(kb): przenosiny type=incident do kb/incidents/ (1 plik)
Jedyny jawny incident-doc w repo. Pozostale incydenty siedza wtopione
w docs/backlog.md, services/home-assistant/DESIGN.md i lustro-shipping-recon
— wychodza w grupie SPLIT-ow.

git mv + frontmatter, tresc nietknieta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 5ccecafce9 feat(kb): przenosiny type=decision do kb/decisions/ (5 plikow, bez SPLIT)
architektura-2026-07-28, ai-cluster-legacy (public), sso-forgejo-oidc,
kb-dokumenty-otwarte (status: planned — decyzje jeszcze niepodjete),
tech-debt-legacy (deprecated, przejete przez backlog).

git mv + frontmatter, tresc nietknieta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 5c262ca25d feat(kb): przenosiny type=subsystem do kb/subsystems/ (18 plikow, bez SPLIT)
public (wzorce/schematy, bez IP/portow/sciezek hostow): observer,
capability-model, event-system, standards, agent-operating-procedures,
service-model, action-approval-model.

private: recon-multiagent, fleet-inventory, fleet-inventory-verify,
kb-mail-pillar, kb-documents-pillar, topology, agent-system.

deprecated (martwe stuby z 2026-04-15) — visibility private wg
rozstrzygniecia 6: access-model, core-stack, legacy-services-list, networking.

git mv + frontmatter, tresc nietknieta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar ae31802f10 feat(kb): przenosiny type=service do kb/services/ (13 plikow, bez SPLIT)
Serwisy o jednorodnej tresci (bez wydzielonej sekcji deploy/runbook):
kb-postgres, stability-agent, llm-gateway, vikunja, zigbee2mqtt, npm,
forgejo, ollama, ir-ac-ha-integration, chelsty-stability-agent.

Deprecated: mosquitto (NOT DEPLOYED/LEGACY), home-assistant-ken-legacy
(archiwum importu), joplin (stub z 2026-04-15, brak katalogu services/joplin/).

git mv + frontmatter, tresc nietknieta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 3ca1923355 feat(kb): przenosiny type=node do kb/nodes/ (6 plikow)
hosts/{piha,saturn,solaria,vps}/README.md -> kb/nodes/<node>.md
docs/hetzner-vps.md -> kb/nodes/hetzner-vps.md (deprecated, stub z 2026-04-15)
docs/hardware.md    -> kb/nodes/legacy-hardware.md (deprecated)

git mv + frontmatter, tresc nietknieta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar f0522a85dc feat(kb): frontmatter OKF dla 39 session logow
Session logi zostaja w docs/sessions/ (decyzja z etapu 1). Dodany wylacznie
blok frontmattera: type: session-log, visibility: private, status: active,
updated = data ostatniego commita pliku.

Tresc nietknieta — kazdy plik to +9/-0 linii.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar 00f4984375 feat(kb): walidator OKF v0.1 — scripts/kb/check_okf.py
Zaadaptowany z ~/narty-2027/saalbach-kb/check_okf.py. Tamten sprawdzal
wylacznie obecnosc frontmattera i niepuste `type`. Tutaj dochodza reguly
tego repo: okf przypiete do "0.1", type/visibility/status z zamknietych
list, updated/as_of jako YYYY-MM-DD, as_of wymagane wylacznie dla
type: audit, superseded_by wymagane wylacznie dla status: deprecated,
stub jako bool, links rozwiazywalne wzgledem katalogu dokumentu.

Zakres walidacji: kb/ + docs/sessions/. Reszta repo (CLAUDE.md, README.md,
.claude/skills/) lezy poza baza wiedzy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +02:00
oskar efd8d2fb01 fix(node-agent): R1/R2/R3 — stop deleting deliberately stopped containers
R1 (critical): _prune_stopped_containers no longer calls containers.prune().
The unfiltered API call removes EVERY non-running container, ignoring
RestartPolicy and compose labels — that is what deleted ollama@solaria 19 s
after an operator `docker stop`. No filter argument can fix it (`until` filters
on creation time, not stop time). Replaced with explicit enumeration over
containers.list(all=True, filters={"status": "exited"}), skipping anything with
restart policy unless-stopped/always/on-failure or a com.docker.compose.project
label. A stopped managed service is recorded operator intent and now belongs to
the module's NEVER TOUCHED list; only one-off leftovers are removed. Dangling
image and build cache prune are unchanged.

R2 (high): _sd_card_rate_ok → _cleanup_rate_ok, applied to every cleanup-
eligible node type. ai_node and standard pruned every 60 s (1440/day, "0 MB
reclaimed" in practically every cycle) with no rate limit at all; they now share
sd_card's CLEANUP_INTERVAL_SECS (24 h) and mark the cleanup timestamp.

R3 (medium): removals are named in the log — WARNING with the container names
when the list is non-empty, INFO otherwise. The old line reported megabytes
only, which is why ollama's deletion looked identical to every no-op cycle.

Tests: services/node-agent/tests/test_safe_cleanup.py (22 cases) — the ollama
regression (exited + unless-stopped survives, prune() never called), all three
restart policies, compose-label protection, disposable-leftover removal, sweep
continues past a failed remove, WARNING-level naming, and the rate limit for
ai_node/standard/sd_card plus lte_node still doing nothing.

Refs docs/incidents/2026-07-30-ollama-solaria-vanish.md §7 R1-R3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:20:02 +02:00
oskar 11f3f808e6 fix(vps): M1 mitigation — NODE_TYPE=lte_node disables unfiltered prune
node-agent calls docker containers.prune() with no filters every
CHECK_INTERVAL (60 s on VPS). The Docker API removes EVERY non-running
container regardless of RestartPolicy or compose labels — this destroyed
ollama@solaria on 2026-07-30 (19 s after an operator `docker stop`).

On VPS the blast radius is worse: humanai-mailer and humanai-landing have
no compose definition in this repo (recreated by hand from `docker inspect`),
so a pruned container there is an irreversible loss of the only config source.

self.node_type is read only by run_safe_cleanup() (node_agent.py:648,654) and
two log lines (250, 1103). Verified additionally for VPS: the control-plane
filesystem rotation and health probe in run_once() are gated on
`node_name == VPS_NODE_NAME`, not node_type — so they keep running. Monitoring,
event shipping and action dispatch are likewise unaffected.

Temporary — remove once R1 (explicit-enumeration prune) is deployed.
Refs docs/incidents/2026-07-30-ollama-solaria-vanish.md §7 M1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:20:02 +02:00
3 changed files with 285 additions and 12 deletions

View file

@ -3,6 +3,18 @@ services:
environment: environment:
- NODE_NAME=vps - NODE_NAME=vps
- CHECK_INTERVAL=60 - CHECK_INTERVAL=60
# TEMPORARY mitigation (M1) for the unfiltered-prune incident
# (docs/incidents/2026-07-30-ollama-solaria-vanish.md §7). node-agent runs
# `docker container prune()` with NO filters every CHECK_INTERVAL, and the
# Docker API removes EVERY non-running container regardless of restart
# policy or compose labels — this already destroyed ollama@solaria. On VPS
# the loss is worse: humanai-mailer and humanai-landing have no compose
# definition in this repo, so a pruned container cannot be recreated.
# node_type is read ONLY by run_safe_cleanup() (plus two log lines), so
# lte_node disables cleanup and nothing else — monitoring, event shipping
# and action dispatch keep working.
# REMOVE once R1 (explicit-enumeration prune) is deployed to VPS.
- NODE_TYPE=lte_node
# host network mode: node-agent on VPS shares the host's network namespace # host network mode: node-agent on VPS shares the host's network namespace
# so that localhost:18180 resolves to the control-plane's exposed port. # so that localhost:18180 resolves to the control-plane's exposed port.
# Without this, localhost inside the container is the container's own loopback # Without this, localhost inside the container is the container's own loopback

View file

@ -13,10 +13,10 @@ Runs as a Docker container on every managed node. Each cycle it:
6. Optionally rsyncs events (including action_result) to VPS so the 6. Optionally rsyncs events (including action_result) to VPS so the
control-plane observer/executor can process them. control-plane observer/executor can process them.
Cleanup policy (matches health-monitor.sh): Cleanup policy (matches health-monitor.sh). Every type below is additionally
rate-limited to one cleanup run per CLEANUP_INTERVAL_SECS (24 h):
lte_node (chelsty-infra, chelsty-ha) : NO cleanup, NO image operations lte_node (chelsty-infra, chelsty-ha) : NO cleanup, NO image operations
sd_card (piha, saturn) : dangling images + stopped containers, sd_card (piha, saturn) : dangling images + stopped containers
max once per 24 h
ai_node (solaria) : dangling + containers + build cache, ai_node (solaria) : dangling + containers + build cache,
NEVER docker image prune -a NEVER docker image prune -a
standard (vps) : dangling + containers + build cache + standard (vps) : dangling + containers + build cache +
@ -27,6 +27,9 @@ NEVER TOUCHED on any node:
/opt/homelab/config/ All hand-crafted and repo-seeded configuration /opt/homelab/config/ All hand-crafted and repo-seeded configuration
/opt/homelab/state/ Heartbeat files, observer checkpoint /opt/homelab/state/ Heartbeat files, observer checkpoint
actions/pending|approved|running Live work queue actions/pending|approved|running Live work queue
Stopped containers with a restart policy or a compose project label a
stopped service is recorded operator intent, not garbage. Container cleanup
enumerates explicitly and never calls the unfiltered containers.prune().
""" """
import json import json
@ -131,7 +134,8 @@ MEM_CRIT_PCT = 95
# that is actually stuck flapping. Configurable via env for tuning per fleet. # that is actually stuck flapping. Configurable via env for tuning per fleet.
CRASH_LOOP_RESTART_THRESHOLD = int(os.getenv("CRASH_LOOP_RESTART_THRESHOLD", "3")) CRASH_LOOP_RESTART_THRESHOLD = int(os.getenv("CRASH_LOOP_RESTART_THRESHOLD", "3"))
# SD-card nodes: enforce 24-hour gap between Docker cleanup runs # Minimum gap between Docker cleanup runs, on EVERY cleanup-eligible node type
# (was sd_card-only until the 2026-07-30 unfiltered-prune incident)
CLEANUP_INTERVAL_SECS = 86_400 CLEANUP_INTERVAL_SECS = 86_400
LAST_CLEANUP_FILE = STATE_DIR / "last-docker-cleanup" LAST_CLEANUP_FILE = STATE_DIR / "last-docker-cleanup"
@ -591,8 +595,14 @@ class NodeAgent:
# Safe Docker cleanup # Safe Docker cleanup
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _sd_card_rate_ok(self) -> bool: def _cleanup_rate_ok(self) -> bool:
"""Return True only if 24 hours have elapsed since last cleanup.""" """Return True only if CLEANUP_INTERVAL_SECS has elapsed since last cleanup.
Applies to EVERY cleanup-eligible node type, not just sd_card. Before
R2 this guard covered sd_card alone, so ai_node and standard pruned
every 60 s 1440 chances a day to delete a deliberately stopped
container, for `0 MB reclaimed` in practically every cycle.
"""
if LAST_CLEANUP_FILE.exists(): if LAST_CLEANUP_FILE.exists():
try: try:
last_ts = int(LAST_CLEANUP_FILE.read_text().strip()) last_ts = int(LAST_CLEANUP_FILE.read_text().strip())
@ -623,14 +633,58 @@ class NodeAgent:
logger.error(f"Image prune failed: {exc}") logger.error(f"Image prune failed: {exc}")
def _prune_stopped_containers(self): def _prune_stopped_containers(self):
"""Remove exited containers that are unambiguously disposable.
NEVER uses containers.prune(): the Docker API removes *every*
non-running container, ignoring RestartPolicy and compose labels. That
is what deleted `ollama` on SOLARIA 19 s after an operator stopped it
(docs/incidents/2026-07-30-ollama-solaria-vanish.md). No filter can fix
it either `until` filters on creation time, not stop time, so it
never protects a long-lived service.
A container carrying `restart: unless-stopped|always|on-failure` or a
compose project label is recorded operator intent, exactly like the
paths in this module's NEVER TOUCHED list. Only one-off leftovers
(restart policy `no`, no compose project) are removed.
"""
if not self.docker_client: if not self.docker_client:
return return
try: try:
result = self.docker_client.containers.prune() containers = self.docker_client.containers.list(
reclaimed = result.get("SpaceReclaimed", 0) // (1024 * 1024) all=True, filters={"status": "exited"}
logger.info(f"Pruned stopped containers ({reclaimed} MB reclaimed)") )
except Exception as exc: except Exception as exc:
logger.error(f"Container prune failed: {exc}") logger.error(f"Container list for prune failed: {exc}")
return
removed, kept = [], 0
for c in containers:
try:
policy = (
(c.attrs.get("HostConfig") or {})
.get("RestartPolicy", {})
.get("Name", "")
)
if policy in ("unless-stopped", "always", "on-failure"):
kept += 1 # operator intent — do not touch
continue
if (c.labels or {}).get("com.docker.compose.project"):
kept += 1 # compose-managed — do not touch
continue
c.remove()
removed.append(c.name)
except Exception as exc:
logger.error(f"Failed to remove stopped container {c.name}: {exc}")
# R3: name what was deleted. The old log line reported megabytes only,
# which is why ollama's removal looked identical to every no-op cycle.
if removed:
logger.warning(
f"Removed {len(removed)} disposable stopped container(s): "
f"{', '.join(removed)} (kept {kept} managed)"
)
else:
logger.info(f"No disposable stopped containers (kept {kept} managed)")
def _prune_build_cache(self): def _prune_build_cache(self):
if not self.docker_client: if not self.docker_client:
@ -651,9 +705,14 @@ class NodeAgent:
logger.debug("Skipping Docker cleanup: LTE node") logger.debug("Skipping Docker cleanup: LTE node")
return return
# Rate limit applies to every cleanup-eligible node type (R2). Cleanup
# is housekeeping, not a health function: once per CLEANUP_INTERVAL_SECS
# reclaims the same space as once per minute, at a fraction of the I/O
# and of the exposure to accidental deletion.
if not self._cleanup_rate_ok():
return
if self.node_type == "sd_card": if self.node_type == "sd_card":
if not self._sd_card_rate_ok():
return
self._prune_dangling_images() self._prune_dangling_images()
self._prune_stopped_containers() self._prune_stopped_containers()
# No builder prune: minimise write cycles on SD card # No builder prune: minimise write cycles on SD card
@ -665,6 +724,7 @@ class NodeAgent:
self._prune_dangling_images() self._prune_dangling_images()
self._prune_stopped_containers() self._prune_stopped_containers()
self._prune_build_cache() self._prune_build_cache()
self._mark_cleanup_done()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# VPS-specific: control-plane filesystem rotation # VPS-specific: control-plane filesystem rotation

View file

@ -0,0 +1,201 @@
"""Tests for NodeAgent Docker cleanup — regression cover for the 2026-07-30
unfiltered-prune incident (docs/incidents/2026-07-30-ollama-solaria-vanish.md).
`containers.prune()` removes EVERY non-running container, ignoring restart
policy and compose labels; that is what deleted `ollama` on SOLARIA 19 s after
an operator stopped it. These tests pin down the replacement:
R1 explicit enumeration; a stopped container with `restart: unless-stopped`
or a compose project label is never removed, and prune() is never called.
R2 the cleanup rate limit applies to ai_node / standard, not sd_card only.
R3 removals are named in a WARNING log line.
"""
from __future__ import annotations
import time
from unittest.mock import MagicMock
import pytest
import node_agent
# ---------------------------------------------------------------------------
# Fake Docker container helper
# ---------------------------------------------------------------------------
def make_stopped(name, *, restart_policy="no", compose_project=None):
c = MagicMock()
c.name = name
c.status = "exited"
c.attrs = {"HostConfig": {"RestartPolicy": {"Name": restart_policy}}}
c.labels = {"com.docker.compose.project": compose_project} if compose_project else {}
return c
def with_containers(agent, containers):
client = MagicMock()
client.containers.list.return_value = containers
agent.docker_client = client
return client
# ---------------------------------------------------------------------------
# R1 — the incident itself
# ---------------------------------------------------------------------------
def test_unless_stopped_container_survives(agent):
"""THE regression: ollama@solaria — exited, restart=unless-stopped, must live."""
ollama = make_stopped("ollama", restart_policy="unless-stopped")
client = with_containers(agent, [ollama])
agent._prune_stopped_containers()
ollama.remove.assert_not_called()
client.containers.prune.assert_not_called()
@pytest.mark.parametrize("policy", ["unless-stopped", "always", "on-failure"])
def test_every_restart_policy_is_operator_intent(agent, policy):
c = make_stopped("svc", restart_policy=policy)
with_containers(agent, [c])
agent._prune_stopped_containers()
c.remove.assert_not_called()
def test_compose_managed_container_survives(agent):
"""No restart policy, but compose owns it → still off limits."""
c = make_stopped("outline-redis-1", restart_policy="no", compose_project="outline")
with_containers(agent, [c])
agent._prune_stopped_containers()
c.remove.assert_not_called()
def test_disposable_leftover_is_removed(agent):
"""restart=no and no compose project → a one-off leftover, safe to remove."""
c = make_stopped("nervous-shell-42", restart_policy="no")
with_containers(agent, [c])
agent._prune_stopped_containers()
c.remove.assert_called_once()
def test_only_exited_containers_are_enumerated(agent):
"""Running containers must never even enter the candidate list."""
client = with_containers(agent, [])
agent._prune_stopped_containers()
_args, kwargs = client.containers.list.call_args
assert kwargs["all"] is True
assert kwargs["filters"] == {"status": "exited"}
def test_mixed_set_removes_only_the_disposable_one(agent):
keep_policy = make_stopped("ollama", restart_policy="unless-stopped")
keep_compose = make_stopped("umami-db", compose_project="umami")
drop = make_stopped("tmp-build", restart_policy="no")
with_containers(agent, [keep_policy, keep_compose, drop])
agent._prune_stopped_containers()
keep_policy.remove.assert_not_called()
keep_compose.remove.assert_not_called()
drop.remove.assert_called_once()
def test_remove_failure_does_not_abort_the_sweep(agent):
boom = make_stopped("boom", restart_policy="no")
boom.remove.side_effect = RuntimeError("device or resource busy")
later = make_stopped("later", restart_policy="no")
with_containers(agent, [boom, later])
agent._prune_stopped_containers()
later.remove.assert_called_once()
def test_no_docker_client_is_noop(agent):
agent.docker_client = None
agent._prune_stopped_containers() # must not raise
# ---------------------------------------------------------------------------
# R3 — say what was deleted
# ---------------------------------------------------------------------------
def test_removal_is_logged_at_warning_with_names(agent, caplog):
with_containers(agent, [make_stopped("tmp-build", restart_policy="no")])
with caplog.at_level("INFO", logger="node-agent"):
agent._prune_stopped_containers()
warnings = [r for r in caplog.records if r.levelname == "WARNING"]
assert len(warnings) == 1
assert "tmp-build" in warnings[0].message
def test_nothing_removed_stays_at_info(agent, caplog):
with_containers(agent, [make_stopped("ollama", restart_policy="unless-stopped")])
with caplog.at_level("INFO", logger="node-agent"):
agent._prune_stopped_containers()
assert [r for r in caplog.records if r.levelname == "WARNING"] == []
# ---------------------------------------------------------------------------
# R2 — rate limit covers every node type
# ---------------------------------------------------------------------------
@pytest.fixture
def cleanup_calls(agent, monkeypatch):
"""Record which prune helpers run, without touching Docker."""
calls = []
for helper in ("_prune_dangling_images", "_prune_stopped_containers",
"_prune_build_cache"):
monkeypatch.setattr(agent, helper, lambda h=helper: calls.append(h))
return calls
@pytest.fixture
def fresh_cleanup_marker(tmp_path, monkeypatch):
monkeypatch.setattr(node_agent, "LAST_CLEANUP_FILE", tmp_path / "last-docker-cleanup")
return node_agent.LAST_CLEANUP_FILE
@pytest.mark.parametrize("node_type", ["ai_node", "standard", "sd_card"])
def test_recent_cleanup_blocks_next_run(agent, cleanup_calls, fresh_cleanup_marker,
node_type):
"""Was true for sd_card only; after R2 it holds for ai_node and standard too."""
agent.node_type = node_type
fresh_cleanup_marker.write_text(str(int(time.time())))
agent.run_safe_cleanup()
assert cleanup_calls == []
@pytest.mark.parametrize("node_type,expected", [
("ai_node", ["_prune_dangling_images", "_prune_stopped_containers",
"_prune_build_cache"]),
("standard", ["_prune_dangling_images", "_prune_stopped_containers",
"_prune_build_cache"]),
("sd_card", ["_prune_dangling_images", "_prune_stopped_containers"]),
])
def test_stale_marker_allows_cleanup_and_is_refreshed(agent, cleanup_calls,
fresh_cleanup_marker,
node_type, expected):
agent.node_type = node_type
stale = int(time.time()) - node_agent.CLEANUP_INTERVAL_SECS - 1
fresh_cleanup_marker.write_text(str(stale))
agent.run_safe_cleanup()
assert cleanup_calls == expected
# Marker refreshed for every type, otherwise the guard never engages.
assert int(fresh_cleanup_marker.read_text()) > stale
def test_lte_node_still_does_nothing(agent, cleanup_calls, fresh_cleanup_marker):
agent.node_type = "lte_node"
agent.run_safe_cleanup()
assert cleanup_calls == []
assert not fresh_cleanup_marker.exists()