Ga naar hoofdinhoud

Runbooks

Operationele how-to's, afgeleid van de Ansible-playbooks en Terraform-modules in de repo.

Vanaf jumpy

Alle homelab-commando's draaien vanaf jumpy — niet vanaf alma (alma's kubectl wijst naar productie). Ansible-commando's draaien vanuit ansible/, Terraform vanuit de betreffende module onder terraform/.

VM's provisionen (Terraform)​

De Kubernetes-VM's (3 control-plane + 3 workers) worden data-driven aangemaakt door per-shape templates te clonen. De shape (cpu/mem/disk) komt 100% uit de template — er zijn bewust geen post-clone hardware-overrides (zie Beslissingen).

cd terraform/k8s-cluster
terraform plan
terraform apply # vereist bevestiging

Kubernetes zelf wordt niet door Terraform geconfigureerd, maar door de Ansible-playbooks hieronder.

Templates bouwen​

Bouwt de K8s-VM-templates op de Proxmox-hosts. VMID's zijn cluster-breed uniek, dus elke host heeft zijn eigen reeks (px-01 → 9001/9002, px-02 → 9011/9012, px-03 → 9021/9022).

ansible-playbook -i inventory/proxmox-hosts.yml playbooks/build-k8s-templates.yml

K8s greenfield bootstrap​

Volgorde voor een vers HA-cluster (vanuit ansible/):

# 1. OS-prerequisites op alle nodes (incl. containerd)
ansible-playbook -i inventory/hosts.yml playbooks/prepare-nodes.yml

# 2. kubeadm/kubelet/kubectl installeren
ansible-playbook -i inventory/hosts.yml playbooks/kubeadm-install-packages.yml

# 3. (alleen bij herbouw) vorige clusterstaat opruimen
ansible-playbook -i inventory/hosts.yml playbooks/kubeadm-cleanup-before-bootstrap.yml

# 4. HA control-plane bootstrappen (kube-vip VIP .201) + workers joinen
ansible-playbook -i inventory/hosts.yml playbooks/kubeadm-bootstrap.yml

# 5. Post-bootstrap: kubeconfig ophalen + addons
ansible-playbook -i inventory/hosts.yml playbooks/kubeadm-post-bootstrap.yml

De kubeconfig blijft naar de kube-vip VIP 192.168.178.201:6443 wijzen — dat overleeft het uitvallen van een control-plane-node.

Node-onderhoud & upgrades​

Housekeeping (journald-cap + wekelijkse cleanup-timer, geen upgrades):

ansible-playbook -i inventory/hosts.yml playbooks/node-maintenance.yml
ansible-playbook -i inventory/proxmox-hosts.yml playbooks/node-maintenance.yml

Package-updates, drain-aware, één node tegelijk (draai uitsluitend vanaf jumpy — gebruikt kubectl via delegate_to: localhost):

# K8s-nodes: drain → upgrade → reboot → uncordon
ansible-playbook -i inventory/hosts.yml playbooks/node-update.yml

# VM's: upgrade → reboot
ansible-playbook -i inventory/proxmox-hosts.yml playbooks/node-update.yml

node-update.yml houdt kubelet/kubeadm/kubectl op apt-mark hold. Een cluster-versie-hop is een aparte operatie via playbooks/kubeadm-upgrade.yml.

Cilium upgraden​

helm upgrade cilium cilium/cilium -n kube-system \
-f cluster-config/infra/cilium/values.yaml

Kerninstellingen: kubeProxyReplacement=true, Hubble aan, Gateway API aan.

GitOps: apps beheren (Argo CD)​

Er draait géén root-Application. apps/root-app.yaml is nooit gebootstrapt (nagemeten 2026-09-24: kubectl -n argocd get applications toont alleen de losse apps). Elke Application onder apps/infrastructure/ is los ge-applied; een nieuw bestand daar deployt dus niet vanzelf. Daarna gaat wel alles via Git: elke Application synct automatisch (selfHeal) uit cluster-config/infra/<app>.

# Nieuwe app: manifest in apps/infrastructure/ committen, en de Application zelf eenmalig applyen
kubectl apply -f https://raw.githubusercontent.com/MWest2020/homelab/main/apps/infrastructure/<app>.yaml
kubectl get applications -n argocd # sync-status van alle apps

