Runbooks
Operationele how-to's, afgeleid van de Ansible-playbooks en Terraform-modules in de repo.
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 laatscripts/pin_check.py(in de wordsworth-repo) rood worden in CI. WORDSWORTH_GRANT_ISSUER_LABELSniet leegmaken zolang caller-auth aanstaat: leeg betekent hier niemand (anders dan bijWORDSWORTH_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
yellowzijn, 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_countzet een privileged init-container per pod; zonder die waarde weigert OpenSearch te starten.
/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/healthtijdens 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'suser_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 tenantshowcaseop de facade) plusssh-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 logs | Mitigatie |
|---|---|
| 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-resolver | DNS 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 pod | tweede 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/ratatoskrdeploy/en wordt uitgerold volgensdocs/how-to/installeren.mddaar. De echte.envleeft 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).
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: falseop 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.
| Fase | Wat |
|---|---|
| 1 | per host parallel: qm/pct shutdown van elke running gast, pollen tot de host 0 gasten meldt |
| 2 | pas ná fase 1: shutdown -h now op alle hosts, parallel |
| 3 | pollen 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:
| udev | 90-homelab-power.rules — online == 0 start de service. Event-gestuurd, niet pollend: elke seconde pollen is een seconde accu. |
| service | homelab-power-lost.service → power-watch.sh |
| script | wacht 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
grepover de diff die commit én push blokkeert bij een Tailscale-IP, eentskey-, 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:
| melding | betekenis |
|---|---|
| geen wijzigingen sinds de vorige docs-update | er was niets te doen |
| gedraaid, maar geen doc-wijzigingen nodig | de agent keek en vond het in orde |
| docs bijgewerkt en naar main gepusht | het gewone geval |
| AFGEBROKEN — scrub-gate | er 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.