De root-app niet alsnog aanzetten zonder eerst op te ruimen: apps/infrastructure/ bevat ook Applications die bewust níét op het cluster draaien (argocd, argo-workflows, argo-events, argo-rollouts, nextcloud-platform, …). Met automated sync zou de root die allemaal uitrollen.

Sync-waves bepalen de volgorde (operator-CRDs vóór de CRs die ze nodig hebben). Operators met te grote CRDs (CNPG, Tailscale) syncen met ServerSideApply=true — client-side apply loopt daar stuk op de 256KB-annotation-limiet.

Wordsworth-straat: deployen & verifiëren​

De RAG-stack (zie Architectuur) is volledig GitOps. Een nieuwe API-versie uitrollen = de digest van de gewenste sha-<commit>-build resolven en die pinnen in cluster-config/infra/wordsworth/api.yaml én init-job.yaml, committen — Argo CD synct de rest (PreSync init-Job draait eerst, idempotent, voor het DB-schema).

# Digest van een build opzoeken; pin daarna image: ghcr.io/mwest2020/wordsworth@sha256:<digest>
docker buildx imagetools inspect ghcr.io/mwest2020/wordsworth:sha-<short>
  • api en init-job altijd samen op dezelfde digest — anders ontbreken de kolom-migraties die de nieuwe API verwacht.
  • Noem de sha-<short> in de commit-message: de digest zelf zegt niet welke commit het is. Een tag in de manifests laat scripts/pin_check.py (in de wordsworth-repo) rood worden in CI.
  • WORDSWORTH_GRANT_ISSUER_LABELS niet leegmaken zolang caller-auth aanstaat: leeg betekent hier niemand (anders dan bij WORDSWORTH_CORPUS_READ_LABELS), dus dan kan niemand nog een reveal-grant uitgeven.

Prerequisite-secrets (out-of-band, nooit in Git): wordsworth-db, wordsworth-s3, wordsworth-openbao, wordsworth-apikeys, wordsworth-oidc (oauth2-proxy) en wordsworth-tunnel (Cloudflare-run-token) (namespace wordsworth), seaweedfs-s3-config (namespace seaweedfs), operator-oauth (namespace tailscale), openbao-keys (namespace openbao).

# Status van de hele straat
kubectl get applications -n argocd | grep -E 'wordsworth|ollama|opensearch|openanonymiser|cnpg|seaweedfs'
kubectl -n wordsworth get pods
kubectl -n cnpg-database get cluster homelab-pg # 3 instances, Cluster in healthy state

# API-health (in-cluster of via de tailnet-hostname)
kubectl -n wordsworth port-forward svc/wordsworth-api 8000:8000 &
curl -s localhost:8000/health

Config-wijziging die de API moet zien? De pods lezen wordsworth-config via envFrom, en dat pikt alleen een herstart op. Wijzig daarom in dezelfde commit de pod-annotatie wordsworth/config in api.yaml, zodat Argo CD de pods uitrolt.

Ollama: modellen wisselen​

Een ander Ollama-model, of een nieuwe versie? Voeg de pin toe of wijzig hem in de init-container van cluster-config/infra/ollama/ollama-statefulset.yaml (naam plus de eerste 12 tekens van de digest, zoals ollama list die toont) en commit. Beide instances pullen hun eigen modellen en weigeren te starten als een digest afwijkt van de pin.

  • Wisselen van het embedding-model betekent het hele corpus opnieuw embedden: vectoren van twee modelversies mogen nooit in één index belanden.
  • CPU-only: pull plus cold start kost minuten per instance, en de instances rollen één voor één (PDB maxUnavailable: 1).
kubectl -n ollama get pods -o wide # ollama-0/-1 op verschillende workers
kubectl -n ollama logs ollama-0 -c models # "model bge-m3:latest = … (pinned)"

OpenSearch: het cluster van drie nodes​

Sinds 2026-09-26 zoekt Wordsworth op opensearch-cluster (StatefulSet, drie pods, één per worker). De oude single-node opensearch draait er tot 2026-10-03 naast als rollback; daarna gaat hij weg.

kubectl -n opensearch get pods -l app=opensearch-cluster -o wide
kubectl -n opensearch port-forward svc/opensearch-cluster 9200:9200 &
curl -s 'localhost:9200/_cluster/health?pretty' # status green, number_of_nodes 3
curl -s 'localhost:9200/_cat/shards/wordsworth?v' # primary + replica op verschillende nodes
  • Onderhoud/drain: de PDB laat één pod tegelijk gaan. Met één node weg kan het cluster tijdelijk yellow zijn, maar het serveert door; de pod van een ontbrekende worker wacht Pending tot die terug is (het volume is lokaal).
  • No rollback any more. The old single node and its volume were removed on 2026-10-03, after a week in which nothing needed them. Recovery from a lost index is a re-index from the database and the object store, not a switch back.
  • vm.max_map_count zet een privileged init-container per pod; zonder die waarde weigert OpenSearch te starten.
Geheugen-tuning ingest

/ingest buffert PDF-uploads in het API-proces. Worker-recycling staat bewust uit (het liet ingest-batches vallen); de memory-limit staat daarom ruim op 8Gi zodat een lange run plus een grote outlier-PDF past. Grote corpora gaan batch-gewijs, niet in één call.

OpenAnonymiser: schalen & rollouts​

OpenAnonymiser draait met 3 replica's, één per worker (harde anti-affinity). Twee dingen om te weten bij een rollout of incident:

  • maxSurge: 0. Met precies 3 nodes en één pod per node kan een rolling update nooit een 4e pod surgen — die zou unschedulable zijn en de rollout deadlocken. Er wordt dus per node één pod vervangen (maxUnavailable: 1).
  • TCP-probes, geen HTTP. Eén CPU-worker blokkeert /api/v1/health tijdens een GLiNER-forward-pass; met HTTP-probes werden drukke pods uit de Service-endpoints getrokken en zagen callers "No route to host". Een pod die connecties accepteert is ready — de startupProbe (wél HTTP) bewaakt dat het model echt geladen is voordat de pod meedoet.

OpenBao: bootstrap & unseal​

OpenBao deployt via Argo CD sealed + uninitialised — bewust. Initialiseren produceert de kroonjuwelen (unseal-key + root-token) en dat doet de operator zelf, zodat dat materiaal alleen in de eigen terminal belandt: nooit in Git, het cluster of een agent-context. Het volledige stappenplan staat in cluster-config/infra/openbao/README.md.

alias bao='kubectl -n openbao exec -i openbao-0 -- env BAO_ADDR=http://127.0.0.1:8200 bao'

bao operator init -key-shares=1 -key-threshold=1 # output OFFLINE bewaren
bao operator unseal <UNSEAL_KEY>
bao status # Sealed: false, Initialized: true

Daarna (met het root-token): Transit enablen, de wordsworth-KEK aanmaken en een scoped token uitgeven dat alléén onder die KEK mag wrappen/unwrappen — dat token gaat als Secret wordsworth-openbao naar de API. Het token heeft een 768h-period (32 dagen): verloopt het, dan faalt alles wat OpenBao nodig heeft (ingest, herverwerken, reveal) met een kale 403. Daarom verlengt de CronJob wordsworth-openbao-renew het wekelijks (maandag 04:23) met renew-self, zonder root-token. Een falende Job is het signaal: los het op of geef het token opnieuw uit vóór de period om is.

kubectl -n wordsworth get cronjob wordsworth-openbao-renew # LAST SCHEDULE
kubectl -n wordsworth get jobs --sort-by=.metadata.creationTimestamp | tail -3

Auto-unseal (lab): na bootstrap staat de unseal-key in het openbao-keys-Secret; een postStart-hook unsealt automatisch na elke pod-restart, dus de straat heelt zichzelf. De hook faalt nooit hard (liveness is TCP, een sealed-maar-levende pod overleeft) en het Secret is optional, zodat de pod ook vóór bootstrap opkomt. Handmatige fallback:

kubectl -n openbao exec openbao-0 -- env BAO_ADDR=http://127.0.0.1:8200 \
bao operator unseal "$(kubectl -n openbao get secret openbao-keys \
-o jsonpath='{.data.unseal_key}' | base64 -d)"

netnl-facade: beheren & tenants uitgeven​

De publieke batch-API-facade (zie Architectuur) is een Argo CD-app (apps/infrastructure/netnl.yaml); alle wijzigingen gaan via Git. Details: cluster-config/infra/netnl/README.md.

Prerequisite-secrets (out-of-band, namespace netnl, nooit in Git):

  • netnl-upstream — HTTP-Basic-credentials van de batch-user op de VPS-instance (aangemaakt met upstream's user_manage.sh). Roteren = Secret opnieuw aanmaken + Deployment herstarten.
  • netnl-tunnel — het run-token van de Cloudflare Tunnel (TUNNEL_TOKEN); de ingress-regels zelf staan remotely-managed bij Cloudflare (Zero Trust → Tunnels).
  • netnl-measure — voor de dagelijkse showcase-meting: INTERNETNL_CREDENTIAL (de tenant showcase op de facade) plus ssh-privatekey, een deploy key met schrijfrechten op precies één repo. Bewust geen PAT — die zou voor alle repo's van het account gelden.

Daarnaast is er een out-of-band CoreDNS-rewrite (kube-system) die netnl.westerweel.work in-cluster naar de egress-Service wijst — de tailscale-operator muteert die Service naar ExternalName, dus de Application heeft ignoreDifferences op de Service-spec.

# Tenant-credential uitgeven (wachtwoord wordt éénmalig geprint)
kubectl -n netnl exec deploy/netnl -- netnl-admin user add <naam>

# Nieuwe image-versie: digest resolven en pinnen in deployment.yaml + prune-cronjob.yaml
docker buildx imagetools inspect ghcr.io/mwest2020/internetnl-cli:sha-<short>

Acceptatie-check: wijs de internetnl-CLI met een tenant-credential naar de publieke hostname — die moet ongewijzigd werken (alleen de INTERNETNL_*-variabelen anders).

Dagelijkse meting controleren of handmatig draaien​

De netnl-measure-CronJob draait om 05:17 UTC, meet via het publieke endpoint en commit alleen bij een échte wijziging (geen scoreverandering = geen lege commit).

kubectl -n netnl get cronjob netnl-measure # LAST SCHEDULE
kubectl -n netnl get jobs -l job-name --sort-by=.metadata.creationTimestamp | tail -5

# Logs van de laatste run: eerst de meting, dan de publicatie
kubectl -n netnl logs job/<job> -c measure
kubectl -n netnl logs job/<job> -c publish

# Buiten de schedule om draaien
kubectl -n netnl create job --from=cronjob/netnl-measure netnl-measure-adhoc

De meting duurt minuten (activeDeadlineSeconds: 2700, concurrencyPolicy: Forbid). Faalt publish op ssh, controleer dan of netnl-measure de deploy key bevat — de container heeft readOnlyRootFilesystem en krijgt zijn /etc/passwd uit de ConfigMap, omdat ssh zonder passwd-entry voor uid 1000 weigert te starten.

HTTP 530 op api.westerweel.work​

Een 530 komt van het Cloudflare-edge en betekent: geen bereikbare connector. Kijk dus eerst naar de cloudflared-pods, niet naar de facade.

kubectl -n netnl get pods -l component=tunnel # 2 replica's verwacht
kubectl -n netnl logs -l component=tunnel --tail=50 # "connections active", DNS-fouten
kubectl -n netnl get events --field-selector reason=Killing

Bekende oorzaken, alle al gemitigeerd in tunnel.yaml:

Symptoom in de logsMitigatie
SRV-lookup faalt bij opstarten (CoreDNS SERVFAIL)ndots: 2 + publieke resolvers (1.1.1.1, 1.0.0.1) áchter de cluster-resolver
read udp …:53: i/o timeout op de fallback-resolverDNS over TCP (use-vc), attempts: 3, timeout: 2
accept stream listener encountered a failure (QUIC) → no more connections active and exiting--protocol http2: cloudflared over TCP i.p.v. QUIC
Herstart van één podtweede replica neemt het verkeer over

Blijven er herstarts komen, controleer dan of beide replica's echt op verschillende nodes staan — de anti-affinity is preferred — en of de logs nog quic noemen (dan is de --protocol-arg niet actief).

Buzz-relay deployen (VM 109)​

De relay-VM wordt geprovisioned met Terraform (terraform/buzz-relay/, clone van template 9002 ubuntu-24.04-large) en geconfigureerd met Ansible:

ansible-playbook -i inventory/buzz-relay-hosts.yml playbooks/deploy-buzz-relay.yml
  • Het playbook richt alleen de host in (Docker, /opt/buzz-relay). De stack zelf komt uit MWest2020/ratatoskr deploy/ en wordt uitgerold volgens docs/how-to/installeren.md daar. De echte .env leeft alleen op de host (0600).
  • Geen Caddy/certbot in deze stack: de relay is LAN/tailnet-only.

Applicaties deployen (Proxmox-VM's)​

De Nextcloud-tenants, proxy en Portainer draaien als Docker-compose-stacks op de laptop-Proxmox-VM's. Deploy via Ansible:

ansible-playbook -i inventory/proxmox-hosts.yml playbooks/deploy-nextcloud.yml
ansible-playbook -i inventory/proxmox-hosts.yml playbooks/deploy-proxy.yml
ansible-playbook -i inventory/proxmox-hosts.yml playbooks/deploy-portainer.yml

CrowdSec uitrollen (edge-detectie + blocking op de proxy)​

CrowdSec draait naast Caddy op de proxy-VM (192.168.178.50): de engine parst Caddy's JSON-access-log en genereert alerts/decisions, en sinds fase A.2 dwingt een Caddy-L7- bouncer die decisions af — een gebande IP krijgt een 403. Achtergrond: zie Beslissingen.

Prerequisite: Caddy schrijft zijn access-log naar de gedeelde host-bind-mount /var/log/caddy/access.log (de (secured)-snippet in de Caddyfile). De volgorde is crowdsec eerst, dan de proxy — deploy-crowdsec-proxy.yml zet de LAPI, het crowdsec-lapi-netwerk en de bouncer-key (/opt/proxy/.env) klaar die de proxy-stack nodig heeft bij start:

# 1. Engine + crowdsec-lapi-net + bouncer registreren (schrijft /opt/proxy/.env)
ansible-playbook -i inventory/proxmox-hosts.yml playbooks/deploy-crowdsec-proxy.yml

# 2. Custom Caddy-image (mét bouncer) bouwen + proxy omwisselen
ansible-playbook -i inventory/proxmox-hosts.yml playbooks/deploy-proxy.yml

De crowdsec-deploy is zelf-verifiërend: hij faalt hard als cscli lapi status niet binnen ~1 min gezond opkomt (collections + LAPI-startup duren even) en print daarna cscli metrics.

Inspecteren:

ssh 192.168.178.50 'docker exec crowdsec cscli bouncers list' # caddy-bouncer → Valid
ssh 192.168.178.50 'docker exec crowdsec cscli metrics'
ssh 192.168.178.50 'docker exec crowdsec cscli alerts list'
ssh 192.168.178.50 'docker exec crowdsec cscli decisions list' # actieve bans

IP bannen / unbannen (handmatig)​

De bouncer pullt nieuwe decisions elke 15s (ticker_interval), dus een ban/unban wordt na ~15s actief op de proxy.

# Bannen (tijdelijk — altijd een duur meegeven)
ssh 192.168.178.50 'docker exec crowdsec cscli decisions add --ip 203.0.113.7 --duration 4h --reason "handmatig"'

# Unbannen
ssh 192.168.178.50 'docker exec crowdsec cscli decisions delete --ip 203.0.113.7'

Enforcement testen zonder echte aanvaller: ban een IP, doe een request en verwacht 403; verwijder de decision en verwacht weer een normale respons (302).

Client-IP vóór go-live

Achter de Docker-userland-proxy ziet Caddy nu de bridge-gateway (172.20.0.1, RFC1918) i.p.v. de echte client. CrowdSec whitelist RFC1918 standaard → bij écht publiek verkeer worden aanvallen weggewhitelist. Fix vóór de proxy scherp publiek gaat: trusted_proxies

  • XFF in de Caddyfile, of userland-proxy: false op de daemon.

Homelab gracefully afsluiten (stroomonderbreking)​

scripts/graceful-shutdown.sh sluit de hele homelab tweefasig en parallel af. Draai het vanaf een host die de onderbreking zelf overleeft: jumpy of alma bij een geplande onderbreking, of de UPS-master bij een onbeheerde (zie HOMELAB_SELF).

Tweefasig vanwege quorum: px-01/02/03 vormen een 3-node Proxmox-cluster. Zodra twee leden gehalt zijn is de derde niet meer quorate en blokkeert qm shutdown op "cluster not ready - no quorum?" — precies wanneer die zijn eigen VM's nog moet opruimen. Daarom eerst alle gasten op álle hosts omlaag, en pas daarna de hosts zelf.

FaseWat
1per host parallel: qm/pct shutdown van elke running gast, pollen tot de host 0 gasten meldt
2pas ná fase 1: shutdown -h now op alle hosts, parallel
3pollen met ping tot alles down is; het script blijft zelf leven

Parallel in plaats van serieel, omdat serieel worst case ~4,5 minuut per host is — ~18 minuten voor vier hosts, meer dan een UPS bij vollast volhoudt. Parallel is de totaaltijd die van de langzaamste host: in de praktijk één tot vier minuten.

./scripts/graceful-shutdown.sh --dry-run # print wat het zou doen, muteert niets
./scripts/graceful-shutdown.sh --phase1-only # gasten omlaag, hosts blijven up
./scripts/graceful-shutdown.sh # volledige afsluiting

Blijft een host in fase 1 met gasten zitten, dan gaat fase 2 tóch door: een host halten is netter dan wachten tot de accu leeg is. HOMELAB_STRICT=1 breekt in dat geval juist af.

Alle limieten zijn env-tunable: HOMELAB_HOSTS, HOMELAB_SSH_KEY, HOMELAB_VM_TIMEOUT, HOMELAB_POLL_MAX, HOMELAB_POLL_INTERVAL, HOMELAB_DOWN_POLL_MAX, HOMELAB_SSH_TIMEOUT, HOMELAB_STRICT, HOMELAB_LOCK. Een tweede gelijktijdige run stopt op een flock — nodig omdat apcupsd onbattery herhaald kan afvuren.

Automatisch bij netstroomverlies​

Sinds 2026-09-16 hoeft niemand dit handmatig te starten. De proxmox-laptop is de enige host met een accu, en dus de enige die een onderbreking overleeft om de rest af te sluiten. Zijn eigen AC-status is het signaal.

Geen apcupsd. Dat was het oorspronkelijke ontwerp, maar het vraagt een UPS met USB-verbinding en een daemon die op geen enkele host geïnstalleerd stond. De accu van de laptop is de UPS; /sys/class/power_supply/AC/online is één bestand en heeft geen daemon nodig.

De keten:

udev90-homelab-power.rules — online == 0 start de service. Event-gestuurd, niet pollend: elke seconde pollen is een seconde accu.
servicehomelab-power-lost.service → power-watch.sh
scriptwacht HOMELAB_POWER_GRACE (90 s), leest de AC-status opnieuw, en start dan pas graceful-shutdown.sh met HOMELAB_SELF

Die respijttijd is het hart ervan: een flikkering van twee seconden mag de hele homelab niet platleggen. Komt de stroom terug, dan gebeurt er niets en zegt het logboek dat.

Kan de AC-status niet gelezen worden, dan sluit het script niet af maar eindigt het niet-nul, zodat de unit op failed komt. Een onterechte afsluiting van de hele homelab is erger dan een gemiste — maar niet kunnen vaststellen is geen "alles in orde", en dat hoort iemand te zien.

Uitrollen:

cd ansible
ansible-playbook -i inventory/hypervisors.yml playbooks/deploy-ups-master.yml

De playbook weigert op een host zonder accu, want daar is de hele opzet zinloos.

Welke host de UPS-master is, staat in de inventarisgroep ups_master (inventory/hypervisors.yml). Dat is vandaag proxmox-laptop — dezelfde host als in hypervisors, maar met een eigen naam omdat het een andere rol is. Komt er ooit een echte UPS, dan verhuist die groep en verder niets.

De accu van de UPS-master​

check-battery.sh draait dagelijks (homelab-battery-check.timer) en meldt drie dingen: te weinig lading, een versleten accu, en — het belangrijkste — wel netstroom maar geen lading erbij.

Dat laatste is geen theorie. Op 2026-09-16 stond die accu op 28% en meldde status: Charging, terwijl er in 45 seconden nul µAh bij kwam en de stroom 0 was. De cellen waren prima (98% van fabriekscapaciteit); de laadregelaar hing. De adapter er even uit en weer in trekken loste het op, en daarna liep hij met 0,48 A te laden.

Daarom vertrouwt de controle status niet maar meet hij het verschil over tijd. status: Charging is op deze laptop niet meer dan "er is netstroom" — de EC-firmware meldt manufacturer: Notebook, model_name: BAT, cycle_count: 0, allemaal plaatshouders.

/usr/local/sbin/check-battery.sh # rapport, niet-nul bij een probleem
/usr/local/sbin/check-battery.sh --json # voor een monitor

Grenzen zijn env-tunable: HOMELAB_BAT_MIN_PCT (40), HOMELAB_BAT_MIN_HEALTH (70), HOMELAB_BAT_SETTLE (45 s).

Let op die laatste bij het handmatig draaien: de controle wacht die 45 seconden om het laden te meten in plaats van status te geloven. Een ssh-commando met een kortere timeout kapt hem af.

Draaien óp een van de hosts (UPS-master)​

Draait het script op een Proxmox-host die zelf moet blijven leven, zet dan HOMELAB_SELF op diens adres. Die host doet fase 1 lokaal (geen ssh naar zichzelf) en wordt in fase 2 en 3 overgeslagen. Vereist root, want qm/pct lopen dan lokaal.

HOMELAB_SELF=<adres van deze host> \
HOMELAB_HOSTS="<adressen van alle vier de hosts, spatie-gescheiden>" \
./scripts/graceful-shutdown.sh

De hosts worden op hun tailnet-adres aangesproken (het script kent ze als default); vul hier de adressen uit scripts/graceful-shutdown.sh in.

Power-up daarna (handmatig): hosts weer aanzetten — de K8s-VM's (onboot=1) starten vanzelf. Verifieer:

pvecm status # 3 nodes quorate
kubectl get nodes # 6× Ready (vanaf jumpy)

De docs-agent: wie houdt deze documentatie bij?​

scripts/docs-freshness-agent.sh draait wekelijks op agent-lxc (cron, maandag 03:00). Hij pullt de repo, bepaalt wat er is veranderd sinds de laatste commit die docusaurus/ raakte, laat claude headless de documentatie bijwerken en commit direct op main.

Twee dingen beschermen die automatische commit:

  • De prompt zegt dat er nooit Tailscale-IP's, tokens of secrets in de docs mogen.
  • Een harde scrub-gate in de wrapper, los van de prompt: een grep over de diff die commit én push blokkeert bij een Tailscale-IP, een tskey-, of iets met de vorm van een UUID. Die gate vertrouwt het model niet, en dat is opzet.

Elke run meldt zich in #runs​

Sinds 2026-09-16, en om een goede reden. Daarvóór zakte de uitkomst in ~/docs-agent.log — een bestand dat niemand leest. In dat log stond één afgebroken run: de scrub-gate had gevoelige data in de diff gevonden en de documentatie dus níét bijgewerkt. Dat was nooit opgemerkt.

Die ene keer dichtte de volgende run het gat vanzelf, want het referentiepunt is "laatste commit die docusaurus/ raakte". Maar bleef die gate afgaan, dan stond de documentatie maandenlang stil terwijl cron elke week netjes zijn ding deed — een storing zonder waarnemer. Op dezelfde dag bleek een systemd-timer op precies die manier 82 keer achter elkaar te zijn omgevallen zonder dat iemand het zag.

Vier uitkomsten, elk één regel in #runs als orchestrator:

meldingbetekenis
geen wijzigingen sinds de vorige docs-updateer was niets te doen
gedraaid, maar geen doc-wijzigingen nodigde agent keek en vond het in orde
docs bijgewerkt en naar main gepushthet gewone geval
AFGEBROKEN — scrub-gateer staat gevoelige data in de diff; docs níét bijgewerkt, dit moet iemand nakijken

Een mislukte melding laat de agent nooit falen: documentatie bijwerken is het werk, melden is het verslag ervan. Staat ratatoskr niet op de host, dan meldt hij niets en draait hij gewoon door. Instelbaar met DOCS_AGENT_PING, DOCS_AGENT_IDENT en DOCS_AGENT_CHAN.