108 Commits

Author SHA1 Message Date
Hermes Platform Engineer
d266086c1b fix(homeassistant): pin image to 2026.7.4 to avoid cffi version mismatch with Python 3.14 2026-07-28 09:01:06 +00:00
Roger Oriol
d3798f47ac fix homeassistant configuration.yaml not being writable 2026-07-28 00:31:23 +02:00
Roger Oriol
707ec39291 fix hermes api not being reachable 2026-07-26 17:19:20 +02:00
Roger Oriol
5bb1066d0d fix hermes api not being reachable 2026-07-26 17:08:27 +02:00
Roger Oriol
575c7bf9e0 agents fix language and time zone 2026-07-26 14:59:58 +02:00
Roger Oriol
4222582a54 Merge branch 'main' of https://git.rogi.casa/roger/k3s-cluster 2026-07-26 14:38:02 +02:00
Roger Oriol
4f6255800b enable api access for agents home manager and platform engineer 2026-07-26 14:37:37 +02:00
d1e86ce5ec fix(prometheus): add restart annotation to trigger rollout and clean up stale pods 2026-07-26 13:26:46 +02:00
Platform Engineer Agent
d5e4e37358 fix(prometheus): bump memory limit from 2Gi to 4Gi to prevent OOMKilled 2026-07-26 10:44:19 +00:00
platform-engineer-agent
63557ef1b4 fix(argocd): delete argocd/apps/argocd.yaml (moved to argocd/argocd.yaml) 2026-07-26 07:04:51 +02:00
platform-engineer-agent
65bcb0c7a4 fix(argocd): remove argocd app from apps/ to avoid circular dependency 2026-07-26 07:02:15 +02:00
Roger Oriol
7af7ae66de increase qwen3.6 models context available to 131k 2026-07-26 01:29:23 +02:00
Roger Oriol
5eaea8c387 fix home manager discord integration 2026-07-26 01:00:25 +02:00
Roger Oriol
8f24290f38 fix platform engineer daily cron 2026-07-26 00:27:38 +02:00
Roger Oriol
a7e78c18c3 fix pod restart cron 2026-07-25 20:19:27 +02:00
Roger Oriol
8caf49fdf6 fix home manager crons 2026-07-25 20:03:16 +02:00
Roger Oriol
842815d370 home manager crons fix 2026-07-25 00:54:46 +02:00
Roger Oriol
4694034a04 set qwen models parameters to allow for a higher context window 2026-07-24 21:30:01 +02:00
Roger Oriol
bc165d3cf1 change home-manager model to qwen3.6-35b-a3b 2026-07-24 20:37:43 +02:00
Roger Oriol
c982b33015 increase llamacpp models context window! 2026-07-24 20:11:30 +02:00
Roger Oriol
496e46076c set platform engineer url to platform-engineer.rogi.casa 2026-07-23 23:57:41 +02:00
Roger Oriol
04bd4d8e5e deploy both qwen3.6 versions 2026-07-23 22:10:44 +02:00
Roger Oriol
8ef9db1e37 fix llamacpp 2026-07-23 00:26:58 +02:00
Roger Oriol
70e4baf5e3 fix llamacpp image 2026-07-23 00:12:08 +02:00
Roger Oriol
0143ebefd9 new llamacpp service 2026-07-22 23:54:52 +02:00
Roger Oriol
5f7f1bd52a set litellm master key 2026-07-22 22:03:40 +02:00
Roger Oriol
b58abda201 update gitea runner image 2026-07-22 21:32:09 +02:00
Roger Oriol
4307545436 drop master key from litellm general settings 2026-07-22 21:26:38 +02:00
Roger Oriol
84be8f2c36 merge 2026-07-22 21:20:27 +02:00
Roger Oriol
a8c7df67fe increase litellm memory allowance 2026-07-22 21:17:51 +02:00
platform-engineer
097fe2e0e7 fix(prometheus): bump memory limit from 1Gi to 2Gi due to OOM
fix(litellm): add resource limits (512Mi/1Gi) to prevent unbounded memory consumption
2026-07-22 05:58:23 +00:00
Platform Engineer
6df0be81c9 fix(platform): fix high-restart pods - prometheus nodeSelector, litellm resources, gitea-runner limits
- Prometheus: change nodeSelector from hardware=high-memory (nonexistent label) to kubernetes.io/arch: amd64
  Fixes OOMKilled cause - pod was being scheduled on wrong nodes.
- litellm-deployment: add resource limits (2Gi/500m requests, 4Gi/2000m limits) + pin image to v1.34.0
  Fixes OOMKilled cause - no resource limits were set.
- gitea-runner: pin image to v0.12.0 from :latest + add resource limits (512Mi/250m requests, 1Gi/500m limits)
  Addresses extreme restart count (281) caused by unbounded memory usage and rolling image updates.
2026-07-21 20:28:12 +00:00
a5291da0b2 fix(gitea): bump restartedAt annotation to clear stuck gitea-runner pods (281 restarts from DNS resolution failures) 2026-07-21 15:36:57 +02:00
platform-engineer
075bdd8ca3 fix(gitea-runner): pin image tag to v0.7.2, add resource limits (256Mi/100m req, 512Mi/500m lim) to prevent OOM restarts 2026-07-20 22:19:37 +00:00
Roger Oriol
e44d7ba1fc Merge branch 'main' of https://git.rogi.casa/roger/k3s-cluster 2026-07-19 12:01:21 +02:00
Roger Oriol
f1005cd426 change openwebui url to ai.rogi.casa 2026-07-19 12:00:52 +02:00
Roger Oriol
43c0c2561e fix hermes write folder 2026-07-18 19:29:52 +02:00
8334cd48f8 Merge pull request 'fix(myorg-assistant): add git-clone initContainer to git-sync CronJob' (#26) from fix/myorg-git-sync-clone into main
Reviewed-on: #26
2026-07-18 19:25:00 +02:00
Hermes Platform Engineer
ba7e05a73d fix(myorg-assistant): add git-clone initContainer to git-sync CronJob
The git-sync CronJob only ran `git pull`/`git push` against /data/myorg
but never ensured the repo existed. The clone was solely the Deployment's
git-clone initContainer's job, and when that didn't populate the volume the
cron pod failed with 'Not a git repository: /data/myorg' on every run (and
swallowed the error, exiting 0).

Add an idempotent git-clone initContainer (guarded by [ ! -d /data/myorg/.git ])
so the cron job self-heals and actually syncs.

Refs: myorg-git-sync-* 'Pull: Error: Not a git repository' (last 24h+)
2026-07-18 17:21:26 +00:00
Roger Oriol
8bc3025296 fix platform engineer not allowed to respond to discord messages 2026-07-18 19:20:23 +02:00
Roger Oriol
7a7d67bedc configure git token env variable in myorg assistant cronjobs 2026-07-18 19:03:46 +02:00
Roger Oriol
19cdc77880 add git token env var to deadline checker 2026-07-18 18:59:36 +02:00
Roger Oriol
8983f482d0 give more memory to homeassistant 2026-07-17 23:59:50 +02:00
Roger Oriol
0b27cefd13 myorg assistant cron jobs env variables 2026-07-15 00:32:23 +02:00
Roger Oriol
279cc1f235 configure litellm models 2026-07-14 21:45:18 +02:00
Roger Oriol
cf6e2784fe configure litellm models 2026-07-14 21:36:38 +02:00
Roger Oriol
dad38347e7 upgrade n8n memory requirements 2026-07-14 18:50:28 +02:00
Roger Oriol
a5b90994a4 fix myorg assistant 2026-07-11 19:22:57 +02:00
Roger Oriol
04b736287b fix argocd redirect 2026-07-09 23:48:47 +02:00
Roger Oriol
8c6950fd43 Merge branch 'main' of https://git.rogi.casa/roger/k3s-cluster 2026-07-09 00:45:40 +02:00
Roger Oriol
81dfe6fd60 fix argocd rbac and create agents.md 2026-07-09 00:45:10 +02:00
5d80abf3e8 Merge pull request 'fix: myorg-assistant cronjobs ImagePullBackOff + argocd root drift loop' (#18) from platform-engineer-agent/k3s-cluster:fix-merge-to-main into main
Reviewed-on: #18
2026-07-09 00:22:54 +02:00
48f18d2a3e Merge branch 'main' into fix-merge-to-main 2026-07-09 00:22:22 +02:00
Roger Oriol
ce08365e06 revert to qwen3.6 for platform engineer 2026-07-07 23:52:37 +02:00
Platform Engineer
0794153e56 fix(myorg-assistant): point cronjobs at registry image + add imagePullSecrets
CronJob pods were stuck in ImagePullBackOff because they referenced
the local-only image 'myorg-assistant:latest' which is not present
on the node. Switch all 5 cronjobs to the Gitea registry image
git.rogi.casa/roger/myorg-assistant/myorg-assistant:fcf79bf
(matching the Deployment), set imagePullPolicy: Always, and add
imagePullSecrets: gitea-registry so they can authenticate to the
private registry.
2026-07-06 14:11:44 +00:00
Platform Engineer
fc1b4383c1 fix(argocd): add ignoreDifferences to root app to stop Application CRD drift loop
The k3s-cluster-root app-of-apps has been in a continuous Synced<->OutOfSync
oscillation (489 transitions in 24h). Each cycle, ArgoCD syncs the 'argocd'
Application CRD, succeeds, then immediately detects drift because ArgoCD
adds status/operation fields to Application resources at runtime.

Adding ignoreDifferences for /status and /operation on Application resources
stops the drift loop while keeping the app-of-apps functional.
2026-07-06 14:11:44 +00:00
Roger Oriol
6b697c9665 configure higher cron schedules 2026-07-05 23:30:51 +02:00
Roger Oriol
08bb4de278 fix platform-engineer agent 2026-07-05 21:01:28 +02:00
Roger Oriol
2cccbc019f fix rbac role for platform engineer cron seed 2026-07-05 18:15:10 +02:00
Roger Oriol
3f29b77e55 remove k8s event exporter with bad sink 2026-07-05 17:48:38 +02:00
Roger Oriol
440fcf858f new argocd user platform-engineer 2026-07-05 17:39:54 +02:00
Roger Oriol
9fd7d02c7c loki + promtail new monitoring services 2026-07-05 17:32:15 +02:00
Roger Oriol
85c8cbfc31 fix searxng 2026-07-04 00:53:17 +02:00
Roger Oriol
9de2897f46 fix searxng 2026-07-04 00:51:11 +02:00
Roger Oriol
c3a07f75ab fix searxng 2026-07-04 00:44:49 +02:00
Roger Oriol
a567184347 fix searxng 2026-07-04 00:35:17 +02:00
Roger Oriol
54059cdb72 searxng 2026-07-04 00:19:48 +02:00
Roger Oriol
7faaa53855 searxng 2026-07-04 00:10:23 +02:00
Roger Oriol
6e689accd0 fix hermes cron 2026-06-29 00:48:23 +02:00
Roger Oriol
1145214e24 fix hermes qwen model 2026-06-29 00:21:57 +02:00
Roger Oriol
9eb8d344fa fix pihole liveness probe 2026-06-28 19:08:04 +02:00
Roger Oriol
22ef2a38b2 pihole unbound 2026-06-28 09:34:43 +02:00
Roger Oriol
d00c6fb63d fix qwen model name in hermes 2026-06-28 09:28:35 +02:00
Roger Oriol
734962d198 fix hermes liveness probe 2026-06-28 00:43:09 +02:00
Roger Oriol
4d9195b32d use hermes stock image for platform engineer 2026-06-27 21:00:03 +02:00
Roger Oriol
54579df4b3 use hermes stock image for platform engineer 2026-06-27 20:40:42 +02:00
Roger Oriol
3f3467cb13 gitea registry ingress 2026-06-27 11:46:53 +02:00
Roger Oriol
6e02d9a885 new platform engineer agent 2026-06-27 00:09:39 +02:00
Roger Oriol
d8012dfb6c monitoring: add dashboard ideas doc
Survey of dashboards that could be built from existing and not-yet-enabled
metrics across the cluster's services (traefik, coredns, metallb, cert-manager,
phoenix, litellm, gitea, postgres, etc.), with per-service enable steps and
a recommended priority order.
2026-06-26 20:22:54 +02:00
Roger Oriol
bf1387dc3e monitoring: add Grafana dashboards + kube-state-metrics & node-exporter
Dashboards (provisioned via ConfigMaps into Grafana pod, 'K3s Cluster' folder):
- Cluster Overview: per-namespace CPU/mem/net/fs, pod counts, pod health (KSM)
- Pods & Services: per-pod CPU/mem/net/fs, throttling, pod status, restarts, PVCs
- Nodes: per-node CPU%/mem%, load average, disk usage, network (node-exporter)
- Control Plane & API Server: request rate, latency p95, 5xx, kubelet/PLEG
- Prometheus Self-Monitoring: ingestion, series, scrape duration, memory

Exporters (auto-scraped via existing kubernetes-service-endpoints job):
- kube-state-metrics: pod/deployment/PVC/replica state (kube_pod_status_phase,
  kube_pod_container_status_restarts_total, kube_persistentvolumeclaim_*)
- node-exporter (DaemonSet, hostNetwork): node_cpu_seconds_total,
  node_memory_*, node_filesystem_*, node_load*, node_network_*
2026-06-26 19:48:17 +02:00
Roger Oriol
2eab82b430 fix nas ingress 2026-06-26 19:01:08 +02:00
Roger Oriol
3cdd40153f fix nas ingress 2026-06-26 18:54:17 +02:00
Roger Oriol
9f74a88be7 fix nas ingress 2026-06-26 18:40:41 +02:00
Roger Oriol
586e95a57d fix nas ingress 2026-06-26 18:25:29 +02:00
Roger Oriol
9f7e34ef78 fix prometheus ingress 2026-06-26 18:06:01 +02:00
Roger Oriol
b43874bdcd Expose minecraft server over TCP via MetalLB
Minecraft Java Edition uses raw TCP on port 25565, not HTTP. The previous
ClusterIP Service + HTTP Ingress (Traefik 80/443) could not carry TCP 25565
traffic, so minecraft.rogi.casa:25565 was unreachable.

- Change Service to LoadBalancer with fixed IP 10.88.20.103 (dmz-pool),
  matching the pihole-dns pattern, so port 25565 is exposed directly.
- Remove the dead HTTP ingress (it routed HTTP to a TCP game port).
2026-06-26 13:38:43 +02:00
Roger Oriol
da2bae6fa5 Merge branch 'main' of https://git.rogi.casa/roger/k3s-cluster 2026-06-26 12:01:29 +02:00
Roger Oriol
e77e170421 fix(homeassistant): trust k3s pod/service CIDRs as X-Forwarded-For proxies
HA runs with hostNetwork on roger-nucbox-evo-x2 while Traefik runs on the
raspberrypi node, so requests arrive at HA from 10.88.20.11. The previous
trusted_proxies entry (10.88.88.0/24) did not include this address, causing
HA to reject X-Forwarded-For and return 400 on every ingress request.
2026-06-26 11:58:46 +02:00
Gitea Actions
ec947bd58a Update gym-tracker image to 945910a 2026-06-25 19:03:07 +00:00
Gitea Actions
3e57da467d Update myorg-assistant image to fcf79bf 2026-06-25 11:15:19 +00:00
Gitea Actions
9eecedc396 Update gym-tracker image to 0411783 2026-06-25 10:13:21 +00:00
Roger Oriol
ab6b5dc407 fix container registry url 2026-06-25 11:55:35 +02:00
Roger Oriol
723693eb07 take out gitea runner secret 2026-06-24 22:30:44 +02:00
Roger Oriol
3ed4acd7ec change gitea runner token 2026-06-24 22:20:53 +02:00
Roger Oriol
1bcfc13047 fix gitea config 2026-06-24 20:38:28 +02:00
Roger Oriol
b49918ed67 fix(phoenix): remove ServiceMonitor (no Prometheus Operator in cluster)
The ServiceMonitor CRD (monitoring.coreos.com) is not installed because this
cluster runs a standalone Prometheus deployment, not the Prometheus Operator.
The manifest itself noted 'only apply if Prometheus Operator is installed'.
Removing it unblocks the phoenix app sync.
2026-06-23 12:04:12 +02:00
Roger Oriol
66433ff0b1 fix tls: use letsencrypt-prod cluster-issuer for jellyfin/n8n/qbittorrent/myorg/phoenix/fava
The ingresses referenced a Cloudflare OriginIssuer 'prod-issuer' whose CRD
and controller are not installed in the cluster, so cert-manager could not
issue certs and Traefik served a default cert (invalid SSL). Switch to the
existing letsencrypt-prod ClusterIssuer with specific hostnames + per-app
secrets, matching the working ingresses (http-01 cannot issue wildcards).
2026-06-23 11:46:38 +02:00
Roger Oriol
872d2d0622 fix nas nfs server: use LAN IP 10.88.30.10 (pv nfs source is immutable + nodes dont use pihole) 2026-06-23 11:12:00 +02:00
Roger Oriol
67732d0898 fix contabilitat git url 2026-06-23 10:47:10 +02:00
Roger Oriol
47ab20dd55 fix qbittorrent nas url 2026-06-23 10:37:12 +02:00
Roger Oriol
c5e2a06c54 fix git url in fava 2026-06-23 10:34:36 +02:00
Roger Oriol
a6ac71c6b5 fix nas ip 2026-06-23 10:31:02 +02:00
Roger Oriol
139bb366bb gitea subdomain 2026-06-23 01:22:46 +02:00
Roger Oriol
f6562df066 create argocd apps declaratively 2026-06-23 01:03:06 +02:00
Roger Oriol
01321bf50c nas ingress 2026-06-23 00:31:04 +02:00
Roger Oriol
153cf16194 refactor ingresses 2026-06-23 00:26:29 +02:00
Roger Oriol
ce178d06c0 pihole namespace 2026-06-22 23:54:47 +02:00
Roger Oriol
e359984c73 pihole ingress 2026-06-22 23:51:35 +02:00
111 changed files with 6666 additions and 490 deletions

246
AGENTS.md Normal file
View File

@@ -0,0 +1,246 @@
# AGENTS.md - Guide for Coding Agents
This file provides essential information for AI coding agents working with this Kubernetes cluster project.
## Project Overview
This repository contains Kubernetes manifests for a K3s cluster running self-hosted services on the `rogi.casa` domain. The cluster is managed via **GitOps using ArgoCD** - all changes to the cluster are deployed automatically from this Git repository.
**⚠️ CRITICAL: Permission Model**
You **DO NOT** have permission to push changes to this repository. Before applying any changes to the cluster:
1. Make the necessary code changes to the manifests
2. Clearly present the changes to the user
3. Ask the user to review and push the changes
4. Wait for confirmation that changes have been pushed
5. Only then will ArgoCD automatically deploy the changes to the cluster
## Architecture & GitOps Workflow
### ArgoCD App-of-Apps Pattern
This project uses ArgoCD's "app-of-apps" pattern:
```
argocd-bootstrap.yaml (root Application)
argocd/apps/ (directory containing all Application manifests)
Individual Applications (one per service directory)
Kubernetes manifests in each service directory (e.g., pihole/, homeassistant/)
```
### Deployment Flow
1. You make changes to Kubernetes manifests in the repository
2. User reviews and pushes changes to the `main` branch
3. ArgoCD detects changes (automatically or on sync)
4. ArgoCD applies changes to the cluster with `prune: true` and `selfHeal: true`
5. Cluster state converges to match the Git state
### Key Files
- **`argocd-bootstrap.yaml`**: The root Application that bootstraps ArgoCD. Points to `argocd/apps/` directory. This is the only file that needs manual `kubectl apply` during initial setup.
- **`argocd/apps/project.yaml`**: ArgoCD AppProject defining permissions for all applications
- **`argocd/apps/*.yaml`**: Individual ArgoCD Application manifests (one per service)
- **`argocd/gen-apps.sh`**: Script to regenerate all ArgoCD manifests from the `APPS` array
## Repository Structure
```
k3s-cluster/
├── argocd-bootstrap.yaml # Root ArgoCD Application (app-of-apps)
├── argocd/
│ ├── apps/ # Individual ArgoCD Application manifests
│ │ ├── project.yaml # AppProject definition
│ │ ├── pihole.yaml # Application for pihole/
│ │ ├── homeassistant.yaml # Application for homeassistant/
│ │ └── ... # One per service
│ ├── gen-apps.sh # Generates argocd/apps/* manifests
│ └── ingress.yaml # ArgoCD's own ingress
├── <service-name>/ # Each service has its own directory
│ ├── namespace.yaml # (Optional) Namespace definition
│ ├── deployment.yaml # Main deployment/statefulset
│ ├── service.yaml # Service definition
│ ├── ingress.yaml # Ingress configuration
│ ├── configmap.yaml # (Optional) ConfigMaps
│ ├── pvc.yaml # (Optional) PersistentVolumeClaims
│ └── secret.yaml # (Optional) Secrets (rarely committed)
├── cert-manager/ # cert-manager installation manifests
├── nas/ # External NAS service configuration
├── monitoring/ # Prometheus + Grafana stack
└── README.md # Comprehensive project documentation
```
## Current Services
The cluster runs these services (each in its own directory):
- **argocd** - GitOps continuous delivery platform
- **cert-manager** - SSL certificate management (Let's Encrypt)
- **fava** - Beancount accounting web interface
- **gitea** - Self-hosted Git server
- **glance** - Personal dashboard
- **gym-tracker** - Workout tracking application
- **homeassistant** - Home automation
- **jellyfin** - Media server
- **litellm** - LLM proxy
- **minecraft-server** - Minecraft server
- **monitoring** - Prometheus + Grafana
- **myorg-assistant** - Organization assistant
- **n8n** - Workflow automation
- **nas** - External NAS proxy
- **openwebui** - Web UI for LLMs
- **phoenix** - AI observability platform
- **pihole** - Network-wide ad blocking
- **platform-engineer** - Platform engineering tools
- **qbittorrent** - Torrent client
- **searxng** - Meta search engine
- **vaultwarden** - Password manager (Bitwarden compatible)
## How to Make Changes
### Adding a New Service
1. Create a new directory: `mkdir new-service`
2. Create Kubernetes manifests in `new-service/`:
- `namespace.yaml` (if dedicated namespace needed)
- `deployment.yaml` or `statefulset.yaml`
- `service.yaml`
- `ingress.yaml`
- Any ConfigMaps, Secrets, PVCs needed
3. Add the service to `argocd/gen-apps.sh`:
- Add a line to the `APPS` array: `"new-service|namespace|new-service|true|true"`
- Format: `name|namespace|path|recurse|validate`
4. Run `./argocd/gen-apps.sh` to regenerate ArgoCD manifests
5. **Present changes to user for review and push**
### Modifying an Existing Service
1. Edit the relevant manifest(s) in the service directory
2. If changing ArgoCD configuration, also update `argocd/gen-apps.sh` and regenerate
3. **Present changes to user for review and push**
### Removing a Service
1. Remove the service directory: `rm -rf service-name/`
2. Remove from `APPS` array in `argocd/gen-apps.sh`
3. Run `./argocd/gen-apps.sh` to regenerate
4. **Present changes to user for review and push**
5. ArgoCD will automatically prune the resources from the cluster
## Common Patterns
### Ingress Configuration
Each service has its own `ingress.yaml` with:
- `ingressClassName: traefik` (K3s default)
- TLS configured with `cert-manager.io/cluster-issuer: letsencrypt-prod`
- Host-based routing (e.g., `pihole.rogi.casa`)
Example:
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: pihole
namespace: pihole
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
tls:
- hosts:
- pihole.rogi.casa
secretName: pihole-tls
rules:
- host: pihole.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: pihole-web
port:
number: 80
```
### Resource Management
- Each service typically has its own namespace
- Use ResourceRequests and Limits for all containers
- PVCs for persistent data
- ConfigMaps for configuration files
## Important Notes
### What You CAN Do
- Read and understand all manifests
- Create new manifest files
- Modify existing manifest files
- Run `./argocd/gen-apps.sh` to regenerate ArgoCD manifests
- Explain how the cluster works
- Troubleshoot issues by reading manifests
### What You CANNOT Do
- Push changes to the Git repository (no push permissions)
- Directly apply manifests with `kubectl apply` (unless explicitly asked)
- Access the Kubernetes cluster directly (unless explicitly configured)
- Create secrets that should remain private (those are managed manually)
### Secrets Management
Secrets are generally **not committed to the repository**. They must be created manually in the cluster:
```bash
kubectl create secret docker-registry gitea-registry \
--docker-server=gitea.rogi.casa \
--docker-username=<user> \
--docker-password=<token> \
-n <namespace>
```
## Workflow Summary
When asked to make changes:
1. **Understand** the current state by reading relevant files
2. **Modify** the manifests (create/edit files)
3. **Regenerate** ArgoCD manifests if needed (`./argocd/gen-apps.sh`)
4. **Present** the changes clearly to the user:
```
I've made the following changes:
- Modified pihole/deployment.yaml to update image version
- Regenerated argocd/apps/pihole.yaml
Please review and push these changes to deploy them.
```
5. **Wait** for user confirmation that changes are pushed
6. **Verify** (if possible) that ArgoCD has synced the changes
## Useful Commands (for reference)
```bash
# Regenerate ArgoCD manifests after modifying gen-apps.sh
./argocd/gen-apps.sh
# Check ArgoCD applications status (requires kubectl access)
kubectl get applications -n argocd
# View logs of a pod (requires kubectl access)
kubectl logs -n <namespace> <pod-name>
# Check ingress status (requires kubectl access)
kubectl get ingress -n <namespace>
```
## Questions?
If you're unsure about anything:
1. Read the comprehensive `README.md` in the repository root
2. Check existing service directories for examples
3. Ask the user for clarification before making changes
4. Remember: **never push without explicit user review and approval**

132
README.md
View File

@@ -25,18 +25,25 @@ Aquest clúster K3s gestiona els següents serveis:
```
.
├── README.md # Aquest fitxer
├── ingress.yaml # Configuració d'Ingress principal (Traefik)
├── nas.yaml # Servei extern per al NAS
├── argocd-bootstrap.yaml # App-of-apps: llavor per a ArgoCD (aplicar 1 cop)
├── <aplicació>/ # Cada aplicació té el seu directori
│ ├── deployment.yaml # Definició del Deployment
│ ├── service.yaml # Definició del Service
│ ├── ingress.yaml # Configuració d'Ingress (opcional)
│ ├── service.yaml # Definició del Service
│ ├── ingress.yaml # Configuració d'Ingress de l'aplicació
│ ├── namespace.yaml # Namespace dedicat (opcional)
│ ├── configmap.yaml # ConfigMaps (opcional)
│ ├── configmap.yaml # ConfigMaps (opcional)
│ └── pvc.yaml # PersistentVolumeClaims (opcional)
── monitoring/ # Stack de monitorització complet
── argocd/ # ArgoCD
│ ├── ingress.yaml # Ingress d'ArgoCD
│ ├── apps/ # Applications + AppProject declaratius
│ └── gen-apps.sh # Genera argocd/apps/* i argocd-bootstrap.yaml
└── nas/ # Servei extern per al NAS
├── transport.yaml # ServersTransport de Traefik
└── ingress.yaml # Ingress del NAS
```
> **Nota**: Cada aplicació té el seu propi `ingress.yaml` dins del seu directori. Ja no hi ha cap `ingress.yaml` centralitzat a l'arrel.
## 🚀 Desplegament
### Prerequisits
@@ -60,15 +67,17 @@ kubectl apply -f <aplicació>/<fitxer>.yaml
### Desplegar Tot el Clúster
La forma recomanada és deixar que ArgoCD sincronitzi el repo (veure secció [ArgoCD (GitOps)](#-argocd-gitops)).
Per a un desplegament manual sense ArgoCD:
```bash
# Desplegar totes les aplicacions
for dir in */; do
kubectl apply -f "$dir"
done
# O aplicar recursos globals primer
kubectl apply -f ingress.yaml
kubectl apply -f nas.yaml
# O aplicar recursos globals primer (opcional)
kubectl apply -f nas/
```
### Eliminar una Aplicació
@@ -83,18 +92,103 @@ kubectl delete -f <aplicació>/<fitxer>.yaml
## 🌐 Ingress i Networking
### Configuració d'Ingress Principal
### Configuració d'Ingress per Aplicació
El fitxer [ingress.yaml](ingress.yaml) conté la configuració centralitzada d'Ingress utilitzant **Traefik** (controlador per defecte de K3s). Característiques:
Cada aplicació té el seu propi fitxer `ingress.yaml` dins del seu directori, seguint el model de [pihole/ingress.yaml](pihole/ingress.yaml). Característiques:
- **TLS/SSL**: Certificats wildcard `*.rogi.casa` gestionats per cert-manager
- **Cloudflare Origin Issuer**: Utilitzat per generar certificats
- **Redirect HTTPS**: Redireccions automàtiques de HTTP a HTTPS
- **Compressió**: Habilitada per defecte
- **Traefik**: Controlador per defecte de K3s (`ingressClassName: traefik`)
- **TLS/SSL**: Certificats per host gestionats per cert-manager amb el cluster-issuer `letsencrypt-prod`
- **Secret per aplicació**: Cada ingress té el seu propi `<aplicació>-tls`
- **Namespace dedicat**: Cada ingress pertany al namespace de la seva aplicació
### Aplicacions amb Ingress Dedicat
Exemple (`pihole/ingress.yaml`):
Algunes aplicacions tenen el seu propi fitxer `ingress.yaml` dins del seu directori per a configuracions específiques.
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: pihole
namespace: pihole
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
tls:
- hosts:
- pihole.rogi.casa
secretName: pihole-tls
rules:
- host: pihole.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: pihole-web
port:
number: 80
```
## 🐙 ArgoCD (GitOps)
Totes les aplicacions del repo es despleguen de forma declarativa amb ArgoCD. Hi ha un `Application` per cada directori d'aplicació, agrupades sota un `AppProject` anomenat `k3s-cluster`.
### Estructura
- [`argocd/apps/project.yaml`](argocd/apps/project.yaml) — `AppProject` `k3s-cluster` (sync-wave -1).
- [`argocd/apps/<app>.yaml`](argocd/apps/) — un `Application` per aplicació (sync-wave 0), cadascun apunta al seu directori del repo.
- [`argocd-bootstrap.yaml`](argocd-bootstrap.yaml) — `Application` "app-of-apps" que sincronitza tot el directori `argocd/apps/`. És l'únic recurs que cal aplicar a mà.
- [`argocd/gen-apps.sh`](argocd/gen-apps.sh) — regenera tots els fitxers anteriors a partir d'una llista d'aplicacions.
### Flux
1. El `AppProject` i tots els `Application` estan versionats a `argocd/apps/`.
2. L'app `k3s-cluster-root` (a `argocd-bootstrap.yaml`) llegeix `argocd/apps/` i crea/actualitza el projecte i totes les applications.
3. Cada `Application` sincronitza el seu directori (ex: `pihole/`) cap al seu namespace, amb `prune` i `selfHeal` activats.
### Bootstrap (una sola vegada)
Prerequisits:
1. ArgoCD instal·lat al clúster (namespace `argocd`).
2. `cert-manager` instal·lat (veure [`cert-manager/install.sh`](cert-manager/install.sh)) — el `ClusterIssuer` depèn dels seus CRDs.
3. El repo registrat a ArgoCD (`Settings → Repositories`). Si el repo és públic a GitHub, l'HTTPS funciona sense credencials.
Llançar la llavor:
```bash
kubectl apply -f argocd-bootstrap.yaml
```
A partir d'aquí ArgoCD crea el projecte `k3s-cluster`, totes les `Application` i les sincronitza automàticament. Qualsevol canvi al repo es propaga sol (self-heal).
### Recrear el clúster des de zero
```bash
# 1. Instal·lar K3s
# 2. Instal·lar ArgoCD
# 3. Instal·lar cert-manager
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml
kubectl wait --for=condition=available --timeout=120s deployment/cert-manager -n cert-manager
# 4. Registrar el repo a ArgoCD (o deixar-lo públic)
# 5. Llançar la llavor
kubectl apply -f argocd-bootstrap.yaml
```
### Afegir o treure una aplicació
1. Crea/esborra el directori de l'aplicació.
2. Afegeix/treu la línia corresponent a l'array `APPS` de [`argocd/gen-apps.sh`](argocd/gen-apps.sh) amb el format `name|namespace|path|recurse|validate`.
3. Executa `./argocd/gen-apps.sh` per regenerar els manifests.
4. Fes commit i push; ArgoCD ho sincronitza sol.
### Notes
- Els `Application`/`AppProject` pertanyen al namespace `argocd` (recursos propis d'ArgoCD).
- L'app `argocd``recurse: false` sobre el directori `argocd/` per gestionar només `ingress.yaml` i no els seus propis manifests sota `argocd/apps/`.
- L'app `phoenix` usa `Validate=false` per tolerar el CRD `ServiceMonitor` si el Prometheus Operator encara no és instal·lat.
- Els secrets que no estan al repo (ex: `gitea-registry` per a `gym-tracker`) s'han de crear manualment al seu namespace; Argo no els gestiona ni els esborra.
## 💾 Persistència de Dades
@@ -218,7 +312,7 @@ kubectl get pv
## 📝 Bones Pràctiques
1. **Namespaces**: Les aplicacions complexes utilitzen namespaces dedicats (n8n, monitoring, phoenix)
1. **Namespaces**: Totes les aplicacions tenen un namespace dedicat; cap queda al namespace `default`
2. **Labels**: Tots els recursos utilitzen labels consistents per facilitar la gestió
3. **Resources Limits**: Configura limits de CPU/memòria per evitar overconsumption
4. **Health Checks**: Implementa liveness i readiness probes quan sigui possible
@@ -245,7 +339,7 @@ kubectl rollout undo deployment/<nom> -n <namespace>
## 🌟 Serveis Externs
### NAS
El fitxer [nas.yaml](nas.yaml) configura un servei extern que apunta al NAS local (10.88.88.238:5000) sense desplegar pods dins del clúster.
El fitxer [nas/nas.yaml](nas/nas.yaml) configura un servei extern que apunta al NAS local (10.88.88.238:5000) sense desplegar pods dins del clúster. L'Ingress corresponent és a [nas/ingress.yaml](nas/ingress.yaml).
## 📚 Recursos Addicionals

30
argocd-bootstrap.yaml Normal file
View File

@@ -0,0 +1,30 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: k3s-cluster-root
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "-1"
spec:
project: default
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: argocd/apps
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false
ignoreDifferences:
- group: argoproj.io
kind: Application
jsonPointers:
- /status
- /operation

View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: cert-manager
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: cert-manager
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: cert-manager
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

24
argocd/apps/fava.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: fava
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: fava
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: fava
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

24
argocd/apps/gitea.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: gitea
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: gitea
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: gitea
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

24
argocd/apps/glance.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: glance
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: glance
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: glance
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: gym-tracker
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: gym-tracker
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: gym-tracker
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: home-manager
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: home-manager
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: home-manager
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: homeassistant
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: homeassistant
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: home-assistant
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

24
argocd/apps/jellyfin.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: jellyfin
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: jellyfin
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: jellyfin
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

24
argocd/apps/litellm.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: litellm
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: litellm
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: litellm
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

24
argocd/apps/llamacpp.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: llamacpp
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: llamacpp
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: llamacpp
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: minecraft-server
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: minecraft-server
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: minecraft
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: monitoring
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: monitoring
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: monitoring
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: myorg-assistant
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: myorg-assistant
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: myorg-assistant
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

24
argocd/apps/n8n.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: n8n
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: n8n
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: n8n
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

24
argocd/apps/nas.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: nas
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: nas
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: nas-proxy
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: openwebui
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: openwebui
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: openwebui
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

25
argocd/apps/phoenix.yaml Normal file
View File

@@ -0,0 +1,25 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: phoenix
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: phoenix
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: phoenix
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false
- Validate=false

24
argocd/apps/pihole.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: pihole
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: pihole
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: pihole
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: platform-engineer
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: platform-engineer
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: platform-engineer
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

17
argocd/apps/project.yaml Normal file
View File

@@ -0,0 +1,17 @@
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: k3s-cluster
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "-1"
spec:
description: Applications for the rogi.casa K3s cluster (managed in Git)
sourceRepos:
- https://git.rogi.casa/roger/k3s-cluster.git
destinations:
- server: https://kubernetes.default.svc
namespace: "*"
clusterResourceWhitelist:
- group: "*"
kind: "*"

View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: qbittorrent
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: qbittorrent
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: qbittorrent
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

24
argocd/apps/searxng.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: searxng
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: searxng
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: searxng
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

View File

@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: vaultwarden
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
targetRevision: main
path: vaultwarden
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: vaultwarden
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false

19
argocd/argocd-cm.yaml Normal file
View File

@@ -0,0 +1,19 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
labels:
app.kubernetes.io/name: argocd-cm
app.kubernetes.io/part-of: argocd
data:
# Serve HTTP (no redirect to HTTPS) so the TLS-terminating Traefik ingress works.
# Without this, argocd-server redirects HTTP->HTTPS, causing an infinite
# redirect loop behind the ingress (argocd.rogi.casa unreachable).
server.insecure: "true"
# add an additional local user with apiKey and login capabilities
# apiKey - allows generating API keys
# login - allows to login using UI
accounts.roger: apiKey, login
accounts.platform-engineer: apiKey, login

View File

@@ -0,0 +1,29 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-rbac-cm
namespace: argocd
labels:
app.kubernetes.io/name: argocd-rbac-cm
app.kubernetes.io/part-of: argocd
data:
policy.csv: |
# Grant platform-engineer read-only access to applications
g, platform-engineer, role:readonly
# Custom policy for platform-engineer with application read permissions
p, role:platform-engineer, applications, get, *, allow
p, role:platform-engineer, applications, list, *, allow
p, role:platform-engineer, clusters, get, *, allow
p, role:platform-engineer, clusters, list, *, allow
p, role:platform-engineer, repositories, get, *, allow
p, role:platform-engineer, repositories, list, *, allow
p, role:platform-engineer, projects, get, *, allow
p, role:platform-engineer, projects, list, *, allow
g, platform-engineer, role:platform-engineer
# Default policy - deny by default (ArgoCD default)
policy.default: role:readonly
# Enable RBAC
rbac.enabled: "true"

142
argocd/gen-apps.sh Executable file
View File

@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# Generates ArgoCD Application manifests (one per app folder) + an AppProject.
#
# Layout produced:
# argocd/apps/project.yaml -> AppProject "k3s-cluster" (sync-wave -1)
# argocd/apps/<app>.yaml -> Application for that app folder
# argocd-bootstrap.yaml (repo root) -> app-of-apps: syncs everything in argocd/apps/
#
# Bootstrap (one-time, after ArgoCD + cert-manager are installed):
# kubectl apply -f argocd-bootstrap.yaml
#
# Re-run this script after adding/removing an app folder to regenerate the manifests.
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
REPO="${REPO:-https://git.rogi.casa/roger/k3s-cluster.git}"
REV="${REV:-main}"
APPS_DIR="argocd/apps"
mkdir -p "$APPS_DIR"
# app-name | namespace | path | recurse | validate
APPS=(
"cert-manager|cert-manager|cert-manager|true|true"
"fava|fava|fava|true|true"
"gitea|gitea|gitea|true|true"
"glance|glance|glance|true|true"
"gym-tracker|gym-tracker|gym-tracker|true|true"
"homeassistant|home-assistant|homeassistant|true|true"
"home-manager|home-manager|home-manager|true|true"
"jellyfin|jellyfin|jellyfin|true|true"
"llamacpp|llamacpp|llamacpp|true|true"
"litellm|litellm|litellm|true|true"
"minecraft-server|minecraft|minecraft-server|true|true"
"monitoring|monitoring|monitoring|true|true"
"myorg-assistant|myorg-assistant|myorg-assistant|true|true"
"n8n|n8n|n8n|true|true"
"nas|nas-proxy|nas|true|true"
"openwebui|openwebui|openwebui|true|true"
"phoenix|phoenix|phoenix|true|false"
"pihole|pihole|pihole|true|true"
"platform-engineer|platform-engineer|platform-engineer|true|true"
"qbittorrent|qbittorrent|qbittorrent|true|true"
"vaultwarden|vaultwarden|vaultwarden|true|true"
)
# ---------------------------------------------------------------------------
# AppProject
# ---------------------------------------------------------------------------
cat > "$APPS_DIR/project.yaml" <<EOF
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: k3s-cluster
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "-1"
spec:
description: Applications for the rogi.casa K3s cluster (managed in Git)
sourceRepos:
- ${REPO}
destinations:
- server: https://kubernetes.default.svc
namespace: "*"
clusterResourceWhitelist:
- group: "*"
kind: "*"
EOF
# ---------------------------------------------------------------------------
# One Application per app folder
# ---------------------------------------------------------------------------
gen_app() {
local name="$1" ns="$2" path="$3" recurse="$4" validate="$5"
local recurse_yaml validate_opts=""
[ "$recurse" = "true" ] && recurse_yaml=" recurse: true" || recurse_yaml=" recurse: false"
[ "$validate" = "false" ] && validate_opts=$'\n - Validate=false'
cat > "$APPS_DIR/${name}.yaml" <<EOF
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: ${name}
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: k3s-cluster
source:
repoURL: ${REPO}
targetRevision: ${REV}
path: ${path}
directory:
${recurse_yaml}
destination:
server: https://kubernetes.default.svc
namespace: ${ns}
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false${validate_opts}
EOF
}
for line in "${APPS[@]}"; do
IFS='|' read -r name ns path recurse validate <<< "$line"
gen_app "$name" "$ns" "$path" "$recurse" "$validate"
done
# ---------------------------------------------------------------------------
# Root "app-of-apps" bootstrap Application (uses the built-in default project)
# ---------------------------------------------------------------------------
cat > argocd-bootstrap.yaml <<EOF
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: k3s-cluster-root
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "-1"
spec:
project: default
source:
repoURL: ${REPO}
targetRevision: ${REV}
path: argocd/apps
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false
EOF
echo "Generated $(find "$APPS_DIR" -name '*.yaml' | wc -l) files in $APPS_DIR/ and argocd-bootstrap.yaml"

View File

@@ -35,7 +35,7 @@ data:
# Clone or update the repository
if [ ! -d "/data/contabilitat/.git" ]; then
echo "Cloning repository..."
git clone https://${GITEA_USERNAME}:${GITEA_PASSWORD}@gitea.rogi.casa/${GITEA_USERNAME}/contabilitat.git /data/contabilitat
git clone https://${GITEA_USERNAME}:${GITEA_PASSWORD}@git.rogi.casa/roger/contabilitat.git /data/contabilitat
else
echo "Repository exists, pulling latest changes..."
cd /data/contabilitat

View File

@@ -7,9 +7,7 @@ metadata:
annotations:
kubernetes.io/ingress.class: "traefik"
traefik.ingress.kubernetes.io/redirect-entry-point: https
cert-manager.io/issuer: prod-issuer
cert-manager.io/issuer-kind: OriginIssuer
cert-manager.io/issuer-group: cert-manager.k8s.cloudflare.com
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:

View File

@@ -25,6 +25,8 @@ metadata:
namespace: gitea
labels:
app: gitea
annotations:
kubectl.kubernetes.io/restartedAt: "2026-07-21T13:30:00Z"
spec:
replicas: 1
selector:
@@ -92,16 +94,7 @@ metadata:
name: gitea-runner-config
namespace: gitea
data:
GITEA_INSTANCE_URL: "http://gitea.rogi.casa"
---
apiVersion: v1
kind: Secret
metadata:
name: gitea-runner-secret
namespace: gitea
type: Opaque
stringData:
GITEA_RUNNER_REGISTRATION_TOKEN: "BqkIGoAiwSYUFm2CPXlvvKAdSw5fl6ayCAb60zsM"
GITEA_INSTANCE_URL: "http://git.rogi.casa"
---
apiVersion: apps/v1
kind: Deployment
@@ -110,6 +103,8 @@ metadata:
namespace: gitea
labels:
app: gitea-runner
annotations:
kubectl.kubernetes.io/restartedAt: "2026-07-21T13:30:00Z"
spec:
replicas: 1
selector:
@@ -124,7 +119,14 @@ spec:
kubernetes.io/arch: arm64
containers:
- name: gitea-runner
image: vegardit/gitea-act-runner:latest
image: vegardit/gitea-act-runner:2.1.0
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "500m"
env:
- name: GITEA_INSTANCE_URL
valueFrom:

View File

@@ -1,4 +1,3 @@
# gitea-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:

View File

@@ -2,6 +2,7 @@ apiVersion: v1
kind: ConfigMap
metadata:
name: glance-config
namespace: glance
data:
glance.yml: |
pages:

View File

@@ -1,7 +1,13 @@
apiVersion: v1
kind: Namespace
metadata:
name: glance
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: glance
namespace: glance
spec:
replicas: 1
selector:
@@ -29,7 +35,7 @@ apiVersion: v1
kind: Service
metadata:
name: glance-service
namespace: default
namespace: glance
spec:
type: ClusterIP
selector:

24
glance/ingress.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: glance
namespace: glance
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
tls:
- hosts:
- glance.rogi.casa
secretName: glance-tls
rules:
- host: glance.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: glance-service
port:
number: 80

View File

@@ -1,7 +1,13 @@
apiVersion: v1
kind: Namespace
metadata:
name: gym-tracker
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gym-tracker
namespace: gym-tracker
labels:
app: gym-tracker
spec:
@@ -18,7 +24,7 @@ spec:
- name: gitea-registry
containers:
- name: gym-tracker
image: gitea.rogi.casa/roger/gym-tracker/gym-tracker:3ba68d6
image: git.rogi.casa/roger/gym-tracker/gym-tracker:945910a
imagePullPolicy: Always
ports:
- containerPort: 80
@@ -67,6 +73,7 @@ apiVersion: v1
kind: Service
metadata:
name: gym-tracker
namespace: gym-tracker
labels:
app: gym-tracker
spec:
@@ -87,6 +94,7 @@ apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gym-tracker-data
namespace: gym-tracker
labels:
app: gym-tracker
spec:
@@ -95,4 +103,4 @@ spec:
resources:
requests:
storage: 1Gi
storageClassName: local-path
storageClassName: local-path

24
gym-tracker/ingress.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gym-tracker
namespace: gym-tracker
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
tls:
- hosts:
- gym.rogi.casa
secretName: gym-tracker-tls
rules:
- host: gym.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gym-tracker
port:
number: 80

150
home-manager/README.md Normal file
View File

@@ -0,0 +1,150 @@
# Home Manager Agent — Deployment Plan
An autonomous **Hermes Agent** that runs inside the k3s cluster and takes care
of the home by talking to **Home Assistant**'s REST API. It mirrors the
`platform-engineer` agent pattern but its domain is the home, not the cluster:
it watches sensors, runs routines, and notifies Roger on Discord — asking
before touching anything security-critical (locks, alarm, garage, HVAC).
Docs: https://hermes-agent.nousresearch.com/docs/user-guide/docker
---
## 1. Goal & operating model
- **One Hermes container** in namespace `home-manager`, scheduled on the amd64
NUC (`roger-nucbox-evo-x2`, 24 GiB) — same node as `platform-engineer`.
- Hermes runs in **gateway mode** under s6 supervision (`gateway run`), so the
built-in **cron scheduler** is active and survives restarts.
- The agent talks to the home via the **Home Assistant REST API** at
`http://home-assistant.home-assistant:80` (in-cluster `home-assistant`
Service). Auth is a HA Long-Lived Access Token in a Secret.
- The agent has **NO k8s RBAC and NO git repo** — it doesn't need them. Its
only write path is HA service calls. (Contrast with `platform-engineer`,
which remediates via git commits → ArgoCD sync.)
- LLM calls route through the in-cluster **LiteLLM** proxy.
- Notifications go to a **dedicated Discord bot** (separate token from the
platform engineer's bot).
- **Hermes-native cron jobs** make the agent run periodic checks + routines.
---
## 2. Files in this directory
| File | Purpose |
|------|---------|
| `namespace.yaml` | namespace `home-manager` |
| `rbac.yaml` | ServiceAccount + Role for the `cron-seed` Job only (pods/exec on the hermes pod) |
| `configmap.yaml` | seed `config.yaml` + `SOUL.md` (HA REST API docs + operating rules) |
| `secret.yaml` | HA token, Discord token/channel, LiteLLM key, dashboard auth (PLACEHOLDERS — fill in) |
| `pvc.yaml` | 5 Gi PVC for `/opt/data` (HERMES_HOME) |
| `deployment.yaml` | Deployment (1 replica, Recreate, pinned to amd64 NUC) + Service |
| `ingress.yaml` | `home-manager.rogi.casa` → dashboard (TLS + basic auth) |
| `cron-seed.yaml` | Sync-hook Job that reconciles the Hermes cron schedule from Git (deletes + recreates each job on every run so prompt/schedule changes apply on sync) |
| `README.md` | this file |
---
## 3. How it differs from `platform-engineer`
| Concern | `platform-engineer` | `home-manager` (this) |
|---|---|---|
| Domain | k3s cluster health | the home |
| Observe via | Prometheus / Loki / ArgoCD HTTP APIs | Home Assistant REST API |
| Write path | git commit → push → ArgoCD sync | HA service calls (`POST /api/services/...`) |
| k8s RBAC | none | none |
| git repo | yes (cloned to `/workspace/k3s-cluster`) | none |
| Discord bot | dedicated | dedicated (separate token + channel) |
| `HERMES_WRITE_SAFE_ROOT` | `/` (needs to edit manifests) | unset → defaults to `/opt/data` (tighter) |
| Image | `nousresearch/hermes-agent:latest` | same |
| Model | `qwen3.6` via LiteLLM | same |
---
## 4. Safety & guardrails
1. **SOUL.md is the real boundary.** The agent may freely toggle lights, fans,
media, and non-critical switches. It must **ask Roger on Discord before
touching** `lock.*`, `alarm_control_panel.*`, garage `cover.*`, risky
`climate.*` changes, or anything tagged `critical`/`security`/`safe_to_auto: false`.
2. **`tool_loop_guardrails.hard_stop_enabled: true`** — circuit-breaks a stuck
gateway.
3. **`yolo: true` + `approvals.mode: off`** — cron jobs aren't blocked waiting
for a human. Safe because the blast radius is HA service calls and the SOUL.md
encodes the confirmation gate for dangerous entities.
4. **Tirith scanner disabled** — it false-positives on the in-cluster plain-HTTP
HA URL and would block every query.
5. **Single replica / single-writer PVC** — never scale >1. `podAntiAffinity`
guards against an accidental co-run.
6. **No real-time event bus** — the terminal tool can't hold a HA WebSocket open.
The agent works on cron polling + on-demand Discord commands. (See §6 for an
optional real-time hook.)
---
## 5. Deployment checklist (do in this order)
1. **Create a Home Assistant Long-Lived Access Token.** In HA UI:
Profile → Long-Lived Access Tokens → Create Token → label `home-manager-agent`.
Copy the token.
2. **Create a dedicated Discord bot** (separate from the platform-engineer bot),
invite it to your server, and note its token + the channel ID you want it to
use as its home channel.
3. **Fill in `secret.yaml`** — base64-encode each value (`echo -n 'VALUE' | base64`):
- `HOMEASSISTANT_TOKEN` — the HA LLAT from step 1.
- `OPENAI_API_KEY` — your LiteLLM master key.
- `DISCORD_BOT_TOKEN`, `DISCORD_HOME_CHANNEL`, `DISCORD_FREE_RESPONSE_CHANNELS`
— from step 2 (channel ID is the same for both).
- `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD` and `..._SECRET` — strong values.
4. **Fill in `configmap.yaml`** — replace `REPLACE_WITH_HOME_CHANNEL_ID` (two
places under `discord:`) with your numeric home channel ID. (Must match the
secret's `DISCORD_HOME_CHANNEL`.)
5. **Tailor `cron-seed.yaml`** schedules/prompts to your actual HA entities
(your sensor names, your calendars, your todo lists). The default schedule
has 4 jobs (energy anomaly, comfort check, morning briefing, evening
briefing) — adjust thresholds and wording to your setup.
6. **Commit & push** the whole change (including the regenerated
`argocd/apps/home-manager.yaml`). ArgoCD will create the namespace resources,
deploy the pod, and bring up the ingress at `home-manager.rogi.casa`.
7. **Seed the cron jobs:** ArgoCD runs `cron-seed.yaml` as a Sync hook
automatically on every sync. The hook **reconciles** the cron schedule to
match Git: it deletes + recreates each named job, so prompt/schedule edits
in `cron-seed.yaml` take effect on the next sync (this overwrites any live
`hermes cron edit` changes — edit the prompts in Git instead). The schedule
has exactly 4 jobs: `energy-anomaly` (every 4h), `comfort-check` (every 4h),
`morning-briefing` (08:00 daily), `evening-briefing` (00:00 daily). To
re-run it by hand: `kubectl apply -f home-manager/cron-seed.yaml`.
8. **Smoke test:** trigger a check manually —
`kubectl exec -n home-manager deploy/hermes -- hermes cron run morning-briefing`
and confirm the message lands in Discord.
---
## 6. Optional: real-time events from HA
The base setup is cron-poll + Discord. For real-time reaction (e.g. "front door
opened → agent acts immediately"), you can have Home Assistant call the Hermes
gateway API on an event:
1. Enable the gateway API (port 8642) in `config.yaml` and set an auth token.
2. In HA, create a `rest` command + an automation that fires on the event and
POSTs to `http://home-manager.home-manager:80/...` (the in-cluster Service).
This is not enabled by default — start with cron + Discord, add webhooks once
the agent is trusted.
---
## 7. What ArgoCD owns vs. what is runtime state
- **ArgoCD owns** (in git): namespace, RBAC, Secret, ConfigMap (seed), PVC,
Deployment, Service, Ingress, cron-seed Job.
- **Runtime state (on the PVC, NOT reconciled):** `config.yaml`, `SOUL.md`,
`.env`, `sessions/`, `memories/`, `skills/`. The ConfigMap only *seeds* these
on first boot; after that, edits made via the dashboard persist on the PVC
and Argo will not revert them. For a hard reset, delete the PVC and re-apply.
- **Exception — cron jobs (`cron/jobs.json`):** the `cron-seed` Sync hook
re-runs on every ArgoCD sync and **deletes + recreates** each named job from
`cron-seed.yaml`, so the cron schedule IS reconciled from Git. Live edits
via `hermes cron edit` will be overwritten on the next sync — edit the
prompts in `cron-seed.yaml` and commit instead.

205
home-manager/configmap.yaml Normal file
View File

@@ -0,0 +1,205 @@
# Hermes configuration + SOUL.md (seeded into the PVC on first boot).
---
apiVersion: v1
kind: ConfigMap
metadata:
name: hermes-seed
namespace: home-manager
data:
config.yaml: |
model:
provider: openai-api
default: qwen3.6-35b-a3b
base_url: "http://litellm-service.litellm:80/v1"
api_mode: chat_completions
auxiliary:
compression:
provider: openai-api
model: qwen3.6-35b-a3b
base_url: "http://litellm-service.litellm:80/v1"
title_generation:
provider: openai-api
model: qwen3.6-35b-a3b
base_url: "http://litellm-service.litellm:80/v1"
terminal:
backend: local
cwd: /opt/data
timeout: 180
home_mode: profile
# The agent runs unattended (cron jobs). yolo:true disables approval prompts
# so cron jobs aren't blocked waiting for a human. Safe here because the
# agent's blast radius is limited to Home Assistant service calls, and the
# SOUL.md forbids touching security-critical entities without confirmation.
yolo: true
approvals:
mode: off
# Disable the Tirith pre-exec command scanner. It flags in-cluster plain
# HTTP URLs (http://home-assistant.home-assistant:80) as 'insecure URL'
# false positives, which blocks every HA API query.
security:
tirith_enabled: false
tirith_fail_open: true
tool_loop_guardrails:
hard_stop_enabled: true
hard_stop_after:
exact_failure: 5
idempotent_no_progress: 5
sessions:
auto_prune: true
retention_days: 90
cron:
wrap_response: false
discord:
# The home channel's Discord ID. Must match DISCORD_HOME_CHANNEL /
# DISCORD_FREE_RESPONSE_CHANNELS in the Secret. Hermes reads these from
# config.yaml, not the env.
allowed_channels: '1470909384162017444'
free_response_channels: '1470909384162017444'
dm_policy: open
group_policy: open
memory:
memory_enabled: true
user_profile_enabled: true
write_approval: false
skills:
write_approval: false
SOUL.md: |
# Home Manager — rogi.casa (via Home Assistant)
You are the autonomous Home Manager for the `rogi.casa` home. You run
*inside* the k3s cluster (namespace `home-manager`) and your job is to keep
the home comfortable, safe, and efficient by talking to Home Assistant, and
to notify your owner (Roger) on Discord when something needs a human.
## The home you look after
- **Home Assistant:** the brain of the home. Runs in namespace
`home-assistant`. You reach its REST API in-cluster at
`http://home-assistant.home-assistant:80` (the `home-assistant` Service,
port 80 → 8123). If that DNS ever fails, fall back to the node IP
`http://10.88.20.11:8123` (HA runs hostNetwork).
- **Auth:** every HA REST call needs an `Authorization: Bearer $HOMEASSISTANT_TOKEN`
header. The token is in your environment. NEVER log or echo the token.
- **Your model provider:** LiteLLM at `http://litellm-service.litellm:80/v1`.
- **Notifications:** Discord (your home channel).
## How you observe the home (Home Assistant REST API)
Use the terminal tool to send HTTP requests with curl. Always pass the bearer
token from the environment (`$HOMEASSISTANT_TOKEN`), never hard-code it.
Base URL: `$HOMEASSISTANT_URL` (set to http://home-assistant.home-assistant:80)
### 1. Read state
- All entities: GET /api/states
- One entity: GET /api/states/<entity_id> (e.g. light.living_room)
- Services list: GET /api/services
- Calendar events: GET /api/calendars/<calendar_id>?start=...&end=...
- History: GET /api/history/period/<timestamp>?filter_entity_id=<id>&minimal
- Config check: GET /api/config
### 2. Actuate (call a service)
POST /api/services/<domain>/<service> with a JSON body.
Common services:
- light.turn_on / light.turn_off / light.toggle
body: {"entity_id": "light.x", "brightness": 200}
- switch.turn_on / switch.turn_off / switch.toggle
body: {"entity_id": "switch.x"}
- cover.open_cover / close_cover / set_cover_position
body: {"entity_id": "cover.x", "position": 50}
- fan.toggle / fan.set_percentage
- media_player.play_media / media_pause / volume_set
- climate.set_temperature
body: {"entity_id": "climate.x", "temperature": 21}
- script.turn_on
body: {"entity_id": "script.goodnight"}
- input_boolean.toggle / input_select.select_option
### 3. Fire events
POST /api/events/<event_type> { ... }
(Listening to the live event bus needs the WebSocket API, which the
terminal tool can't hold open. Use cron polling for periodic checks, and
ask Roger on Discord for anything that needs real-time reaction.)
### 4. Parse JSON
Pipe curl output through `python3 -m json.tool` or `jq` in the terminal
tool. The execute_code tool is BLOCKED in cron mode.
## How you remediate (call services — NOT git, NOT kubectl)
Unlike the platform engineer, you do NOT change the cluster. You change the
*home* by calling Home Assistant services via the REST API. You have no k8s
access and no git repo — and you don't need them.
Workflow:
1. GET /api/states (or a specific entity) to understand the situation.
2. Decide a safe action. POST the service call.
3. GET the entity back to confirm the new state.
4. Report what you did in one line (entity_id + service + result).
## Operating rules
1. **Read first, act second.** Always GET the current state before changing
anything. Cite the exact `entity_id` and the service you called.
2. **Only safe, idempotent actions on your own.** Allowed without asking:
lights, fans, media players, non-critical switches, running scripts Roger
has marked safe, reading any sensor.
3. **NEVER touch security- or safety-critical entities without explicit
confirmation from Roger on Discord.** This includes, but is not limited to:
- `lock.*` (door locks)
- `alarm_control_panel.*` (alarm arm/disarm)
- `cover.*` for garage doors or main gates
- `climate.*` changes that could freeze/overheat the house or cost a lot
- any `switch.*` / `script.*` tagged `critical` or `security`
- any entity with a custom attribute `safe_to_auto: false`
For these, post the proposed action to Discord and wait for Roger to
reply yes/no before calling the service.
4. **When in doubt, notify, don't act.** If an action is unusual, could
startle someone (e.g. loud media at 03:00, vacuum robot at night), or you
are unsure what an entity does, ask Roger first.
5. **Be quiet when healthy.** Watchdog cron jobs reply with exactly
`[SILENT]` when there is nothing to report. Failed jobs always deliver.
6. **No runaway loops.** You cannot create new cron jobs from inside a cron
run (Hermes disables that). Do not try.
7. **Talk like a concierge.** Short, concrete, with entity_ids and values.
No filler. When you did something, say what in one line.
8. **Language: Catalan by default.** Address Roger in Catalan in *all*
interactions — briefings, watchdog alerts, Discord DMs, and any other
message you produce. This is the default regardless of the language the
cron prompt or instruction is written in (those are just instructions
to you, not the output language). Only switch away from Catalan if Roger
writes to you in another language or explicitly asks you to. Never
default to English or Italian.
9. **Respect time of day and presence.** Don't run noisy routines (loud
media, vacuum robots) at night. Check presence (`zone.*` /
`binary_sensor.*`) and the time before acting.
10. **Absence of presence entities ≠ nobody home.** This Home Assistant
instance has **no presence/device-tracker sensors configured**. Do NOT
infer "nobody is home" from the absence of `zone.*`, `device_tracker.*`,
or presence `binary_sensor.*` entities, from `zone.home` being missing /
unavailable, or from presence sensors being absent. "No presence data"
means **unknown occupancy**, not "empty house". Any cron prompt or rule
that says "if no one is home" / "if presence sensors all off" must be
treated as: **skip the empty-home logic** and fall back to the safe
default (assume someone *might* be home; don't turn things off, don't
run aggressive routines). Only treat the house as empty if Roger tells
you explicitly, or if a real presence entity actually reports `away` /
`off`.
## How you reach Roger
Notifications go to Discord (your home channel). Cron jobs deliver there by
default (`deliver="discord"`). Keep messages under ~1800 chars. For anything
that needs a yes/no decision, ask the question clearly and wait — do not
proceed on a guess.

105
home-manager/cron-seed.yaml Normal file
View File

@@ -0,0 +1,105 @@
# One-shot Job that seeds Hermes' built-in cron schedule.
# Reconciles: on every run it deletes any existing job with the same name and
# recreates it with the prompt/schedule below, so changes to this file (e.g.
# prompt wording) are applied to the live schedule on the next ArgoCD sync.
# Note: this means live `hermes cron edit` changes will be overwritten — edit
# the prompts here in Git instead and let ArgoCD reconcile.
#
# Cron prompts are deliberately written as plain-English instructions (no inline
# curl commands) to avoid tripping Hermes' threat-pattern scanner, which blocks
# cron prompts containing curl+auth-header patterns. The exact HA REST API
# endpoints and examples are documented in the agent's SOUL.md instead.
#
# Schedule (4 jobs):
# 1. energy-anomaly — every 4h (silent unless power draw is too high)
# 2. comfort-check — every 4h (silent unless a room is out of range)
# 3. morning-briefing — 0 6 * * * (always delivered)
# 4. evening-briefing — 0 22 * * * (always delivered)
# NOTE: Hermes' cron runs in UTC (no TZ set on the container). The cluster is
# at UTC+2, so every fixed daily time below is expressed in UTC and shifted
# back 2h from the intended local wall-clock time:
# 08:00 local -> 0 6 (morning briefing)
# 00:00 local -> 0 22 (evening briefing, i.e. midnight local)
# Relative schedules (every 4h) are timezone-independent.
# None of these act autonomously on climate/energy — they report and ask Roger.
---
apiVersion: batch/v1
kind: Job
metadata:
name: hermes-cron-seed
namespace: home-manager
labels:
app: hermes
annotations:
argocd.argoproj.io/sync-options: Replace=true
argocd.argoproj.io/hook: Sync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
backoffLimit: 4
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app: hermes
spec:
serviceAccountName: cron-seeder
restartPolicy: OnFailure
containers:
- name: seed
image: alpine:3.20
command: ["sh", "-c"]
args:
- |
set -e
apk add --no-cache curl
ARCH=$(uname -m)
case "$ARCH" in
x86_64) KARCH=amd64 ;;
aarch64) KARCH=arm64 ;;
armv7l) KARCH=arm ;;
*) echo "unsupported arch: $ARCH" >&2; exit 1 ;;
esac
curl -fsSL -o /usr/local/bin/kubectl \
"https://dl.k8s.io/release/v1.35.0/bin/linux/${KARCH}/kubectl"
chmod +x /usr/local/bin/kubectl
echo "Waiting for hermes pod to be Ready..."
kubectl -n home-manager wait --for=condition=Ready pod -l app=hermes --timeout=300s || true
POD=$(kubectl -n home-manager get pod -l app=hermes -o jsonpath='{.items[0].metadata.name}')
echo "Using pod: $POD"
# Returns 0 if a cron job with this name already exists.
exists() { kubectl -n home-manager exec "$POD" -- hermes cron list 2>/dev/null | grep -qi " $1 "; }
# Reconcile a cron job to the desired state defined in Git: if a job
# with this name already exists, delete it first so the (re)create
# below picks up prompt/schedule changes instead of being skipped.
create() {
name="$1"; schedule="$2"; deliver="$3"; prompt="$4"
if exists "$name"; then
echo "deleting existing cron job '$name' to apply updates ..."
kubectl -n home-manager exec "$POD" -- hermes cron delete "$name" || true
fi
echo "creating cron job '$name' ..."
kubectl -n home-manager exec "$POD" -- hermes cron create "$schedule" "$prompt" --name "$name" --deliver "$deliver"
}
# ---- 1. Energy anomaly (every 4h, silent unless too high) ----
create "energy-anomaly" "every 4h" "discord" \
"Check the Home Assistant REST API as documented in your SOUL.md. Read the current total house power draw sensor (sensor.* power / current_power — your main power clamp). Determine whether it is currently day (06:00 to 23:00) or night (23:00 to 06:00) from the current time. If the power draw exceeds 4000 W during the day, or exceeds 1000 W during the night, report to Roger on Discord: the current reading in watts, the time, whether it is day or night, and which threshold was exceeded. Ask Roger what he wants to do about it. Do NOT turn anything off or change any entity yourself — only report and ask. If power is within the normal range for the time of day, reply with exactly [SILENT]."
# ---- 2. Temperature & humidity check (every 4h, silent unless out of range) ----
create "comfort-check" "every 4h" "discord" \
"Check the Home Assistant REST API as documented in your SOUL.md. Read indoor temperature and humidity sensors in each room (sensor.*temperature, sensor.*humidity). Comfortable range: temperature between 18C and 26C, humidity between 30 and 65 percent. For any room that is outside that range, report to Roger on Discord: the room name (entity_id), the reading, and whether it is too high or too low. Ask Roger what he wants to do about it. Do NOT change climate/HVAC, fans, or any entity yourself — only report and ask. If every room is within the comfortable range, reply with exactly [SILENT]."
# ---- 3. Morning briefing (daily at 08:00 local = 06:00 UTC, always delivered) ----
create "morning-briefing" "0 6 * * *" "discord" \
"Produce a morning home briefing for Roger using the Home Assistant REST API as documented in your SOUL.md. Include, in short bullet form: (1) today's calendar events from calendar.* entities for today (time + title); (2) open tasks from todo.* task lists; (3) overnight energy use — total energy consumed overnight from energy / utility_meter sensors, plus the current power draw; (4) outdoor and indoor temperature and humidity, and the current weather from weather.*; (5) occupancy status from zone.home — report its state if it exists; if zone.home is missing or unavailable, state 'occupancy: unknown — zone.home not configured' rather than claiming no one is home; (6) any sensors currently in an alert or unavailable state. Keep the message under 1800 chars. Always deliver (no [SILENT])."
# ---- 4. Evening briefing (daily at midnight local = 22:00 UTC, always delivered) ----
create "evening-briefing" "0 22 * * *" "discord" \
"Produce an evening home status report for Roger using the Home Assistant REST API as documented in your SOUL.md. Report on: (1) lights currently turned on (light.* with state 'on'); (2) sockets and switches currently turned on (switch.* with state 'on'); (3) current energy use / power draw; (4) indoor temperature and humidity per room. List the specific entity_ids and their values. Then ask Roger which of the on devices/lights he wants turned off. Do NOT turn anything off yourself — only report and ask. Keep the message under 1800 chars. Always deliver (no [SILENT])."
echo "Done. Listing all cron jobs:"
kubectl -n home-manager exec "$POD" -- hermes cron list

View File

@@ -0,0 +1,173 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: hermes
namespace: home-manager
labels:
app: hermes
spec:
replicas: 1 # MUST be 1 — Hermes' /opt/data is single-writer.
strategy:
type: Recreate # never run two pods against the same PVC
selector:
matchLabels:
app: hermes
template:
metadata:
labels:
app: hermes
spec:
# No serviceAccountName — the agent has NO k8s API access. It manages the
# home via the Home Assistant REST API and notifies via Discord.
# Pin to the powerful amd64 node (image is linux/amd64; the NUC has 24 GiB).
nodeSelector:
kubernetes.io/arch: amd64
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: hardware
operator: In
values: ["high-memory"]
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: hermes
topologyKey: kubernetes.io/hostname
initContainers:
# Seed /opt/data with config.yaml + SOUL.md + .env on first boot only.
# ArgoCD owns the manifests; the PVC is runtime state and is NOT reconciled.
- name: seed-data
image: busybox:1.36
command: ["sh", "-c"]
args:
- |
set -e
if [ ! -f /opt/data/config.yaml ]; then
echo "First boot: seeding /opt/data from ConfigMap + env..."
cp /seed/config.yaml /opt/data/config.yaml
cp /seed/SOUL.md /opt/data/SOUL.md
chmod 600 /opt/data/config.yaml
# Write .env from the injected Secret env vars so the s6 gateway
# finds API keys (the hermes container reads keys from /opt/data/.env).
: > /opt/data/.env
chmod 600 /opt/data/.env
for k in OPENAI_API_KEY OPENAI_BASE_URL HOMEASSISTANT_TOKEN \
DISCORD_BOT_TOKEN DISCORD_HOME_CHANNEL \
GATEWAY_ALLOW_ALL_USERS DISCORD_FREE_RESPONSE_CHANNELS \
HERMES_DASHBOARD HERMES_DASHBOARD_BASIC_AUTH_USERNAME \
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD HERMES_DASHBOARD_BASIC_AUTH_SECRET \
API_SERVER_KEY; do
eval "v=\${$k:-}"
[ -n "$v" ] && echo "$k=$v" >> /opt/data/.env
done
else
echo "/opt/data already initialized — leaving runtime state intact."
fi
mkdir -p /opt/data/home/.kube /opt/data/cron/output /opt/data/scripts
envFrom:
- secretRef:
name: hermes-env
volumeMounts:
- name: data
mountPath: /opt/data
- name: seed
mountPath: /seed
containers:
- name: hermes
image: nousresearch/hermes-agent:latest
imagePullPolicy: Always
# IMPORTANT: do NOT set `command:` — it would override the image's
# ENTRYPOINT (/init, s6-overlay), which sets up the hermes user, seeds
# config on first boot, and supervises the gateway.
args: ["gateway", "run"]
ports:
- name: gateway
containerPort: 8642
- name: dashboard
containerPort: 9119
envFrom:
- secretRef:
name: hermes-env
env:
- name: HERMES_HOME
value: /opt/data
# Home Assistant REST API base URL (in-cluster service). Non-secret, so
# it lives here rather than in the Secret. The SOUL.md uses $HOMEASSISTANT_URL.
- name: HOMEASSISTANT_URL
value: "http://home-assistant.home-assistant:80"
# Expose the Hermes gateway HTTP API on port 8642 (the Service routes
# 80 → 8642). Used by Open WebUI and other in-cluster clients to talk
# to the agent as an OpenAI-compatible endpoint.
- name: API_SERVER_ENABLED
value: "true"
# Bind the gateway API on all interfaces so other pods can reach it
# via the Service (default is 127.0.0.1/loopback, which is unreachable
# cross-pod). Auth is still enforced via API_SERVER_KEY.
- name: API_SERVER_HOST
value: "0.0.0.0"
# Distinct model id advertised on GET /v1/models. Without this both
# agents report "hermes-agent" and Open WebUI dedupes them, hiding one.
- name: API_SERVER_MODEL_NAME
value: "home-manager"
# NOTE: API_SERVER_KEY comes from the hermes-env Secret (via envFrom)
# and is also seeded into /opt/data/.env by the init container. Clients
# (e.g. Open WebUI) authenticate with `Authorization: Bearer <key>`.
# HERMES_WRITE_SAFE_ROOT is intentionally unset → defaults to HERMES_HOME
# (/opt/data). The home agent has no git workspace to write to, so the
# tighter default is correct (memory/skills/scripts live under /opt/data).
volumeMounts:
- name: data
mountPath: /opt/data
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "2Gi"
cpu: "1000m"
livenessProbe:
# Probe the dashboard port (9119, always enabled via HERMES_DASHBOARD=1
# and binds 0.0.0.0). The gateway API on 8642 is enabled via
# API_SERVER_ENABLED=true above.
tcpSocket:
port: 9119
initialDelaySeconds: 90
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 5
securityContext:
allowPrivilegeEscalation: false
volumes:
- name: data
persistentVolumeClaim:
claimName: hermes-data
- name: seed
configMap:
name: hermes-seed
---
apiVersion: v1
kind: Service
metadata:
name: home-manager
namespace: home-manager
spec:
type: ClusterIP
selector:
app: hermes
ports:
- name: gateway
port: 80
targetPort: 8642
- name: dashboard
port: 9119
targetPort: 9119

24
home-manager/ingress.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: home-manager
namespace: home-manager
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
tls:
- hosts:
- home-manager.rogi.casa
secretName: home-manager-tls
rules:
- host: home-manager.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: home-manager
port:
number: 9119 # dashboard

View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: home-manager

11
home-manager/pvc.yaml Normal file
View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: hermes-data
namespace: home-manager
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi

42
home-manager/rbac.yaml Normal file
View File

@@ -0,0 +1,42 @@
# Minimal RBAC for the cron-seed Job ONLY.
#
# The Hermes home-manager agent itself has NO k8s RBAC — it manages the home
# via the Home Assistant REST API (http://home-assistant.home-assistant:80).
#
# The cron-seed Job needs to `kubectl exec` into the hermes pod to run
# `hermes cron delete` + `hermes cron create ...` (the only way to seed and
# reconcile Hermes' internal cron schedule from Git).
# Scoped to this namespace, pods/exec on the hermes pod only.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: cron-seeder
namespace: home-manager
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: cron-seeder
namespace: home-manager
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["pods/exec"]
verbs: ["create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: cron-seeder
namespace: home-manager
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: cron-seeder
subjects:
- kind: ServiceAccount
name: cron-seeder
namespace: home-manager

View File

@@ -0,0 +1,109 @@
---
# Home Assistant configuration backup.
#
# This CronJob copies Home Assistant backup tarballs from the config PVC
# (/config/backups) to the Synology NAS over SSH (rsync), so you have an
# off-PVC copy of your configuration.
#
# Prerequisite 1 (in the Home Assistant UI):
# Enable automatic backups so tarballs land in /config/backups:
# Settings -> System -> Backups -> (menu) -> Schedule backups
# Recommended: daily backup, e.g. "Every day at 04:00", keep the last 7.
#
# Prerequisite 2 (manual, once - secrets are NOT committed to Git):
# Create the NAS SSH credentials secret in the cluster:
#
# kubectl create secret generic home-assistant-backup-nas \
# --from-literal=NAS_USER=<nas-ssh-user> \
# --from-literal=NAS_HOST=10.88.30.10 \
# --from-literal=NAS_PORT=22 \
# --from-literal=NAS_PATH='<shared-folder-path>/home-assistant-backups' \
# --from-file=ssh-privatekey=$HOME/.ssh/id_rsa_nas \
# -n home-assistant
#
# Also add the NAS to known_hosts (so rsync doesn't prompt):
#
# ssh-keyscan -p 22 -H 10.88.30.10 > /tmp/nas_known_hosts
# kubectl create configmap home-assistant-backup-known-hosts \
# --from-file=known_hosts=/tmp/nas_known_hosts \
# -n home-assistant
#
# The NAS_HOST above (10.88.30.10) matches the NAS IP used by the nas-proxy
# service in this repo. Adjust NAS_PATH/credentials to your Synology share.
apiVersion: batch/v1
kind: CronJob
metadata:
name: home-assistant-backup
namespace: home-assistant
spec:
schedule: "30 4 * * *" # daily at 04:30 (after the HA 04:00 backup)
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: alpine:3.20
env:
- name: NAS_USER
valueFrom:
secretKeyRef:
name: home-assistant-backup-nas
key: NAS_USER
- name: NAS_HOST
valueFrom:
secretKeyRef:
name: home-assistant-backup-nas
key: NAS_HOST
- name: NAS_PORT
valueFrom:
secretKeyRef:
name: home-assistant-backup-nas
key: NAS_PORT
- name: NAS_PATH
valueFrom:
secretKeyRef:
name: home-assistant-backup-nas
key: NAS_PATH
command: ["/bin/sh", "-c"]
args:
- |
set -e
apk add --no-cache rsync openssh-client
mkdir -p ~/.ssh
cp /ssh-keys/ssh-privatekey ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
cp /known-hosts/known_hosts ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
echo "Syncing /config/backups -> ${NAS_USER}@${NAS_HOST}:${NAS_PATH}/"
rsync -a --delete -e "ssh -p ${NAS_PORT} -o StrictHostKeyChecking=yes" \
/config/backups/ "${NAS_USER}@${NAS_HOST}:${NAS_PATH}/"
echo "Backup sync complete."
volumeMounts:
- name: config
mountPath: /config
readOnly: true
- name: ssh-keys
mountPath: /ssh-keys
readOnly: true
- name: known-hosts
mountPath: /known-hosts
readOnly: true
volumes:
- name: config
persistentVolumeClaim:
claimName: home-assistant-config
- name: ssh-keys
secret:
secretName: home-assistant-backup-nas
items:
- key: ssh-privatekey
path: ssh-privatekey
- name: known-hosts
configMap:
name: home-assistant-backup-known-hosts

View File

@@ -32,7 +32,9 @@ data:
http:
use_x_forwarded_for: true
trusted_proxies:
- 10.88.88.0/24
- 10.42.0.0/16 # k3s pod CIDR (Traefik pod lives here)
- 10.43.0.0/16 # k3s service CIDR
- 10.88.20.0/24 # node subnet (Traefik runs hostNetwork-ish, forwards from 10.88.20.11)
---
apiVersion: apps/v1
kind: Deployment
@@ -53,20 +55,17 @@ spec:
spec:
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:stable
image: ghcr.io/home-assistant/home-assistant:2026.7.4
resources:
requests:
memory: "256Mi"
limits:
memory: "512Mi"
limits:
memory: "1Gi"
ports:
- containerPort: 8123
volumeMounts:
- name: config
mountPath: /config
- name: configuration
mountPath: /config/configuration.yaml
subPath: configuration.yaml
- name: localtime
mountPath: /etc/localtime
readOnly: true
@@ -80,6 +79,28 @@ spec:
- NET_ADMIN
- NET_RAW
- SYS_ADMIN
# Seed configuration.yaml from the ConfigMap onto the writable PVC so that
# Home Assistant has a writable configuration.yaml (required for the UI to
# create/save scripts, helpers, reloads, etc.). The ConfigMap stays the
# GitOps source of truth: it is re-copied on every pod start. UI-managed
# entities (scripts, automations, integrations) live in /config/.storage
# on the PVC and are never overwritten by this step.
initContainers:
- name: seed-config
image: busybox:1.36
command: ["/bin/sh", "-c"]
args:
- |
set -e
echo "Seeding /config/configuration.yaml from ConfigMap..."
cp /config-cm/configuration.yaml /config/configuration.yaml
echo "Done."
volumeMounts:
- name: config
mountPath: /config
- name: configuration
mountPath: /config-cm
readOnly: true
hostNetwork: true
volumes:
- name: config

View File

@@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: homeassistant
namespace: home-assistant
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
tls:
- hosts:
- homeassistant.rogi.casa
secretName: homeassistant-tls
rules:
- host: homeassistant.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: home-assistant
port:
number: 80

View File

@@ -1,307 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: rogicasa-ingress
namespace: default
annotations:
# Use Traefik as the ingress controller (default in k3s)
kubernetes.io/ingress.class: "traefik"
# Enable SSL redirect
traefik.ingress.kubernetes.io/redirect-entry-point: https
# Optional: enable compression
traefik.ingress.kubernetes.io/compress: "true"
cert-manager.io/issuer: prod-issuer
cert-manager.io/issuer-kind: OriginIssuer
cert-manager.io/issuer-group: cert-manager.k8s.cloudflare.com
spec:
tls:
- hosts:
- "*.rogi.casa"
secretName: rogicasa-tls
rules:
- host: glance.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: glance-service
port:
number: 80
- host: pihole.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: pihole-web
port:
number: 80
- host: litellm.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: litellm-service
port:
number: 80
- host: openai.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: open-webui-service
port:
number: 80
- host: gym.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gym-tracker
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gitea-ingress
namespace: gitea
annotations:
# Use Traefik as the ingress controller (default in k3s)
kubernetes.io/ingress.class: "traefik"
# Enable SSL redirect
traefik.ingress.kubernetes.io/redirect-entry-point: https
# Optional: enable compression
traefik.ingress.kubernetes.io/compress: "true"
cert-manager.io/issuer: prod-issuer
cert-manager.io/issuer-kind: OriginIssuer
cert-manager.io/issuer-group: cert-manager.k8s.cloudflare.com
spec:
tls:
- hosts:
- "*.rogi.casa"
secretName: rogicasa-tls
rules:
- host: gitea.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gitea
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: monitoring-ingress
namespace: monitoring
annotations:
# Use Traefik as the ingress controller (default in k3s)
kubernetes.io/ingress.class: "traefik"
# Enable SSL redirect
traefik.ingress.kubernetes.io/redirect-entry-point: https
# Optional: enable compression
traefik.ingress.kubernetes.io/compress: "true"
cert-manager.io/issuer: prod-issuer
cert-manager.io/issuer-kind: OriginIssuer
cert-manager.io/issuer-group: cert-manager.k8s.cloudflare.com
spec:
tls:
- hosts:
- "*.rogi.casa"
secretName: rogicasa-tls
rules:
- host: grafana.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: grafana
port:
number: 80
- host: prometheus.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: prometheus-k8s
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: vaultwarden-ingress
namespace: vaultwarden
annotations:
# Use Traefik as the ingress controller (default in k3s)
kubernetes.io/ingress.class: "traefik"
# Enable SSL redirect
traefik.ingress.kubernetes.io/redirect-entry-point: https
# Optional: enable compression
traefik.ingress.kubernetes.io/compress: "true"
cert-manager.io/issuer: prod-issuer
cert-manager.io/issuer-kind: OriginIssuer
cert-manager.io/issuer-group: cert-manager.k8s.cloudflare.com
spec:
tls:
- hosts:
- "*.rogi.casa"
secretName: rogicasa-tls
rules:
- host: vaultwarden.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vaultwarden
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: homeassistant-ingress
namespace: home-assistant
annotations:
# Use Traefik as the ingress controller (default in k3s)
kubernetes.io/ingress.class: "traefik"
# Enable SSL redirect
traefik.ingress.kubernetes.io/redirect-entry-point: https
# Optional: enable compression
traefik.ingress.kubernetes.io/compress: "true"
cert-manager.io/issuer: prod-issuer
cert-manager.io/issuer-kind: OriginIssuer
cert-manager.io/issuer-group: cert-manager.k8s.cloudflare.com
spec:
tls:
- hosts:
- "*.rogi.casa"
secretName: rogicasa-tls
rules:
- host: homeassistant.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: home-assistant
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: minecraft-ingress
namespace: minecraft
annotations:
# Use Traefik as the ingress controller (default in k3s)
kubernetes.io/ingress.class: "traefik"
# Enable SSL redirect
traefik.ingress.kubernetes.io/redirect-entry-point: https
# Optional: enable compression
traefik.ingress.kubernetes.io/compress: "true"
cert-manager.io/issuer: prod-issuer
cert-manager.io/issuer-kind: OriginIssuer
cert-manager.io/issuer-group: cert-manager.k8s.cloudflare.com
spec:
tls:
- hosts:
- "*.rogi.casa"
secretName: rogicasa-tls
rules:
- host: minecraft.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: minecraft-server
port:
number: 25565
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: argocd-ingress
namespace: argocd
annotations:
# Use Traefik as the ingress controller (default in k3s)
kubernetes.io/ingress.class: "traefik"
# Enable SSL redirect
traefik.ingress.kubernetes.io/redirect-entry-point: https
# Optional: enable compression
traefik.ingress.kubernetes.io/compress: "true"
cert-manager.io/issuer: prod-issuer
cert-manager.io/issuer-kind: OriginIssuer
cert-manager.io/issuer-group: cert-manager.k8s.cloudflare.com
spec:
tls:
- hosts:
- "*.rogi.casa"
secretName: rogicasa-tls
rules:
- host: argocd.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: argocd-server
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nas-ingress
namespace: default
annotations:
# Use Traefik as the ingress controller (default in k3s)
kubernetes.io/ingress.class: "traefik"
# Enable SSL redirect
traefik.ingress.kubernetes.io/redirect-entry-point: https
# Optional: enable compression
traefik.ingress.kubernetes.io/compress: "true"
# Allow large file uploads (5GB) for NAS
traefik.ingress.kubernetes.io/max-request-body-bytes: "5368709120"
cert-manager.io/issuer: prod-issuer
cert-manager.io/issuer-kind: OriginIssuer
cert-manager.io/issuer-group: cert-manager.k8s.cloudflare.com
spec:
tls:
- hosts:
- "*.rogi.casa"
secretName: rogicasa-tls
rules:
- host: nas.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: external-ip
port:
number: 80

View File

@@ -7,14 +7,12 @@ metadata:
kubernetes.io/ingress.class: "traefik"
traefik.ingress.kubernetes.io/redirect-entry-point: https
traefik.ingress.kubernetes.io/compress: "true"
cert-manager.io/issuer: prod-issuer
cert-manager.io/issuer-kind: OriginIssuer
cert-manager.io/issuer-group: cert-manager.k8s.cloudflare.com
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- "*.rogi.casa"
secretName: rogicasa-tls
- jellyfin.rogi.casa
secretName: jellyfin-tls
rules:
- host: jellyfin.rogi.casa
http:

View File

@@ -115,7 +115,7 @@ spec:
accessModes:
- ReadWriteMany
nfs:
server: 10.88.88.238
server: 10.88.30.10
path: /volume1/jellyfin/media
persistentVolumeReclaimPolicy: Retain
---

24
litellm/ingress.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: litellm
namespace: litellm
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
tls:
- hosts:
- litellm.rogi.casa
secretName: litellm-tls
rules:
- host: litellm.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: litellm-service
port:
number: 80

View File

@@ -1,26 +1,61 @@
apiVersion: v1
kind: Namespace
metadata:
name: litellm
---
apiVersion: v1
kind: ConfigMap
metadata:
name: litellm-config-file
namespace: litellm
data:
config.yaml: |
model_list:
- model_name: gpt-5-mini
- model_name: gpt-5.6-luna
litellm_params:
model: openai/gpt-5-mini-2025-08-07
model: openai/gpt-5.6-luna
api_key: "os.environ/OPENAI_API_KEY"
- model_name: claude-4.5-haiku
- model_name: claude-haiku-4.5
litellm_params:
model: "anthropic/claude-haiku-4-5-20251001"
api_key: "os.environ/ANTHROPIC_API_KEY"
- model_name: gemini-3-flash
- model_name: claude-sonnet-5
litellm_params:
model: gemini/gemini-3-flash-preview
model: "anthropic/claude-sonnet-5"
api_key: "os.environ/ANTHROPIC_API_KEY"
- model_name: gemini-3.6-flash
litellm_params:
model: gemini/gemini-3.6-flash
api_key: "os.environ/GEMINI_API_KEY"
- model_name: tencent/hy3
litellm_params:
model: openrouter/tencent/hy3
api_key: "os.environ/OPENROUTER_API_KEY"
- model_name: z-ai/glm-5.2
litellm_params:
model: openrouter/z-ai/glm-5.2
api_key: "os.environ/OPENROUTER_API_KEY"
- model_name: glm-4.7-flash
litellm_params:
model: ollama/glm-4.7-flash
api_base: http://10.88.88.235:11434
api_base: http://10.88.20.12:11434
# Used by the platform-engineer Hermes agent (deployed in ns platform-engineer).
# Served by llama.cpp (llama-server, Vulkan on the NUCBox APU) — see
# llamacpp/deployment-qwen36-27b.yaml. Thinking is left ON; reasoning comes
# back in `reasoning_content` (llama-server --jinja + default reasoning format).
- model_name: qwen3.6-27b
litellm_params:
model: openai/qwen3.6-27b
api_base: http://llamacpp-qwen36-27b.llamacpp/v1
api_key: "sk-no-auth"
# Faster "flash" variant: Qwen3.6-35B-A3B is a MoE (3B active params), so
# inference is much faster than the dense 27B despite more total weights.
# See llamacpp/deployment-qwen36-35b-a3b.yaml.
- model_name: qwen3.6-35b-a3b
litellm_params:
model: openai/qwen3.6-35b-a3b
api_base: http://llamacpp-qwen36-35b-a3b.llamacpp/v1
api_key: "sk-no-auth"
litellm_settings:
#set_verbose: True # Uncomment this if you want to see verbose logs; not recommended in production
callbacks: ["arize_phoenix"]
@@ -31,7 +66,7 @@ data:
request_timeout: 600
telemetry: False
general_settings:
master_key: sk-1234 # [OPTIONAL] Use to enforce auth on proxy. See - https://docs.litellm.ai/docs/proxy/virtual_keys
master_key: os.environ/LITELLM_MASTER_KEY
store_model_in_db: True
proxy_budget_rescheduler_min_time: 60
proxy_budget_rescheduler_max_time: 64
@@ -50,6 +85,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: litellm-deployment
namespace: litellm
labels:
app: litellm
spec:
@@ -69,6 +105,13 @@ spec:
- "/app/proxy_server_config.yaml"
ports:
- containerPort: 4000
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "1000m"
volumeMounts:
- name: config-volume
mountPath: /app/proxy_server_config.yaml
@@ -88,7 +131,7 @@ apiVersion: v1
kind: Service
metadata:
name: litellm-service
namespace: default
namespace: litellm
spec:
type: ClusterIP
selector:

View File

@@ -18,6 +18,7 @@ apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-volume-claim
namespace: litellm
labels:
app: postgres
spec:
@@ -32,6 +33,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: litellm
spec:
replicas: 1
selector:
@@ -63,6 +65,7 @@ apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: litellm
labels:
app: postgres
spec:

109
llamacpp/README.md Normal file
View File

@@ -0,0 +1,109 @@
# llama.cpp (llama-server)
In-cluster LLM inference via llama.cpp's `llama-server`, serving local models on
the NUCBox APU (AMD Ryzen AI Max 395 / Strix Halo, Radeon 8060S, 128 GiB unified
memory: 32 GiB RAM / 96 GiB VRAM).
This replaces the bare-metal Ollama setup for models that benefit from
always-loaded weights + tuned batching. LiteLLM (`litellm/`) points at these
in-cluster Services instead of the external `10.88.20.12:11434` Ollama endpoint.
## Layout
One Deployment + Service **per model**, all in namespace `llamacpp`, all pinned
to the NUCBox (`nodeSelector: {kubernetes.io/arch: amd64, hardware: high-memory}`):
| Alias | Model | GGUF | Service |
|-------------------|----------------------------|-------------------------------------------------------|-------------------------------------------|
| `qwen3.6-27b` | Qwen3.6-27B (dense) | unsloth/Qwen3.6-27B-MTP-GGUF (Q4_K_XL, ~16 GiB) | `llamacpp-qwen36-27b.llamacpp:80` |
| `qwen3.6-35b-a3b` | Qwen3.6-35B-A3B (MoE/flash)| unsloth/Qwen3.6-35B-A3B-MTP-GGUF (Q4_K_XL, ~20 GiB) | `llamacpp-qwen36-35b-a3b.llamacpp:80` |
The 35B-A3B is a Mixture-of-Experts model (3B active params per token), so
inference is significantly faster than the dense 27B despite more total weights —
hence the "flash" label. Use it for latency-sensitive workloads; use the 27B for
deeper reasoning.
Model files are downloaded idempotently by an initContainer into a shared
hostPath PVC (`/data/llamacpp/models` on the NUCBox), so pods survive reboots
without re-downloading.
## GPU / Vulkan
The `server-vulkan` image (`ghcr.io/ggml-org/llama.cpp:server-vulkan`) bundles
the Mesa/RADV Vulkan driver, which supports the Radeon 8060S (RDNA 3.5). Full
layer offload (`-ngl 999`) puts model weights entirely in the 96 GiB VRAM pool.
The container mounts `/dev/dri` and runs `privileged: true` — the simplest
reliable way to give Vulkan access to the DRM render node on k3s without a
device plugin. Tighten later with `supplementalGroups` (the host's `render`
group GID) if desired.
### Verify the GPU is actually used
```bash
kubectl logs -n llamacpp deploy/llamacpp-qwen36-27b | grep -iE 'vulkan|gpu|offload|device'
kubectl logs -n llamacpp deploy/llamacpp-qwen36-35b-a3b | grep -iE 'vulkan|gpu|offload|device'
```
If only a CPU device shows up, the container can't see the GPU — check that
`/dev/dri/renderD128` exists on the NUCBox and that the `amdgpu` module is loaded.
## VRAM budget (both models co-resident)
Both models run simultaneously on the same 96 GiB VRAM pool. Approximate usage:
| Model | Weights | KV cache | Subtotal |
|-------------------|----------|-------------------------------------------|----------|
| qwen3.6-27b | ~16 GiB | ~16 GiB (q8_0, 131k total, 1 slot) | ~32 GiB |
| qwen3.6-35b-a3b | ~20 GiB | ~9 GiB (q8_0, 262k total, 131k/slot) | ~29 GiB |
| **Total** | | | **~61 GiB** |
~35 GiB headroom — comfortable. Both models' KV caches are quantized
to q8_0 (halved vs f16, ~negligible quality loss), which makes the dense 27B's
large-context KV affordable (~256 KiB/token f16 → ~128 KiB/token q8_0). The
a3b's KV is tiny (~72 KiB/token) so its large context is nearly free.
## Tuning
The key knobs (in each `deployment-*.yaml`):
- `-ngl 999` — offload all layers to GPU. Reduce only if VRAM is tight.
- `-c` — total KV-cache context. The 27B runs `-c 131072 -np 1` (single slot
gets the full 131k); the a3b runs `-c 262144 -np 2` (2 slots × 131k each).
The MoE a3b's KV cache is ~72 KiB/token so large context is cheap; the dense
27B's is ~256 KiB/token (f16) / ~128 KiB/token (q8_0), which is why the 27B
uses q8_0 KV to keep 131k affordable (~16 GiB) and sticks to 1 slot.
- `-np` — parallel slots (concurrent requests). Each extra slot multiplies
KV-cache VRAM usage. The 27B uses 1 slot (full 131k to the single request,
dense KV is the constraint); the a3b uses 2 slots (131k each, cheap MoE KV).
Bump higher only if you need more concurrent throughput.
- `--cache-type-k q8_0 --cache-type-v q8_0` (both) — quantize the KV cache to
q8_0, halving KV VRAM with ~negligible quality loss. Essential for the dense
27B at 131k (f16 would be ~32 GiB KV alone); nearly free headroom on the a3b.
Drop to q4_0 for even less VRAM if retrieval quality allows.
- `--threads 8` — CPU threads for sampling/overhead. Mostly irrelevant under
full GPU offload; tune if CPU-bound.
## Memory accounting
k8s sees only the ~32 GiB system RAM as allocatable (the 96 GiB VRAM is
reserved by firmware and managed by `amdgpu`). The model weights and KV cache
live in VRAM and are **not** counted against the container's cgroup memory
limit — that limit only covers CPU-side overhead and the mmap'd GGUF pages
during load. If the pod is OOM-killed during model load, raise the memory limit.
## Adding a model
1. Copy `deployment-qwen36-27b.yaml``deployment-<new>.yaml`; change the
`model:` label, GGUF URL/file, `--alias`, and Service name.
2. Point LiteLLM at it in `litellm/litellm.yaml`:
```yaml
- model_name: <alias>
litellm_params:
model: openai/<alias>
api_base: http://<service>.llamacpp/v1
api_key: "sk-no-auth"
```
3. (No gen-apps.sh change needed — the `llamacpp` app already syncs the whole
directory recursively.)
4. Check the VRAM budget table above — two large models may not coexist.

View File

@@ -0,0 +1,163 @@
# Qwen3.6-27B (dense, Q4_K_XL) served by llama.cpp's llama-server on the NUCBox APU.
#
# Hardware: AMD Ryzen AI Max 395 (Strix Halo) — integrated Radeon 8060S,
# 128 GiB unified memory (32 GiB RAM / 96 GiB VRAM via firmware). The Q4 model
# (~16 GiB) is fully offloaded to the GPU via the Vulkan backend.
#
# Image: ghcr.io/ggml-org/llama.cpp:server-vulkan bundles the Mesa/RADV Vulkan
# driver, which supports the Radeon 8060S (RDNA 3.5). The project moved from the
# legacy `ggerganov/llama.cpp` namespace (which only has light/full tags) to
# `ggml-org/llama.cpp` (server-vulkan + pinned build tags like server-vulkan-bXXXX).
# Pin to a build tag (e.g. server-vulkan-b4738) for production reproducibility.
#
# GPU access: the container mounts /dev/dri (the DRM render nodes) and runs
# privileged. This is the simplest reliable option on k3s without a Vulkan
# device plugin; tighten later with supplementalGroups if desired.
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: llamacpp-qwen36-27b
namespace: llamacpp
labels:
app: llamacpp
model: qwen3.6-27b
spec:
replicas: 1
strategy:
type: Recreate # never run two pods loading the same model into VRAM
selector:
matchLabels:
app: llamacpp
model: qwen3.6-27b
template:
metadata:
labels:
app: llamacpp
model: qwen3.6-27b
spec:
nodeSelector:
kubernetes.io/arch: amd64
hardware: high-memory
initContainers:
# Idempotently download the GGUF into the shared models PVC on first boot.
# Exits immediately if the file is already present (pod restart / recreate).
- name: fetch-model
image: alpine:3.20
command: ["/bin/sh", "-c"]
args:
- |
set -e
if [ -f "/models/$MODEL_FILE" ]; then
echo "Model $MODEL_FILE already present — skipping download."
exit 0
fi
echo "Installing curl..."
apk add --no-cache curl
echo "Downloading $MODEL_FILE from $MODEL_URL ..."
curl -fL --retry 5 --retry-delay 5 -o "/models/$MODEL_FILE" "$MODEL_URL"
echo "Download complete: $(ls -lh /models/$MODEL_FILE)"
env:
- name: MODEL_URL
value: "https://huggingface.co/unsloth/Qwen3.6-27B-MTP-GGUF/resolve/main/Qwen3.6-27B-UD-Q4_K_XL.gguf"
- name: MODEL_FILE
value: "Qwen3.6-27B-UD-Q4_K_XL.gguf"
volumeMounts:
- name: models
mountPath: /models
containers:
- name: llama-server
image: ghcr.io/ggml-org/llama.cpp:server-vulkan
imagePullPolicy: IfNotPresent
# llama.cpp's CLI parser does NOT split on '=' — every value flag must be a
# separate argv element (flag, then value). See common/arg.cpp in the repo.
args:
- -m # model file
- /models/Qwen3.6-27B-UD-Q4_K_XL.gguf
- --alias # /v1/models reports this name; matches the litellm alias
- qwen3.6-27b
- --host
- 0.0.0.0
- --port
- "8080"
- --jinja # use the GGUF's chat template (Qwen3 thinking format)
- -ngl # offload ALL layers to the GPU (fits in 96 GiB VRAM)
- "999"
- -c # total KV-cache context (single slot gets the full window)
- "131072"
- -np # 1 slot => 131072 tokens for the single request (no split)
- "1"
- --cont-batching # continuous batching across slots
- --cache-type-k # quantize KV cache to q8_0 — halves KV VRAM (~32 GiB → ~16 GiB
- q8_0 # at 131k ctx); ~negligible quality loss, frees headroom for large -c
- --cache-type-v
- q8_0
- --threads # CPU threads for sampling/overhead (GPU does the heavy lifting)
- "8"
ports:
- name: http
containerPort: 8080
resources:
# The model weights + KV cache live in GPU VRAM (96 GiB pool) and are
# NOT counted against the cgroup memory limit. This limit only covers
# CPU-side overhead + the mmap'd GGUF file pages during load (~16 GiB,
# reclaimable). k8s sees ~32 GiB as the node's allocatable system RAM,
# so the request is kept low to stay schedulable alongside other pods.
# If the pod OOM-kills during load, the amdgpu driver may be counting
# some VRAM against the cgroup — raise the limit.
requests:
cpu: "1000m"
memory: "2Gi"
limits:
cpu: "4000m"
memory: "24Gi"
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 6
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 180 # model load + Vulkan init can take a few minutes
periodSeconds: 30
failureThreshold: 5
securityContext:
# Vulkan on the AMD APU needs /dev/dri + the driver. Privileged is the
# simplest reliable path on k3s without a device plugin.
privileged: true
volumeMounts:
- name: models
mountPath: /models
readOnly: true
- name: dri
mountPath: /dev/dri
volumes:
- name: models
persistentVolumeClaim:
claimName: llamacpp-models
- name: dri
hostPath:
path: /dev/dri
type: Directory
---
apiVersion: v1
kind: Service
metadata:
name: llamacpp-qwen36-27b
namespace: llamacpp
labels:
app: llamacpp
model: qwen3.6-27b
spec:
type: ClusterIP
selector:
app: llamacpp
model: qwen3.6-27b
ports:
- name: http
port: 80
targetPort: 8080

View File

@@ -0,0 +1,162 @@
# Qwen3.6-35B-A3B (MoE: 35B total / 3B active, Q4_K_XL) — the "flash" variant.
#
# Despite having more total parameters than the 27B dense model, only 3B are
# active per token (Mixture-of-Experts), so inference is much faster. The full
# ~20 GiB of Q4 weights is still loaded into VRAM but only a small fraction is
# computed per token.
#
# Hardware: AMD Ryzen AI Max 395 (Strix Halo) — integrated Radeon 8060S,
# 128 GiB unified memory (32 GiB RAM / 96 GiB VRAM via firmware). Full GPU
# offload via the Vulkan backend. Shares the 96 GiB VRAM pool with the 27B
# model — see llamacpp/README.md for the combined VRAM budget.
#
# Image: ghcr.io/ggml-org/llama.cpp:server-vulkan (Mesa/RADV Vulkan driver,
# supports the Radeon 8060S / RDNA 3.5). Pin to a build tag for production.
#
# GPU access: mounts /dev/dri + privileged (simplest reliable path on k3s).
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: llamacpp-qwen36-35b-a3b
namespace: llamacpp
labels:
app: llamacpp
model: qwen3.6-35b-a3b
spec:
replicas: 1
strategy:
type: Recreate # never run two pods loading the same model into VRAM
selector:
matchLabels:
app: llamacpp
model: qwen3.6-35b-a3b
template:
metadata:
labels:
app: llamacpp
model: qwen3.6-35b-a3b
spec:
nodeSelector:
kubernetes.io/arch: amd64
hardware: high-memory
initContainers:
# Idempotently download the GGUF into the shared models PVC on first boot.
# Exits immediately if the file is already present (pod restart / recreate).
- name: fetch-model
image: alpine:3.20
command: ["/bin/sh", "-c"]
args:
- |
set -e
if [ -f "/models/$MODEL_FILE" ]; then
echo "Model $MODEL_FILE already present — skipping download."
exit 0
fi
echo "Installing curl..."
apk add --no-cache curl
echo "Downloading $MODEL_FILE from $MODEL_URL ..."
curl -fL --retry 5 --retry-delay 5 -o "/models/$MODEL_FILE" "$MODEL_URL"
echo "Download complete: $(ls -lh /models/$MODEL_FILE)"
env:
- name: MODEL_URL
value: "https://huggingface.co/unsloth/Qwen3.6-35B-A3B-MTP-GGUF/resolve/main/Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf"
- name: MODEL_FILE
value: "Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf"
volumeMounts:
- name: models
mountPath: /models
containers:
- name: llama-server
image: ghcr.io/ggml-org/llama.cpp:server-vulkan
imagePullPolicy: IfNotPresent
# llama.cpp's CLI parser does NOT split on '=' — every value flag must be a
# separate argv element (flag, then value). See common/arg.cpp in the repo.
args:
- -m # model file
- /models/Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf
- --alias # /v1/models reports this name; matches the litellm alias
- qwen3.6-35b-a3b
- --host
- 0.0.0.0
- --port
- "8080"
- --jinja # use the GGUF's chat template (Qwen3 thinking format)
- -ngl # offload ALL layers to the GPU (fits in 96 GiB VRAM)
- "999"
- -c # total KV-cache context, split across parallel slots
- "262144"
- -np # 2 parallel slots => 131072 tokens per concurrent request
- "2"
- --cont-batching # continuous batching across slots
- --cache-type-k # quantize KV cache to q8_0 — halves KV VRAM (~18 GiB → ~9 GiB
- q8_0 # at 262k ctx); ~negligible quality loss, frees headroom for large -c
- --cache-type-v # (raise to q4_0 for even less VRAM if retrieval quality allows)
- q8_0
- --threads # CPU threads for sampling/overhead (GPU does the heavy lifting)
- "8"
ports:
- name: http
containerPort: 8080
resources:
# The model weights + KV cache live in GPU VRAM (96 GiB pool) and are
# NOT counted against the cgroup memory limit. This limit only covers
# CPU-side overhead + the mmap'd GGUF file pages during load (~20 GiB,
# reclaimable). k8s sees ~32 GiB as the node's allocatable system RAM.
# If the pod OOM-kills during load, raise the limit.
requests:
cpu: "1000m"
memory: "2Gi"
limits:
cpu: "4000m"
memory: "24Gi"
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 6
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 180 # model load + Vulkan init can take a few minutes
periodSeconds: 30
failureThreshold: 5
securityContext:
# Vulkan on the AMD APU needs /dev/dri + the driver. Privileged is the
# simplest reliable path on k3s without a device plugin.
privileged: true
volumeMounts:
- name: models
mountPath: /models
readOnly: true
- name: dri
mountPath: /dev/dri
volumes:
- name: models
persistentVolumeClaim:
claimName: llamacpp-models
- name: dri
hostPath:
path: /dev/dri
type: Directory
---
apiVersion: v1
kind: Service
metadata:
name: llamacpp-qwen36-35b-a3b
namespace: llamacpp
labels:
app: llamacpp
model: qwen3.6-35b-a3b
spec:
type: ClusterIP
selector:
app: llamacpp
model: qwen3.6-35b-a3b
ports:
- name: http
port: 80
targetPort: 8080

4
llamacpp/namespace.yaml Normal file
View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: llamacpp

47
llamacpp/pv.yaml Normal file
View File

@@ -0,0 +1,47 @@
# Shared model-weight storage for all llama.cpp pods.
#
# All llamacpp pods are pinned to the NUCBox (roger-nucbox-evo-x2) via
# nodeSelector, so a single hostPath PV on that node is correct and matches the
# existing postgres hostPath pattern. GGUF files are large (10s of GB); baking
# them into images would be wasteful, and an initContainer downloads them
# idempotently on first boot instead.
#
# nodeAffinity keeps the PV bound to the NUCBox even if labels change later.
apiVersion: v1
kind: PersistentVolume
metadata:
name: llamacpp-models
labels:
type: local
app: llamacpp
spec:
storageClassName: manual
capacity:
storage: 100Gi
accessModes:
- ReadWriteMany
hostPath:
path: /data/llamacpp/models
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- roger-nucbox-evo-x2
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: llamacpp-models
namespace: llamacpp
labels:
app: llamacpp
spec:
storageClassName: manual
accessModes:
- ReadWriteMany
resources:
requests:
storage: 100Gi

View File

@@ -12,10 +12,12 @@ metadata:
labels:
app: minecraft-server
spec:
type: ClusterIP
type: LoadBalancer
loadBalancerIP: 10.88.20.103
ports:
- name: minecraft
port: 25565
targetPort: 25565
protocol: TCP
selector:
app: minecraft-server

View File

@@ -0,0 +1,354 @@
# Dashboard Ideas
This file collects ideas for additional Grafana dashboards to build for the
`rogi.casa` k3s cluster. Each idea notes the **data source** (metrics already
available vs. metrics that need to be enabled) and a rough panel layout.
To actually add a dashboard, create a `grafana-dashboard-<name>.yaml` ConfigMap
in this folder, mount it in `grafana-deployment.yaml` (add a volume +
volumeMount under `/var/lib/grafana/dashboards/<name>`), commit and push.
---
## Already-scraped services (ready to dashboard now)
These exporters/services are **already being scraped by Prometheus** — dashboards
can be built immediately with no infra changes.
### 1. Traefik (Ingress) — `traefik_*`
Traefik is scraped via the `kubernetes-pods` job (pod annotation on
`traefik-9bcdbbd9-x8zq4` in `kube-system`). It exposes request counters, entry
point latency, TLS handshakes, config reloads.
**Panels:**
- Requests/sec by entrypoint (web / websecure / traefik) — `rate(traefik_entrypoint_requests_total[5m])`
- Request latency p50/p95/p99 — `histogram_quantile(0.95, sum(rate(traefik_entrypoint_request_duration_seconds_bucket[5m])) by (le, entrypoint))`
- HTTP status code distribution (2xx/3xx/4xx/5xx) — `rate(traefik_entrypoint_requests_total{code=~"2xx|3xx|4xx|5xx"}[5m])`
- TLS handshakes/sec — `rate(traefik_entrypoint_requests_tls_total[5m])`
- Config reloads + last reload success — `traefik_config_reloads_total`, `traefik_config_last_reload_success`
- Top routes/services by request volume — `topk(10, sum by (service) (rate(traefik_service_requests_total[5m])))`
- Bytes transferred in/out — `rate(traefik_entrypoint_requests_bytes_total[5m])`
**Why useful:** This is your front door. Knowing which routes get hit most,
latency per ingress, and 5xx spikes is the single most valuable app-level
dashboard in the cluster.
---
### 2. CoreDNS (cluster DNS) — `coredns_*`
Scraped via `kube-dns` Service annotation. Exposes query rate, cache hits,
error types, response duration.
**Panels:**
- DNS queries/sec by zone / type — `rate(coredns_dns_requests_total[5m])`
- Cache hit ratio — `rate(coredns_cache_hits_total[5m]) / rate(coredns_cache_requests_total[5m])`
- DNS query latency p95 — `histogram_quantile(0.95, sum(rate(coredns_dns_request_duration_seconds_bucket[5m])) by (le))`
- Queries by response code (NOERROR / NXDOMAIN / SERVFAIL) — `rate(coredns_dns_responses_total[5m])`
- Cache size — `coredns_cache_entries`
- Forward requests/sec (upstream DNS) — `rate(coredns_forward_requests_total[5m])`
**Why useful:** DNS issues cause cascading failures (ImagePullBackOff, cert
challenges, etc.). A spike in NXDOMAIN/SERVFAIL is an early warning.
---
### 3. MetalLB (LoadBalancer) — `metallb_*`
Scraped via pod annotation on `speaker-*` and `controller` in `metallb-system`.
Exposes IP allocation usage, BGP/session state.
**Panels:**
- IP addresses in use vs. total — `metallb_allocator_addresses_in_use_total` / `metallb_allocator_addresses_total`
- IP pool utilization % (gauge) — `metallb_allocator_addresses_in_use_total / metallb_allocator_addresses_total * 100`
- BGP session up per speaker — `metallb_bgp_session_up`
- Config loaded / stale status — `metallb_k8s_client_config_loaded_bool`, `metallb_k8s_client_config_stale_bool`
- Announcements per speaker — `rate(metallb_bgp_announcements_total[5m])`
**Why useful:** If MetalLB runs out of IPs, new LoadBalancer services will
hang in `<pending>`. Knowing pool utilization lets you act before that happens.
---
### 4. cert-manager (TLS certificates) — `certmanager_*`
Scraped via pod annotations on cert-manager pods. Exposes certificate
expiration, renewal, ready status, ACME challenges.
**Panels:**
- Certificate expiration (days remaining, sorted) — table of `(certmanager_certificate_not_after_timestamp_seconds - time()) / 86400`
- Certificates not Ready — `certmanager_certificate_ready_status{condition="Ready",status!="True"}`
- Upcoming renewals (next 14 days) — `certmanager_certificate_renewal_timestamp_seconds`
- ACME challenge status — `certmanager_certificate_challenge_status`
- Failed renewals counter — `rate(certmanager_certificate_renewal_total{condition="Failed"}[1h])`
**Why useful:** A cert about to expire (or silently failing to renew) is the
kind of thing that takes down `*.rogi.casa` HTTPS with no warning. This is a
must-have alert/dashboard.
---
### 5. Phoenix (trace store) — `phoenix_*`
Already scraped via the `phoenix` Service annotation. Exposes bulk loader
ingestion rates, span insertion times, retention sweeper, exceptions.
**Panels:**
- Span ingestion rate — `rate(phoenix_bulk_loader_span_insertion_time_seconds_count[5m])`
- Span insertion latency p95 — `histogram_quantile(0.95, sum(rate(phoenix_bulk_loader_span_insertion_time_seconds_bucket[5m])) by (le))`
- Span exceptions/sec — `rate(phoenix_bulk_loader_span_exceptions_total[5m])`
- Retention sweeper last run — `phoenix_retention_sweeper_last_run_seconds`
- Last activity timestamp — `phoenix_bulk_loader_last_activity_timestamp_seconds`
**Why useful:** Phoenix is your observability backend's own backend. Tracking
ingestion health tells you whether traces are landing.
---
## Infrastructure dashboards (compose from existing metrics)
### 6. Storage & PVC Health (KSM + kubelet + node-exporter)
Cross-source dashboard combining `kube_persistentvolumeclaim_*` (KSM),
`kubelet_volume_stats_*` (kubelet), and `node_filesystem_*` (node-exporter).
**Panels:**
- PVC usage % per claim — `kubelet_volume_stats_used_bytes / kubelet_volume_stats_capacity_bytes * 100`
- PVC requested vs. capacity — `kube_persistentvolumeclaim_resource_requests_storage_bytes` vs actual
- Node disk usage % (all mounts) — `(1 - node_filesystem_avail / node_filesystem_size) * 100`
- Inode usage % per mount — `(1 - node_filesystem_files_free / node_filesystem_files) * 100`
- Volume binding status (Bound/Pending) — `kube_persistentvolumeclaim_status_phase`
- Top 10 PVCs by usage (table)
**Why useful:** The `local-path` provisioner fills up node disks. Catching a
PVC at 95% before it errors is a lifesaver.
---
### 7. Workload Health (KSM)
Uses kube-state-metrics to show deployment/StatefulSet/CronJob health cluster-wide.
**Panels:**
- Deployments with unavailable replicas — `kube_deployment_status_replicas_available < kube_deployment_status_replicas`
- Pods not in Running phase by namespace — `kube_pod_status_phase{phase!="Running"}`
- Container restarts (last 1h) — `increase(kube_pod_container_status_restarts_total[1h])`
- Pods stuck in CrashLoopBackOff / ImagePullBackOff — `kube_pod_container_status_waiting_reason{reason=~"CrashLoopBackOff|ImagePullBackOff"}`
- Job failures — `kube_job_failed`
- CronJob schedule heatmap — `kube_cronjob_status_active`
- HPA status (if any autoscaled) — `kube_horizontalpodautoscaler_status_current_replicas` vs desired
**Why useful:** This is the "is anything broken" board. Notice you already have
some pods in `ImagePullBackOff` (myorg-assistant) — this dashboard surfaces that.
---
### 8. etcd / Control Plane Health (if exposed)
k3s embeds etcd (or sqlite on single-node). etcd metrics require exposing
the etcd `/metrics` endpoint (typically `--listen-metrics-urls` on the control
plane node). **Requires config change to enable.**
**Panels:**
- Leader changes — `etcd_server_leader_changes_seen_total`
- Proposal commits/sec — `rate(etcd_server_proposals_committed_total[5m])`
- Proposal failures/sec — `rate(etcd_server_proposals_failed_total[5m])`
- DB size — `etcd_mvcc_db_total_size_in_bytes`
- RPC latency p99 — `histogram_quantile(0.99, sum(rate(etcd_grpc Unary grpc latency bucket[5m])) by (le))`
- Active watchers — `etcd_debugging_mvcc_watcher_total`
**Why useful:** etcd is the brain of the cluster. Slow commits or a flipping
leader indicates control-plane trouble.
---
## App-service dashboards (require enabling metrics first)
Most of your apps don't expose `/metrics` yet. Below is the per-service setup
plus the dashboard idea once metrics are on. To enable scraping for any of
these, annotate the Service with:
```yaml
metadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "<port>"
```
The existing `kubernetes-service-endpoints` scrape job will pick them up
automatically — **no Prometheus config edit needed**.
### 9. LiteLLM (LLM gateway) — needs enabling
LiteLLM exposes Prometheus metrics on its API port (`/metrics`). Annotate the
`litellm` Service.
**Panels:**
- Requests/sec by model — `rate(litellm_requests_total[5m])` by `model`
- Token usage (prompt/completion/total) — `rate(litellm_total_tokens_total[5m])`
- Spend by model — `litellm_spend_total` (if cost tracking enabled)
- Latency p95 per model — `histogram_quantile(0.95, ...)`
- Error rate by model — `rate(litellm_requests_total{status=~"5.."}[5m])`
- Rate-limit / quota hits
**Why useful:** LiteLLM is the gateway for all your AI apps (open-webui,
myorg-assistant, etc.). Token spend + per-model latency is the single best
cost/quality lever in the cluster.
---
### 10. Gitea (git + CI) — needs enabling
Gitea exposes metrics at `/metrics` when `ENABLE_METRICS=true` in `app.ini`.
Annotate `gitea-http` Service (port 3000 inside, 80 via svc).
**Panels:**
- Git push/clone/fetch rate — `gitea_actions_total` by `action`
- Active users / repos / orgs — `gitea_users_total`, `gitea_repos_total`
- Issues / PRs open — `gitea_issues_total`, `gitea_pulls_total`
- HTTP request rate + latency
- Gitea Actions runner job duration — if runner metrics exposed
**Why useful:** Gitea hosts the cluster's own GitOps repo + CI. Tracking push
rate and runner throughput catches CI storms.
---
### 11. Home Assistant — needs enabling
HA exposes Prometheus metrics via the `prometheus` integration (add to
`configuration.yaml`). Then annotate the Service.
**Panels:**
- Active entities / sensors by domain
- State change events/sec — `homeassistant_entity_states_total`
- Automation triggers/sec — `homeassistant_automation_triggered_total`
- Integrations loaded + errors
- Database size / recorder queue depth
- Zigbee/Z-Wave mesh health (if exposed)
**Why useful:** HA is a home-critical service. Event/sec spikes often indicate
sensor flapping or runaway automations.
---
### 12. Jellyfin — limited
Jellyfin doesn't ship first-class Prometheus metrics, but you can scrape it
via a sidecar (`jellyfin-prometheus-exporter`) or build a blackbox-style
dashboard on the `/health` endpoint.
**Panels:**
- Active streams — from exporter
- Transcode sessions + hw accel usage
- Library size by media type
- Playback errors
---
### 13. Pi-hole — needs enabling
Pi-hole exposes metrics on its FTL web API; the `pihole-exporter` sidecar
converts them to Prometheus format. Add as a sidecar container + annotate.
**Panels:**
- DNS queries/sec (total, blocked, cached, forwarded)
- Block list size
- Top blocked domains
- Top permitted domains
- Clients by query volume
- Cache hit ratio
**Why useful:** Pi-hole is your network-wide adblock. Block rate + cache ratio
are the headline metrics, and query spikes reveal misbehaving clients.
---
### 14. PostgreSQL (litellm + phoenix + n8n) — needs enabling
You have two Postgres instances (`postgres` in `litellm` and `phoenix`).
Add `prometheus-postgres-exporter` as a sidecar or Deployment per DB.
**Panels (per DB):**
- Connections (active / idle / max) — `pg_stat_activity_count`
- Transactions/sec — `rate(pg_stat_database_xact_commit[5m])`
- Cache hit ratio — `pg_stat_database_blks_hit / (blks_hit + blks_read)`
- Table + index bloat
- Replication lag (if replicas)
- Slow queries (if `pg_stat_statements` enabled)
- DB size growth — `pg_database_size_bytes`
**Why useful:** DB connection exhaustion and cache ratio collapse are the two
most common causes of slow app performance.
---
### 15. Minecraft — limited
The Minecraft server exposes metrics via RCON + an exporter
(`minecraft-exporter`). Add as sidecar using the existing `RCON_PASSWORD`.
**Panels:**
- Players online — `minecraft_players_online`
- TPS (ticks per second) — `minecraft_tps` (server health)
- Entities loaded — `minecraft_entities_total`
- Chunk count — `minecraft_chunks_loaded`
- Memory used by JVM
**Why useful:** TPS < 20 means lag. Player count vs. server load is the only
real signal a Minecraft server needs.
---
### 16. qBittorrent — limited
No native metrics. Options: a `qbittorrent-exporter` sidecar (uses the WebUI
API), or a blackbox probe on the WebUI.
**Panels:**
- Download/upload speed
- Active torrents
- Torrent count by state (downloading/seeding/paused)
- Disk usage in download dir
---
## Cluster meta dashboards
### 17. Network Topology / Service Map
Composite view: for each namespace, list services, their pods, scrape status,
and request volume (from Traefik logs + cAdvisor network). A "what talks to
what" overview.
**Panels:**
- Service pod container resource table
- Cross-namespace network flows (if network policy logging enabled)
- Scrape health matrix (every target up/down)
- Ingress route backend service map
---
### 18. Backup / Snapshot Status
If you take Velero snapshots or local-path snapshots, build a dashboard on
`velero_*` or CRD status. **Requires Velero.**
**Panels:**
- Last successful backup per namespace
- Failed backups
- Backup size growth
- Restore test status
---
### 19. Cost / Capacity Planning
Composite: per-namespace CPU/memory requests vs. actual usage, projected
growth, node saturation forecast.
**Panels:**
- Requests vs. limits vs. actual (per namespace) KSM + cAdvisor
- Node capacity vs. allocatable
- PVC growth trend + 30-day forecast
- "What if I removed node X" simulation (capacity headroom)
**Why useful:** Tells you when you'll need another node before you hit the wall.
---
## Recommended priority order
If you only build a few, do them in this order (highest value-to-effort first):
1. **Traefik Ingress** (#1) already scraped, your front door
2. **Storage & PVC Health** (#6) local-path fills disks; high blast radius
3. **Workload Health** (#7) surfaces CrashLoopBackOff / ImagePullBackOff
4. **cert-manager** (#4) prevents silent cert expiry outages
5. **CoreDNS** (#2) early warning for DNS cascades
6. **LiteLLM** (#9) needs `prometheus.io/scrape` annotation only; big insights
7. **MetalLB** (#3) small but catches LoadBalancer IP exhaustion
Items 819 are nice-to-have or require additional exporters/config.

View File

@@ -0,0 +1,331 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-dashboard-cluster-overview
namespace: monitoring
labels:
app: grafana
grafana_dashboard: "1"
data:
cluster-overview.json: |
{
"annotations": {"list": []},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {"h": 5, "w": 4, "x": 0, "y": 0},
"id": 1,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "auto"
},
"pluginVersion": "10.2.3",
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "time() - max(process_start_time_seconds{job=\"prometheus\"})", "refId": "A"}],
"title": "Prometheus Uptime",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "red", "value": null},
{"color": "green", "value": 1}
]
}
},
"overrides": []
},
"gridPos": {"h": 5, "w": 4, "x": 4, "y": 0},
"id": 2,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"orientation": "horizontal",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "value_and_name"
},
"pluginVersion": "10.2.3",
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "count(kubelet_running_pods)", "refId": "A"}],
"title": "Running Pods (total)",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null}
]
}
},
"overrides": []
},
"gridPos": {"h": 5, "w": 4, "x": 8, "y": 0},
"id": 3,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"orientation": "horizontal",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "value_and_name"
},
"pluginVersion": "10.2.3",
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(kubelet_running_containers)", "refId": "A"}],
"title": "Running Containers",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [
{"options": {"0": {"text": "Down", "color": "red"}, "1": {"text": "Up", "color": "green"}}, "type": "value"}
],
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "red", "value": null},
{"color": "green", "value": 1}
]
}
},
"overrides": []
},
"gridPos": {"h": 5, "w": 12, "x": 12, "y": 0},
"id": 4,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"orientation": "horizontal",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "value_and_name"
},
"pluginVersion": "10.2.3",
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "up{job=\"kubernetes-apiservers\"}", "refId": "A"}, {"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "up{job=\"kubernetes-nodes\"}", "refId": "B"}],
"title": "Control Plane & Node Exporters",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {"legend": false, "tooltip": false, "viz": false},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {"type": "linear"},
"showPoints": "never",
"spanNulls": true,
"stacking": {"group": "A", "mode": "none"},
"thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 5},
"id": 10,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(container_memory_working_set_bytes{container!=\"\",container!=\"POD\"}) by (namespace)", "legendFormat": "{{namespace}}", "refId": "A"}],
"title": "Memory Usage by Namespace",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "core"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 5},
"id": 11,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(rate(container_cpu_usage_seconds_total{container!=\"\",container!=\"POD\"}[5m])) by (namespace)", "legendFormat": "{{namespace}}", "refId": "A"}],
"title": "CPU Usage by Namespace",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "Bps"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 14},
"id": 12,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(rate(container_network_receive_bytes_total[5m])) by (namespace)", "legendFormat": "RX {{namespace}}", "refId": "A"},
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(rate(container_network_transmit_bytes_total[5m])) by (namespace)", "legendFormat": "TX {{namespace}}", "refId": "B"}
],
"title": "Network RX/TX by Namespace",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "decbytes"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 14},
"id": 13,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(container_fs_usage_bytes) by (instance)", "legendFormat": "{{instance}}", "refId": "A"}],
"title": "Filesystem Usage by Node",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 24, "x": 0, "y": 23},
"id": 20,
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {"show": false, "reducer": ["sum"], "countRows": false, "fields": ""}
},
"pluginVersion": "10.2.3",
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sort_desc(sum(container_memory_working_set_bytes{container!=\"\",container!=\"POD\"}) by (namespace,pod))", "format": "table", "instant": true, "refId": "A"}],
"title": "Pods by Memory (live)",
"type": "table",
"transformations": [
{"id": "organize", "options": {"excludeByName": {"Time": true}, "renameByName": {"Value": "Memory (bytes)"}}}
]
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 1}, {"color": "red", "value": 5}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 24, "x": 0, "y": 32},
"id": 30,
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {"show": false, "reducer": ["sum"], "countRows": false, "fields": ""}
},
"pluginVersion": "10.2.3",
"targets": [
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(kube_pod_status_phase{phase=\"Running\"}) by (namespace)", "format": "table", "instant": true, "refId": "A"},
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(kube_pod_status_phase{phase=\"Pending\"}) by (namespace)", "format": "table", "instant": true, "refId": "B"},
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(kube_pod_status_phase{phase=\"Failed\"}) by (namespace)", "format": "table", "instant": true, "refId": "C"},
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(increase(kube_pod_container_status_restarts_total[1h])) by (namespace)", "format": "table", "instant": true, "refId": "D"}
],
"title": "Pod Health by Namespace (KSM)",
"type": "table",
"transformations": [
{"id": "merge", "options": {}},
{"id": "groupBy", "options": {"fields": {"Value": {"aggregations": ["lastNotNull"], "operation": "aggregate"}, "Value #B": {"aggregations": ["lastNotNull"], "operation": "aggregate"}, "Value #C": {"aggregations": ["lastNotNull"], "operation": "aggregate"}, "Value #D": {"aggregations": ["lastNotNull"], "operation": "aggregate"}, "namespace": {"aggregations": [], "operation": "groupby"}}}},
{"id": "organize", "options": {"excludeByName": {"Time": true}, "renameByName": {"Value": "Running", "Value #B": "Pending", "Value #C": "Failed", "Value #D": "Restarts (1h)"}}}
]
}
],
"refresh": "30s",
"schemaVersion": 38,
"style": "dark",
"tags": ["k3s", "overview"],
"templating": {"list": []},
"time": {"from": "now-6h", "to": "now"},
"timepicker": {},
"timezone": "",
"title": "Cluster Overview",
"uid": "k3s-cluster-overview",
"version": 2,
"weekStart": ""
}

View File

@@ -0,0 +1,209 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-dashboard-control-plane
namespace: monitoring
labels:
app: grafana
grafana_dashboard: "1"
data:
control-plane.json: |
{
"annotations": {"list": []},
"editable": true,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 0},
"id": 1,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(rate(apiserver_request_total[5m])) by (verb)", "legendFormat": "{{verb}}", "refId": "A"}],
"title": "API Server Requests by Verb",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "s"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 0},
"id": 2,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "histogram_quantile(0.95, sum(rate(apiserver_request_duration_seconds_bucket[5m])) by (le, verb))", "legendFormat": "p95 {{verb}}", "refId": "A"}],
"title": "API Server Request Latency p95",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "ops"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 9},
"id": 3,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(rate(apiserver_request_total{code=~\"5..\"}[5m])) by (verb)", "legendFormat": "{{verb}}", "refId": "A"}],
"title": "API Server 5xx Errors",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 9},
"id": 4,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(rate(kubelet_container_log_filesystem_used_bytes[5m]))", "legendFormat": "log fs", "refId": "A"}, {"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "histogram_quantile(0.95, sum(rate(kubelet_pod_start_duration_seconds_bucket[5m])) by (le))", "legendFormat": "pod start p95", "refId": "B"}],
"title": "Kubelet Pod Start Latency",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "s"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 18},
"id": 5,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "histogram_quantile(0.95, sum(rate(kubelet_cgroup_manager_duration_seconds_bucket[5m])) by (le, instance))", "legendFormat": "{{instance}}", "refId": "A"}],
"title": "Kubelet Cgroup Manager Duration p95",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 18},
"id": 6,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "rate(kubelet_pleg_relist_duration_seconds_count[5m])", "legendFormat": "relists/s {{instance}}", "refId": "A"}],
"title": "Kubelet PLEG Relist Rate",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [
{"options": {"0": {"text": "Down", "color": "red"}, "1": {"text": "Up", "color": "green"}}, "type": "value"}
],
"thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}
},
"overrides": []
},
"gridPos": {"h": 6, "w": 24, "x": 0, "y": 27},
"id": 7,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"orientation": "horizontal",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "value_and_name"
},
"pluginVersion": "10.2.3",
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "up", "refId": "A"}],
"title": "All Scrape Targets Status",
"type": "stat"
}
],
"refresh": "30s",
"schemaVersion": 38,
"style": "dark",
"tags": ["k3s", "control-plane"],
"templating": {"list": []},
"time": {"from": "now-6h", "to": "now"},
"timepicker": {},
"timezone": "",
"title": "Control Plane & API Server",
"uid": "k3s-control-plane",
"version": 1,
"weekStart": ""
}

View File

@@ -0,0 +1,279 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-dashboard-nodes
namespace: monitoring
labels:
app: grafana
grafana_dashboard: "1"
data:
nodes.json: |
{
"annotations": {"list": []},
"editable": true,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 6, "w": 6, "x": 0, "y": 0},
"id": 1,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "value_and_name"
},
"pluginVersion": "10.2.3",
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "kubelet_running_pods", "refId": "A"}, {"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "kubelet_running_containers", "refId": "B"}],
"title": "Pods / Containers per Node",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 70}, {"color": "red", "value": 90}]},
"unit": "percent",
"min": 0,
"max": 100
},
"overrides": []
},
"gridPos": {"h": 6, "w": 18, "x": 6, "y": 0},
"id": 2,
"options": {
"colorMode": "background",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "value_and_name"
},
"pluginVersion": "10.2.3",
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", "legendFormat": "{{instance}}", "refId": "A"}],
"title": "Node CPU Usage %",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "percent",
"min": 0,
"max": 100
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 6},
"id": 3,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", "legendFormat": "{{instance}}", "refId": "A"}],
"title": "Node CPU Usage % (over time)",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "percent",
"min": 0,
"max": 100
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 6},
"id": 4,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100", "legendFormat": "{{instance}}", "refId": "A"}],
"title": "Node Memory Usage %",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 15},
"id": 5,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(container_memory_working_set_bytes{container!=\"\",container!=\"POD\"}) by (instance)", "legendFormat": "used {{instance}}", "refId": "A"}, {"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "node_memory_MemTotal_bytes", "legendFormat": "total {{instance}}", "refId": "B"}],
"title": "Node Memory (used vs total)",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "Bps"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 15},
"id": 6,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum by (instance) (rate(node_network_receive_bytes_total{device!~\"lo|veth.*|docker.*|br-.*|cni.*|flannel.*\"}[5m]))", "legendFormat": "RX {{instance}}", "refId": "A"},
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum by (instance) (rate(node_network_transmit_bytes_total{device!~\"lo|veth.*|docker.*|br-.*|cni.*|flannel.*\"}[5m]))", "legendFormat": "TX {{instance}}", "refId": "B"}
],
"title": "Node Network Traffic",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "percent",
"min": 0,
"max": 100
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 24},
"id": 7,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "(1 - (node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay|squashfs\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay|squashfs\"})) * 100", "legendFormat": "{{instance}} {{mountpoint}}", "refId": "A"}],
"title": "Node Disk Usage %",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 24},
"id": 8,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "node_load1", "legendFormat": "1m {{instance}}", "refId": "A"},
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "node_load5", "legendFormat": "5m {{instance}}", "refId": "B"},
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "node_load15", "legendFormat": "15m {{instance}}", "refId": "C"}
],
"title": "Node Load Average",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 24, "x": 0, "y": 33},
"id": 9,
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {"show": false, "reducer": ["sum"], "countRows": false, "fields": ""}
},
"pluginVersion": "10.2.3",
"targets": [
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "kubelet_running_pods", "format": "table", "instant": true, "refId": "A"},
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "kubelet_running_containers", "format": "table", "instant": true, "refId": "B"},
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", "format": "table", "instant": true, "refId": "C"},
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100", "format": "table", "instant": true, "refId": "D"}
],
"title": "Node Summary (live)",
"type": "table",
"transformations": [
{"id": "merge", "options": {}},
{"id": "groupBy", "options": {"fields": {"Value": {"aggregations": ["lastNotNull"], "operation": "aggregate"}, "Value #B": {"aggregations": ["lastNotNull"], "operation": "aggregate"}, "Value #C": {"aggregations": ["lastNotNull"], "operation": "aggregate"}, "Value #D": {"aggregations": ["lastNotNull"], "operation": "aggregate"}, "instance": {"aggregations": [], "operation": "groupby"}}}},
{"id": "organize", "options": {"excludeByName": {"Time": true}, "renameByName": {"Value": "Pods", "Value #B": "Containers", "Value #C": "CPU %", "Value #D": "Memory %"}}}
]
}
],
"refresh": "30s",
"schemaVersion": 38,
"style": "dark",
"tags": ["k3s", "nodes"],
"templating": {"list": []},
"time": {"from": "now-6h", "to": "now"},
"timepicker": {},
"timezone": "",
"title": "Nodes",
"uid": "k3s-nodes",
"version": 2,
"weekStart": ""
}

View File

@@ -0,0 +1,312 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-dashboard-pods
namespace: monitoring
labels:
app: grafana
grafana_dashboard: "1"
data:
pods.json: |
{
"annotations": {"list": []},
"editable": true,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "normal"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "core"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 24, "x": 0, "y": 0},
"id": 1,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(rate(container_cpu_usage_seconds_total{container!=\"\",container!=\"POD\",namespace=~\"$namespace\"}[5m])) by (pod)", "legendFormat": "{{pod}}", "refId": "A"}],
"title": "CPU Usage per Pod",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "normal"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 24, "x": 0, "y": 9},
"id": 2,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(container_memory_working_set_bytes{container!=\"\",container!=\"POD\",namespace=~\"$namespace\"}) by (pod)", "legendFormat": "{{pod}}", "refId": "A"}],
"title": "Memory Usage per Pod",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "Bps"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 18},
"id": 3,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(rate(container_network_receive_bytes_total{namespace=~\"$namespace\"}[5m])) by (pod)", "legendFormat": "RX {{pod}}", "refId": "A"}
],
"title": "Network RX per Pod",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "Bps"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 18},
"id": 4,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(rate(container_network_transmit_bytes_total{namespace=~\"$namespace\"}[5m])) by (pod)", "legendFormat": "TX {{pod}}", "refId": "A"}
],
"title": "Network TX per Pod",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 27},
"id": 5,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(container_fs_usage_bytes{namespace=~\"$namespace\"}) by (pod)", "legendFormat": "{{pod}}", "refId": "A"}],
"title": "Filesystem Usage per Pod",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "percent"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 27},
"id": 6,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum(rate(container_cpu_cfs_throttled_seconds_total{namespace=~\"$namespace\"}[5m])) by (pod) / sum(rate(container_cpu_cfs_periods_total{namespace=~\"$namespace\"}[5m])) by (pod) * 100", "legendFormat": "{{pod}}", "refId": "A"}],
"title": "CPU Throttling % per Pod",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 1}, {"color": "red", "value": 5}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 10, "w": 24, "x": 0, "y": 36},
"id": 7,
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {"show": false, "reducer": ["sum"], "countRows": false, "fields": ""}
},
"pluginVersion": "10.2.3",
"targets": [
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum by (namespace, pod) (container_memory_working_set_bytes{container!=\"\",container!=\"POD\",namespace=~\"$namespace\"})", "format": "table", "instant": true, "refId": "A"},
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum by (namespace, pod) (rate(container_cpu_usage_seconds_total{container!=\"\",container!=\"POD\",namespace=~\"$namespace\"}[5m]))", "format": "table", "instant": true, "refId": "B"},
{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum by (namespace, pod) (rate(container_network_receive_bytes_total{namespace=~\"$namespace\"}[5m]))", "format": "table", "instant": true, "refId": "C"}
],
"title": "Pod Resource Summary (live)",
"type": "table",
"transformations": [
{"id": "merge", "options": {}},
{"id": "groupBy", "options": {"fields": {"Value": {"aggregations": ["lastNotNull"], "operation": "aggregate"}, "Value #B": {"aggregations": ["lastNotNull"], "operation": "aggregate"}, "Value #C": {"aggregations": ["lastNotNull"], "operation": "aggregate"}, "namespace": {"aggregations": [], "operation": "groupby"}, "pod": {"aggregations": [], "operation": "groupby"}}}},
{"id": "organize", "options": {"excludeByName": {"Time": true}, "renameByName": {"Value": "Memory (bytes)", "Value #B": "CPU (cores)", "Value #C": "Network RX (Bps)"}}}
]
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 46},
"id": 8,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum by (namespace) (kube_pod_status_phase{phase=~\"Running|Pending|Failed\",namespace=~\"$namespace\"})", "legendFormat": "{{namespace}} {{phase}}", "refId": "A"}],
"title": "Pod Status by Namespace (KSM)",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 46},
"id": 9,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "sum by (namespace) (increase(kube_pod_container_status_restarts_total{namespace=~\"$namespace\"}[1h]))", "legendFormat": "{{namespace}}", "refId": "A"}],
"title": "Container Restarts (last 1h)",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true,
"stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 24, "x": 0, "y": 55},
"id": 10,
"options": {
"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "kube_persistentvolumeclaim_resource_requests_storage_bytes{namespace=~\"$namespace\"}", "legendFormat": "{{namespace}}/{{persistentvolumeclaim}}", "refId": "A"}],
"title": "PVC Storage Requests by Claim (KSM)",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 38,
"style": "dark",
"tags": ["k3s", "pods"],
"templating": {
"list": [
{
"allValue": ".*",
"current": {"selected": true, "text": "All", "value": "$__all"},
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"definition": "label_values(container_cpu_usage_seconds_total, namespace)",
"hide": 0,
"includeAll": true,
"multi": true,
"name": "namespace",
"options": [],
"query": "label_values(container_cpu_usage_seconds_total, namespace)",
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
}
]
},
"time": {"from": "now-6h", "to": "now"},
"timepicker": {},
"timezone": "",
"title": "Pods & Services",
"uid": "k3s-pods",
"version": 2,
"weekStart": ""
}

View File

@@ -0,0 +1,218 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-dashboard-prometheus
namespace: monitoring
labels:
app: grafana
grafana_dashboard: "1"
data:
prometheus.json: |
{
"annotations": {"list": []},
"editable": true,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]},
"mappings": [{"options": {"0": {"text": "DOWN", "color": "red"}, "1": {"text": "UP", "color": "green"}}, "type": "value"}]
},
"overrides": []
},
"gridPos": {"h": 5, "w": 6, "x": 0, "y": 0},
"id": 1,
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "horizontal", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "value"},
"pluginVersion": "10.2.3",
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "up{job=\"prometheus\"}", "refId": "A"}],
"title": "Prometheus Status",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {"h": 5, "w": 6, "x": 6, "y": 0},
"id": 2,
"options": {"colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"pluginVersion": "10.2.3",
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "process_resident_memory_bytes{job=\"prometheus\"}", "refId": "A"}],
"title": "Prometheus RSS Memory",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 5, "w": 6, "x": 12, "y": 0},
"id": 3,
"options": {"colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"pluginVersion": "10.2.3",
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "prometheus_tsdb_head_series", "refId": "A"}],
"title": "Active Series",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 5, "w": 6, "x": 18, "y": 0},
"id": 4,
"options": {"colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"pluginVersion": "10.2.3",
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "count(up)", "refId": "A"}],
"title": "Scrape Targets",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 5},
"id": 10,
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "process_resident_memory_bytes{job=\"prometheus\"}", "legendFormat": "RSS", "refId": "A"}, {"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "prometheus_tsdb_head_memory_postings_total", "legendFormat": "postings", "refId": "B"}],
"title": "Prometheus Memory",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "core"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 5},
"id": 11,
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "rate(process_cpu_seconds_total{job=\"prometheus\"}[5m])", "legendFormat": "prometheus", "refId": "A"}],
"title": "Prometheus CPU",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 14},
"id": 12,
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "rate(prometheus_tsdb_head_samples_appended_total[5m])", "legendFormat": "samples/s", "refId": "A"}],
"title": "Ingestion Rate (samples/s)",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "s"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 14},
"id": 13,
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "scrape_duration_seconds", "legendFormat": "{{job}} {{instance}}", "refId": "A"}],
"title": "Scrape Duration by Job",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 23},
"id": 14,
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "prometheus_tsdb_head_series", "legendFormat": "head series", "refId": "A"}, {"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "prometheus_tsdb_head_chunks", "legendFormat": "head chunks", "refId": "B"}],
"title": "TSDB Head Series & Chunks",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "Prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "showPoints": "never", "spanNulls": true, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "s"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 23},
"id": 15,
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"datasource": {"type": "prometheus", "uid": "Prometheus"}, "expr": "rate(prometheus_http_request_duration_seconds_sum[5m]) / rate(prometheus_http_request_duration_seconds_count[5m])", "legendFormat": "avg HTTP req", "refId": "A"}],
"title": "Prometheus HTTP Request Duration",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 38,
"style": "dark",
"tags": ["k3s", "prometheus"],
"templating": {"list": []},
"time": {"from": "now-6h", "to": "now"},
"timepicker": {},
"timezone": "",
"title": "Prometheus Self-Monitoring",
"uid": "k3s-prometheus",
"version": 1,
"weekStart": ""
}

View File

@@ -0,0 +1,20 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-dashboard-provider
namespace: monitoring
labels:
app: grafana
data:
provider.yaml: |
apiVersion: 1
providers:
- name: 'k3s-dashboards'
orgId: 1
folder: 'K3s Cluster'
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards

View File

@@ -13,3 +13,8 @@ data:
url: http://prometheus:9090
isDefault: true
editable: true
- name: Loki
type: loki
access: proxy
url: http://loki:3100
editable: true

View File

@@ -33,6 +33,18 @@ spec:
mountPath: /var/lib/grafana
- name: grafana-datasources
mountPath: /etc/grafana/provisioning/datasources
- name: grafana-dashboard-provider
mountPath: /etc/grafana/provisioning/dashboards
- name: dashboards-cluster-overview
mountPath: /var/lib/grafana/dashboards/cluster-overview
- name: dashboards-pods
mountPath: /var/lib/grafana/dashboards/pods
- name: dashboards-nodes
mountPath: /var/lib/grafana/dashboards/nodes
- name: dashboards-control-plane
mountPath: /var/lib/grafana/dashboards/control-plane
- name: dashboards-prometheus
mountPath: /var/lib/grafana/dashboards/prometheus
resources:
requests:
memory: "256Mi"
@@ -47,3 +59,21 @@ spec:
- name: grafana-datasources
configMap:
name: grafana-datasources
- name: grafana-dashboard-provider
configMap:
name: grafana-dashboard-provider
- name: dashboards-cluster-overview
configMap:
name: grafana-dashboard-cluster-overview
- name: dashboards-pods
configMap:
name: grafana-dashboard-pods
- name: dashboards-nodes
configMap:
name: grafana-dashboard-nodes
- name: dashboards-control-plane
configMap:
name: grafana-dashboard-control-plane
- name: dashboards-prometheus
configMap:
name: grafana-dashboard-prometheus

35
monitoring/ingress.yaml Normal file
View File

@@ -0,0 +1,35 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: monitoring
namespace: monitoring
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
tls:
- hosts:
- grafana.rogi.casa
- prometheus.rogi.casa
secretName: monitoring-tls
rules:
- host: grafana.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: grafana
port:
number: 80
- host: prometheus.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: prometheus
port:
number: 9090

View File

@@ -0,0 +1,118 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: kube-state-metrics
namespace: monitoring
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kube-state-metrics
rules:
- apiGroups: [""]
resources:
- configmaps
- secrets
- nodes
- pods
- services
- resourcequotas
- replicationcontrollers
- limitranges
- persistentvolumeclaims
- persistentvolumes
- namespaces
- endpoints
verbs: ["list", "watch"]
- apiGroups: ["apps"]
resources: ["statefulsets", "daemonsets", "deployments", "replicasets"]
verbs: ["list", "watch"]
- apiGroups: ["batch"]
resources: ["cronjobs", "jobs"]
verbs: ["list", "watch"]
- apiGroups: ["autoscaling"]
resources: ["horizontalpodautoscalers"]
verbs: ["list", "watch"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["list", "watch"]
- apiGroups: ["storage.k8s.io"]
resources: ["storageclasses", "volumeattachments"]
verbs: ["list", "watch"]
- apiGroups: ["certificates.k8s.io"]
resources: ["certificatesigningrequests"]
verbs: ["list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: kube-state-metrics
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: kube-state-metrics
subjects:
- kind: ServiceAccount
name: kube-state-metrics
namespace: monitoring
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kube-state-metrics
namespace: monitoring
labels:
app: kube-state-metrics
spec:
replicas: 1
selector:
matchLabels:
app: kube-state-metrics
template:
metadata:
labels:
app: kube-state-metrics
spec:
serviceAccountName: kube-state-metrics
containers:
- name: kube-state-metrics
image: registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.10.1
ports:
- containerPort: 8080
name: http-metrics
- containerPort: 8081
name: telemetry
readinessProbe:
httpGet:
path: /
port: 8081
initialDelaySeconds: 5
timeoutSeconds: 5
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: kube-state-metrics
namespace: monitoring
labels:
app: kube-state-metrics
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
spec:
selector:
app: kube-state-metrics
ports:
- name: http-metrics
port: 8080
targetPort: http-metrics
- name: telemetry
port: 8081
targetPort: telemetry

153
monitoring/loki.yaml Normal file
View File

@@ -0,0 +1,153 @@
# Loki — log aggregation (single-binary mode, local filesystem storage).
#
# Stores compressed, indexed pod logs shipped by Promtail. Queried by the
# platform-engineer Hermes agent via the HTTP API (LogQL) and by Grafana.
#
# Storage: 20 GiB local PVC, 1-week retention enforced by the compactor.
# Service: loki.monitoring:3100 (ClusterIP, no auth — homelab).
---
apiVersion: v1
kind: ConfigMap
metadata:
name: loki-config
namespace: monitoring
data:
loki.yaml: |
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9096
common:
path_prefix: /loki
replication_factor: 1
ring:
instance_addr: 127.0.0.1
kvstore:
store: inmemory
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
storage_config:
filesystem:
directory: /loki/chunks
tsdb_shipper:
active_index_directory: /loki/tsdb-index
cache_location: /loki/tsdb-cache
limits_config:
retention_period: 168h # 1 week
max_query_series: 10000
reject_old_samples: true
reject_old_samples_max_age: 168h
allow_structured_metadata: false # tsdb v13 compat
compactor:
working_directory: /loki/compactor
compaction_interval: 10m
retention_enabled: true
retention_delete_delay: 2h
retention_delete_worker_count: 50
delete_request_store: filesystem
analytics:
reporting_enabled: false
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: loki-data
namespace: monitoring
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: loki
namespace: monitoring
labels:
app: loki
spec:
replicas: 1
strategy:
type: Recreate # single-writer storage
selector:
matchLabels:
app: loki
template:
metadata:
labels:
app: loki
spec:
nodeSelector:
kubernetes.io/arch: amd64 # Loki image; runs on the NUC
containers:
- name: loki
image: grafana/loki:3.4.4
args:
- -config.file=/etc/loki/loki.yaml
ports:
- name: http
containerPort: 3100
volumeMounts:
- name: config
mountPath: /etc/loki
readOnly: true
- name: data
mountPath: /loki
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "1000m"
readinessProbe:
httpGet:
path: /ready
port: 3100
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 5
livenessProbe:
httpGet:
path: /ready
port: 3100
initialDelaySeconds: 60
periodSeconds: 30
failureThreshold: 5
volumes:
- name: config
configMap:
name: loki-config
- name: data
persistentVolumeClaim:
claimName: loki-data
---
apiVersion: v1
kind: Service
metadata:
name: loki
namespace: monitoring
spec:
type: ClusterIP
selector:
app: loki
ports:
- name: http
port: 3100
targetPort: 3100

View File

@@ -0,0 +1,112 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: node-exporter
namespace: monitoring
---
apiVersion: v1
kind: Service
metadata:
name: node-exporter
namespace: monitoring
labels:
app: node-exporter
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9100"
spec:
selector:
app: node-exporter
ports:
- name: metrics
port: 9100
targetPort: 9100
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: node-exporter
rules:
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: node-exporter
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: node-exporter
subjects:
- kind: ServiceAccount
name: node-exporter
namespace: monitoring
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: node-exporter
namespace: monitoring
labels:
app: node-exporter
spec:
selector:
matchLabels:
app: node-exporter
template:
metadata:
labels:
app: node-exporter
spec:
serviceAccountName: node-exporter
hostPID: true
hostNetwork: true
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
- key: node-role.kubernetes.io/master
operator: Exists
effect: NoSchedule
containers:
- name: node-exporter
image: prom/node-exporter:v1.7.0
args:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/host/root
- --collector.filesystem.mount-points-exclude=^/(dev|proc|sys|var/lib/docker/.+)($|/)
- --collector.filesystem.fs-types-exclude=^(autofs|binfmt_misc|cgroup|configfs|debugfs|devpts|devtmpfs|fusectl|hugetlbfs|mqueue|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|sysfs|tracefs)$
ports:
- containerPort: 9100
hostPort: 9100
name: metrics
volumeMounts:
- name: proc
mountPath: /host/proc
readOnly: true
- name: sys
mountPath: /host/sys
readOnly: true
- name: root
mountPath: /host/root
readOnly: true
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "200m"
volumes:
- name: proc
hostPath:
path: /proc
- name: sys
hostPath:
path: /sys
- name: root
hostPath:
path: /

View File

@@ -14,6 +14,8 @@ spec:
metadata:
labels:
app: prometheus
annotations:
kubectl.kubernetes.io/restartedAt: "2026-07-26T12:00:00Z"
spec:
# Prevent scheduling on Raspberry Pi due to resource requirements (512Mi-1Gi memory, 500m-1000m CPU)
nodeSelector:
@@ -39,10 +41,10 @@ spec:
mountPath: /prometheus
resources:
requests:
memory: "512Mi"
memory: "1Gi"
cpu: "500m"
limits:
memory: "1Gi"
memory: "4Gi"
cpu: "1000m"
volumes:
- name: prometheus-config

138
monitoring/promtail.yaml Normal file
View File

@@ -0,0 +1,138 @@
# Promtail — DaemonSet that tails pod logs on every node and ships them to Loki.
#
# Runs on ALL nodes (amd64 + arm). Multi-arch image. Reads /var/log/pods/*,
# attaches k8s labels (namespace, pod, container), ships to loki.monitoring:3100.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: promtail
namespace: monitoring
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: promtail
rules:
- apiGroups: [""]
resources:
- nodes
- nodes/proxy
- services
- endpoints
- pods
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: promtail
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: promtail
subjects:
- kind: ServiceAccount
name: promtail
namespace: monitoring
---
apiVersion: v1
kind: ConfigMap
metadata:
name: promtail-config
namespace: monitoring
data:
promtail.yaml: |
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki.monitoring:3100/loki/api/v1/push
scrape_configs:
# Tail all container logs via /var/log/containers/*.log (symlinks to
# /var/log/pods/<ns>_<pod>_<uid>/<container>/<N>.log). Extract namespace,
# pod, container labels from the filename via pipeline_stages regex.
- job_name: kubernetes-containers
static_configs:
- targets:
- localhost
labels:
job: kube-containers
__path__: /var/log/containers/*.log
pipeline_stages:
- cri: {}
# k3s filename: <pod>_<namespace>_<container>-<hash>.log
- regex:
expression: '/var/log/containers/(?P<pod>[^_]+)_(?P<namespace>[^_]+)_(?P<container>[^-]+)-.*\.log'
source: filename
- labels:
pod:
namespace:
container:
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: promtail
namespace: monitoring
labels:
app: promtail
spec:
selector:
matchLabels:
app: promtail
template:
metadata:
labels:
app: promtail
spec:
serviceAccountName: promtail
tolerations:
- operator: Exists # run on every node including tainted Pis
containers:
- name: promtail
image: grafana/promtail:3.4.4
args:
- -config.file=/etc/promtail/promtail.yaml
- -config.expand-env=true
env:
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
volumeMounts:
- name: config
mountPath: /etc/promtail
readOnly: true
- name: positions
mountPath: /tmp
- name: pods-logs
mountPath: /var/log/pods
readOnly: true
- name: containers-logs
mountPath: /var/log/containers
readOnly: true
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "250m"
volumes:
- name: config
configMap:
name: promtail-config
- name: positions
emptyDir: {}
- name: pods-logs
hostPath:
path: /var/log/pods
- name: containers-logs
hostPath:
path: /var/log/containers

View File

@@ -2,7 +2,7 @@ apiVersion: v1
kind: ConfigMap
metadata:
name: myorg-assistant-config
namespace: default
namespace: myorg-assistant
data:
# LiteLLM Configuration
LITELLM_ENDPOINT: "http://litellm-service.default.svc.cluster.local:4000"

View File

@@ -2,7 +2,7 @@ apiVersion: batch/v1
kind: CronJob
metadata:
name: myorg-deadline-checker
namespace: default
namespace: myorg-assistant
labels:
app: myorg-assistant
job: deadline-checker
@@ -22,13 +22,16 @@ spec:
job: deadline-checker
spec:
restartPolicy: OnFailure
imagePullSecrets:
- name: gitea-registry
containers:
- name: deadline-checker
image: myorg-assistant:latest
imagePullPolicy: IfNotPresent
image: git.rogi.casa/roger/myorg-assistant/myorg-assistant:fcf79bf
imagePullPolicy: Always
command:
- python
- run_job.py
- -c
- "from src.scheduler.jobs import run_job; import sys; run_job(sys.argv[1])"
- deadline-checker
env:
- name: MYORG_REPO_PATH
@@ -51,6 +54,16 @@ spec:
secretKeyRef:
name: myorg-assistant-secret
key: LITELLM_API_KEY
- name: WEB_SECRET_KEY
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: WEB_SECRET_KEY
- name: GIT_TOKEN
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: GIT_TOKEN
volumeMounts:
- name: myorg-data
mountPath: /data/myorg

View File

@@ -2,7 +2,7 @@ apiVersion: batch/v1
kind: CronJob
metadata:
name: myorg-evening-summary
namespace: default
namespace: myorg-assistant
labels:
app: myorg-assistant
job: evening-summary
@@ -22,13 +22,16 @@ spec:
job: evening-summary
spec:
restartPolicy: OnFailure
imagePullSecrets:
- name: gitea-registry
containers:
- name: evening-summary
image: myorg-assistant:latest
imagePullPolicy: IfNotPresent
image: git.rogi.casa/roger/myorg-assistant/myorg-assistant:fcf79bf
imagePullPolicy: Always
command:
- python
- run_job.py
- -c
- "from src.scheduler.jobs import run_job; import sys; run_job(sys.argv[1])"
- evening-summary
env:
- name: MYORG_REPO_PATH
@@ -51,6 +54,16 @@ spec:
secretKeyRef:
name: myorg-assistant-secret
key: LITELLM_API_KEY
- name: WEB_SECRET_KEY
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: WEB_SECRET_KEY
- name: GIT_TOKEN
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: GIT_TOKEN
volumeMounts:
- name: myorg-data
mountPath: /data/myorg

View File

@@ -2,7 +2,7 @@ apiVersion: batch/v1
kind: CronJob
metadata:
name: myorg-git-sync
namespace: default
namespace: myorg-assistant
labels:
app: myorg-assistant
job: git-sync
@@ -22,13 +22,53 @@ spec:
job: git-sync
spec:
restartPolicy: OnFailure
imagePullSecrets:
- name: gitea-registry
initContainers:
- name: git-clone
image: alpine/git:latest
command:
- sh
- -c
- |
if [ ! -d /data/myorg/.git ]; then
echo "Cloning repository..."
git clone ${GIT_REPO_URL} /data/myorg
cd /data/myorg
git config user.name "${GIT_USERNAME}"
git config user.email "${GIT_USERNAME}@rogi.casa"
git config credential.helper store
echo "https://${GIT_USERNAME}:${GIT_TOKEN}@git.rogi.casa" > ~/.git-credentials
else
echo "Repository already exists, skipping clone."
fi
env:
- name: GIT_REPO_URL
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: GIT_REPO_URL
- name: GIT_USERNAME
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: GIT_USERNAME
- name: GIT_TOKEN
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: GIT_TOKEN
volumeMounts:
- name: myorg-data
mountPath: /data/myorg
containers:
- name: git-sync
image: myorg-assistant:latest
imagePullPolicy: IfNotPresent
image: git.rogi.casa/roger/myorg-assistant/myorg-assistant:fcf79bf
imagePullPolicy: Always
command:
- python
- run_job.py
- -c
- "from src.scheduler.jobs import run_job; import sys; run_job(sys.argv[1])"
- git-sync
env:
- name: MYORG_REPO_PATH
@@ -66,6 +106,11 @@ spec:
secretKeyRef:
name: myorg-assistant-secret
key: LITELLM_API_KEY
- name: WEB_SECRET_KEY
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: WEB_SECRET_KEY
volumeMounts:
- name: myorg-data
mountPath: /data/myorg

View File

@@ -2,7 +2,7 @@ apiVersion: batch/v1
kind: CronJob
metadata:
name: myorg-morning-briefing
namespace: default
namespace: myorg-assistant
labels:
app: myorg-assistant
job: morning-briefing
@@ -22,13 +22,16 @@ spec:
job: morning-briefing
spec:
restartPolicy: OnFailure
imagePullSecrets:
- name: gitea-registry
containers:
- name: morning-briefing
image: myorg-assistant:latest
imagePullPolicy: IfNotPresent
image: git.rogi.casa/roger/myorg-assistant/myorg-assistant:fcf79bf
imagePullPolicy: Always
command:
- python
- run_job.py
- -c
- "from src.scheduler.jobs import run_job; import sys; run_job(sys.argv[1])"
- morning-briefing
env:
# From ConfigMap
@@ -58,6 +61,16 @@ spec:
secretKeyRef:
name: myorg-assistant-secret
key: LITELLM_API_KEY
- name: WEB_SECRET_KEY
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: WEB_SECRET_KEY
- name: GIT_TOKEN
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: GIT_TOKEN
volumeMounts:
- name: myorg-data
mountPath: /data/myorg

View File

@@ -2,7 +2,7 @@ apiVersion: batch/v1
kind: CronJob
metadata:
name: myorg-waiting-followup
namespace: default
namespace: myorg-assistant
labels:
app: myorg-assistant
job: waiting-followup
@@ -22,13 +22,16 @@ spec:
job: waiting-followup
spec:
restartPolicy: OnFailure
imagePullSecrets:
- name: gitea-registry
containers:
- name: waiting-followup
image: myorg-assistant:latest
imagePullPolicy: IfNotPresent
image: git.rogi.casa/roger/myorg-assistant/myorg-assistant:fcf79bf
imagePullPolicy: Always
command:
- python
- run_job.py
- -c
- "from src.scheduler.jobs import run_job; import sys; run_job(sys.argv[1])"
- waiting-followup
env:
- name: MYORG_REPO_PATH
@@ -51,6 +54,16 @@ spec:
secretKeyRef:
name: myorg-assistant-secret
key: LITELLM_API_KEY
- name: WEB_SECRET_KEY
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: WEB_SECRET_KEY
- name: GIT_TOKEN
valueFrom:
secretKeyRef:
name: myorg-assistant-secret
key: GIT_TOKEN
volumeMounts:
- name: myorg-data
mountPath: /data/myorg

View File

@@ -1,8 +1,13 @@
apiVersion: v1
kind: Namespace
metadata:
name: myorg-assistant
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: myorg-assistant
namespace: default
namespace: myorg-assistant
labels:
app: myorg-assistant
spec:
@@ -29,7 +34,7 @@ spec:
git config user.name "${GIT_USERNAME}"
git config user.email "${GIT_USERNAME}@rogi.casa"
git config credential.helper store
echo "https://${GIT_USERNAME}:${GIT_TOKEN}@gitea.rogi.casa" > ~/.git-credentials
echo "https://${GIT_USERNAME}:${GIT_TOKEN}@git.rogi.casa" > ~/.git-credentials
else
echo "Repository already exists, pulling latest changes..."
cd /data/myorg
@@ -58,7 +63,7 @@ spec:
- name: gitea-registry
containers:
- name: myorg-assistant
image: gitea.rogi.casa/roger/myorg-assistant/myorg-assistant:5215cd9
image: git.rogi.casa/roger/myorg-assistant/myorg-assistant:fcf79bf
imagePullPolicy: Always
command: ["./start.sh"]
ports:

View File

@@ -2,7 +2,7 @@ apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myorg-ingress
namespace: default
namespace: myorg-assistant
annotations:
# Use Traefik as the ingress controller (default in k3s)
kubernetes.io/ingress.class: "traefik"
@@ -10,14 +10,12 @@ metadata:
traefik.ingress.kubernetes.io/redirect-entry-point: https
# Optional: enable compression
traefik.ingress.kubernetes.io/compress: "true"
cert-manager.io/issuer: prod-issuer
cert-manager.io/issuer-kind: OriginIssuer
cert-manager.io/issuer-group: cert-manager.k8s.cloudflare.com
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- "*.rogi.casa"
secretName: rogicasa-tls
- myorg.rogi.casa
secretName: myorg-tls
rules:
- host: myorg.rogi.casa
http:

View File

@@ -2,7 +2,7 @@ apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: myorg-assistant-pvc
namespace: default
namespace: myorg-assistant
spec:
accessModes:
- ReadWriteOnce

View File

@@ -2,7 +2,7 @@ apiVersion: v1
kind: Service
metadata:
name: myorg-assistant-service
namespace: default
namespace: myorg-assistant
labels:
app: myorg-assistant
spec:

View File

@@ -10,14 +10,12 @@ metadata:
traefik.ingress.kubernetes.io/redirect-entry-point: https
# Optional: enable compression
traefik.ingress.kubernetes.io/compress: "true"
cert-manager.io/issuer: prod-issuer
cert-manager.io/issuer-kind: OriginIssuer
cert-manager.io/issuer-group: cert-manager.k8s.cloudflare.com
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- "*.rogi.casa"
secretName: rogicasa-tls
- n8n.rogi.casa
secretName: n8n-tls
rules:
- host: n8n.rogi.casa
http:

View File

@@ -53,15 +53,17 @@ spec:
value: http
- name: N8N_PORT
value: "5678"
- name: NODE_OPTIONS
value: "--max-old-space-size=768"
image: n8nio/n8n
name: n8n
ports:
- containerPort: 5678
resources:
requests:
memory: "250Mi"
memory: "512Mi"
limits:
memory: "500Mi"
memory: "1Gi"
volumeMounts:
- mountPath: /home/node/.n8n
name: n8n-claim0

View File

@@ -1,45 +0,0 @@
#apiVersion: networking.k8s.io/v1
#kind: Ingress
#metadata:
# name: nas-redirect
# annotations:
# nginx.ingress.kubernetes.io/permanent-redirect: "http://10.88.88.238:5000"
#spec:
# rules:
# - host: nas.rogi.casa
# http:
# paths:
# - path: /
# pathType: Prefix
# backend:
# service:
# name: dummy-service
# port:
# number: 80
apiVersion: v1
kind: Service
metadata:
name: external-ip
spec:
ports:
- name: app
port: 80
protocol: TCP
targetPort: 5000
clusterIP: None
type: ClusterIP
---
apiVersion: v1
kind: Endpoints
metadata:
name: external-ip
subsets:
- addresses:
- ip: 10.88.88.238
ports:
- name: app
port: 5000
protocol: TCP

77
nas/ingress.yaml Normal file
View File

@@ -0,0 +1,77 @@
apiVersion: v1
kind: Namespace
metadata:
name: nas-proxy
---
# Standalone cert-manager Certificate for nas.rogi.casa (not owned by an Ingress,
# since cert-manager's ingress-shim would otherwise create one owned by the
# Ingress below and tie its lifecycle to it; keeping it standalone is cleaner).
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: nas-tls
namespace: nas-proxy
spec:
secretName: nas-tls
dnsNames:
- nas.rogi.casa
issuerRef:
group: cert-manager.io
kind: ClusterIssuer
name: letsencrypt-prod
usages:
- digital signature
- key encipherment
---
# Selector-less Service + manual Endpoints pointing at the NAS.
# (Endpoints is no longer excluded in argocd-cm, so ArgoCD manages it.)
apiVersion: v1
kind: Service
metadata:
name: synology-nas
namespace: nas-proxy
spec:
type: ClusterIP
clusterIP: None
ports:
- port: 5001
targetPort: 5001
protocol: TCP
---
apiVersion: v1
kind: Endpoints
metadata:
name: synology-nas
namespace: nas-proxy
subsets:
- addresses:
- ip: 10.88.30.10
ports:
- port: 5001
protocol: TCP
---
# Traefik IngressRoute (CRD provider) where scheme: https is a first-class
# field. The standard kubernetes Ingress `service.serversscheme` annotation is
# ignored for selector-less/Endpoints-backed services in Traefik v3, which
# caused Traefik to dial the NAS with plain HTTP -> 400 from DSM's nginx.
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: nas
namespace: nas-proxy
spec:
entryPoints:
- websecure
routes:
- match: Host(`nas.rogi.casa`)
kind: Rule
services:
- kind: Service
name: synology-nas
namespace: nas-proxy
port: 5001
scheme: https
serversTransport: skip-verify
passHostHeader: true
tls:
secretName: nas-tls

8
nas/transport.yaml Normal file
View File

@@ -0,0 +1,8 @@
# nas-transport.yaml
apiVersion: traefik.io/v1alpha1
kind: ServersTransport
metadata:
name: skip-verify
namespace: nas-proxy
spec:
insecureSkipVerify: true

24
openwebui/ingress.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: openwebui
namespace: openwebui
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
tls:
- hosts:
- ai.rogi.casa
secretName: openwebui-tls
rules:
- host: ai.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: open-webui-service
port:
number: 80

View File

@@ -1,7 +1,13 @@
apiVersion: v1
kind: Namespace
metadata:
name: openwebui
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: openwebui-pvc
namespace: openwebui
spec:
accessModes:
- ReadWriteOnce
@@ -15,6 +21,7 @@ metadata:
labels:
app: open-webui
name: open-webui
namespace: openwebui
spec:
replicas: 1
selector:
@@ -84,6 +91,7 @@ metadata:
labels:
app: open-webui
name: open-webui-service
namespace: openwebui
spec:
ports:
- name: http

View File

@@ -10,14 +10,12 @@ metadata:
traefik.ingress.kubernetes.io/redirect-entry-point: https
# Optional: enable compression
traefik.ingress.kubernetes.io/compress: "true"
cert-manager.io/issuer: prod-issuer
cert-manager.io/issuer-kind: OriginIssuer
cert-manager.io/issuer-group: cert-manager.k8s.cloudflare.com
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- "*.rogi.casa"
secretName: rogicasa-tls
- phoenix.rogi.casa
secretName: phoenix-tls
rules:
- host: phoenix.rogi.casa
http:

View File

@@ -1,18 +0,0 @@
# Optional: ServiceMonitor for Prometheus Operator
# Only apply this if you have Prometheus Operator installed
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: phoenix-metrics
namespace: phoenix
labels:
app: phoenix
spec:
selector:
matchLabels:
app: phoenix
endpoints:
- port: metrics
path: /metrics
interval: 30s
scrapeTimeout: 10s

24
pihole/ingress.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: pihole
namespace: pihole
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
tls:
- hosts:
- pihole.rogi.casa
secretName: pihole-tls
rules:
- host: pihole.rogi.casa
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: pihole-web
port:
number: 80

View File

@@ -1,8 +1,78 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: pihole
---
apiVersion: v1
kind: ConfigMap
metadata:
name: unbound-config
namespace: pihole
data:
unbound.conf: |
server:
# Listen on all interfaces so the kubelet's liveness/readiness probes
# (which connect to the pod IP, not 127.0.0.1) can reach unbound.
# No Service exposes port 5335, so it stays cluster-internal; pihole
# still forwards to 127.0.0.1#5335 which works because 0.0.0.0 covers
# loopback.
interface: 0.0.0.0
port: 5335
# IPv4 only for simplicity
do-ip4: yes
do-udp: yes
do-tcp: yes
do-ip6: no
prefer-ip6: no
# Recursive resolver: do not use any forwarders, start from the root servers
root-hints: "/opt/unbound/etc/unbound/root.hints"
# DNSSEC / hardening
harden-glue: yes
harden-dnssec-stripped: yes
harden-referral-path: yes
# Performance / privacy
prefetch: yes
prefetch-key: yes
qname-minimisation: yes
aggressive-nsec: yes
edns-buffer-size: 1232
num-threads: 1
so-rcvbuf: 1m
# RFC1918 / link-local addresses should never come back from the internet
private-address: 10.0.0.0/8
private-address: 172.16.0.0/12
private-address: 192.168.0.0/16
private-address: 169.254.0.0/16
private-address: fd00::/8
private-address: fe80::/10
# Hide identity / version
hide-identity: yes
hide-version: yes
---
# Pi-hole config that points dnsmasq at the local unbound sidecar.
# Mounted into /etc/dnsmasq.d so it is read on (re)start.
apiVersion: v1
kind: ConfigMap
metadata:
name: pihole-dnsmasq-config
namespace: pihole
data:
99-unbound.conf: |
# Use the recursive unbound sidecar as the only upstream DNS
server=127.0.0.1#5335
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pihole-pvc
namespace: default
namespace: pihole
spec:
accessModes:
- ReadWriteOnce
@@ -10,11 +80,23 @@ spec:
requests:
storage: 1Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: unbound-pvc
namespace: pihole
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 100Mi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: pihole
namespace: default
namespace: pihole
labels:
app: pihole
spec:
@@ -27,11 +109,31 @@ spec:
labels:
app: pihole
spec:
# The pod itself still needs DNS to (e.g.) download blocklists on gravity
# updates. Use the cluster DNS / a public resolver for that - it is NOT
# used to answer client queries, which go through the unbound sidecar.
dnsPolicy: "None"
dnsConfig:
nameservers:
- 8.8.8.8
- 8.8.4.4
initContainers:
- name: unbound-root-hints
image: curlimages/curl:8.12.1
command:
- /bin/sh
- -c
- |
set -e
if [ ! -s /opt/unbound/etc/unbound/root.hints ]; then
echo "Downloading root hints..."
curl -fsSL https://www.internic.net/domain/named.root -o /opt/unbound/etc/unbound/root.hints
else
echo "Root hints already present, skipping download."
fi
volumeMounts:
- name: unbound-data
mountPath: /opt/unbound/etc/unbound
containers:
- name: pihole
image: pihole/pihole:latest
@@ -66,8 +168,9 @@ spec:
volumeMounts:
- name: pihole-data
mountPath: /etc/pihole
#- name: pihole-dnsmasq
#mountPath: /etc/dnsmasq.d
- name: pihole-dnsmasq-config
mountPath: /etc/dnsmasq.d/99-unbound.conf
subPath: 99-unbound.conf
resources:
requests:
memory: "256Mi"
@@ -81,18 +184,57 @@ spec:
- NET_ADMIN
- SYS_TIME
- SYS_NICE
- name: unbound
image: mvance/unbound:latest
ports:
- containerPort: 5335
name: unbound-dns-tcp
protocol: TCP
- containerPort: 5335
name: unbound-dns-udp
protocol: UDP
volumeMounts:
- name: unbound-config
mountPath: /opt/unbound/etc/unbound/unbound.conf
subPath: unbound.conf
- name: unbound-data
mountPath: /opt/unbound/etc/unbound
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "500m"
livenessProbe:
tcpSocket:
port: 5335
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
tcpSocket:
port: 5335
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: pihole-data
persistentVolumeClaim:
claimName: pihole-pvc
#- name: pihole-dnsmasq
#emptyDir: {}
- name: unbound-data
persistentVolumeClaim:
claimName: unbound-pvc
- name: unbound-config
configMap:
name: unbound-config
- name: pihole-dnsmasq-config
configMap:
name: pihole-dnsmasq-config
---
apiVersion: v1
kind: Service
metadata:
name: pihole-web
namespace: default
namespace: pihole
labels:
app: pihole
spec:
@@ -109,7 +251,7 @@ apiVersion: v1
kind: Service
metadata:
name: pihole-dns
namespace: default
namespace: pihole
labels:
app: pihole
spec:

View File

@@ -0,0 +1,44 @@
server:
# Listen on all interfaces so the kubelet's liveness/readiness probes
# (which connect to the pod IP, not 127.0.0.1) can reach unbound.
# No Service exposes port 5335, so it stays cluster-internal; pihole
# still forwards to 127.0.0.1#5335 which works because 0.0.0.0 covers
# loopback.
interface: 0.0.0.0
port: 5335
# IPv4 only for simplicity
do-ip4: yes
do-udp: yes
do-tcp: yes
do-ip6: no
prefer-ip6: no
# Recursive resolver: do not use any forwarders, start from the root servers
root-hints: "/opt/unbound/etc/unbound/root.hints"
# DNSSEC / hardening
harden-glue: yes
harden-dnssec-stripped: yes
harden-referral-path: yes
# Performance / privacy
prefetch: yes
prefetch-key: yes
qname-minimisation: yes
aggressive-nsec: yes
edns-buffer-size: 1232
num-threads: 1
so-rcvbuf: 1m
# RFC1918 / link-local addresses should never come back from the internet
private-address: 10.0.0.0/8
private-address: 172.16.0.0/12
private-address: 192.168.0.0/16
private-address: 169.254.0.0/16
private-address: fd00::/8
private-address: fe80::/10
# Hide identity / version
hide-identity: yes
hide-version: yes

367
platform-engineer/README.md Normal file
View File

@@ -0,0 +1,367 @@
# Platform Engineer Agent — Deployment Plan
An autonomous **Hermes Agent** that runs inside the k3s cluster, watches its
health on a schedule, tries to fix simple problems, and notifies me (via
Discord) when something needs my attention or a fix failed.
Docs: https://hermes-agent.nousresearch.com/docs/user-guide/docker
---
## 1. Goal & operating model
- **One Hermes container** in a new namespace `platform-engineer`, scheduled on
the powerful amd64 node (`roger-nucbox-evo-x2`, 24 GiB RAM).
- Hermes runs in **gateway mode** under s6 supervision (`command: gateway run`),
so the built-in **cron scheduler** is active and survives restarts.
- The agent talks to the cluster with `kubectl` from *inside* the container
(terminal backend = `local`). We give the pod a **ServiceAccount + ClusterRole**
scoped to read-mostly + restart/scale/delete-pod permissions.
- LLM calls are routed through the in-cluster **LiteLLM** proxy
(`litellm.rogi.casa`) — no external API keys needed in the cluster.
- Notifications go to **Discord** (reuse the pattern from `myorg-assistant`).
- A set of **cron jobs** (Hermes-native, not Kubernetes CronJobs) make the agent
run periodic checks. Watchdog checks use `[SILENT]` so it only pings me when
something is wrong.
Why Hermes-native cron (not k8s CronJobs):
- Hermes cron ticks inside the gateway, runs in an isolated agent session,
supports `[SILENT]` suppression, `deliver="discord"`, `workdir`, and
`context_from` chaining — far less plumbing than spawning a fresh pod per run.
- Cron jobs live in `~/.hermes/cron/jobs.json` on the PVC, so they survive pod
restarts and can be edited live via `hermes cron edit` without redeploying.
---
## 2. Files to create (this directory)
```
platform-engineer/
├── namespace.yaml # namespace platform-engineer
├── rbac.yaml # ServiceAccount + ClusterRole (+binding)
├── configmap.yaml # hermes config.yaml + SOUL.md + cron seed script
├── secret.yaml # DISCORD bot token, LITELLM_API_KEY, kubeconfig-less SA token
├── pvc.yaml # persistent /opt/data (HERMES_HOME)
├── dockerfile # derived image: hermes-agent + kubectl + helm
├── deployment.yaml # Deployment, schedules on amd64, mounts kube SA token
├── ingress.yaml # platform-engineer.rogi.casa → dashboard (optional)
└── README.md # this file
```
Then add a line to `argocd/gen-apps.sh` `APPS=(...)`:
```
"platform-engineer|platform-engineer|platform-engineer|true|true"
```
and re-run `./argocd/gen-apps.sh` to generate `argocd/apps/platform-engineer.yaml`
so ArgoCD reconciles it like every other app in the repo.
---
## 3. RBAC — least privilege
ServiceAccount `platform-engineer` in ns `platform-engineer`, bound to a
**ClusterRole** scoped to *platform engineer* actions:
**Read (get/list/watch):** nodes, pods, services, deployments, statefulsets,
daemonsets, replicasets, jobs, cronjobs, events, configmaps, secrets, PVCs,
ingresses, namespaces.
**Act (patch/update on a allowlist):**
- `pods``delete` (force-restart a stuck pod), `patch` (`/evict`, annotations)
- `deployments`, `statefulsets`, `daemonsets`, `replicasets``patch` (restart
via `kubectl rollout restart` / scale), `update`
- `jobs`, `cronjobs``delete`, `patch`
- `pods/exec` (subresource) → `create` (only if we want the agent to `kubectl
exec` into pods for log-style debugging — optional; keep off initially)
- `events` → `get/list/watch` only
**No cluster-scoped writes** (no creating namespaces, no node taints, no RBAC
edits, no CRDs). The agent can *propose* those and tell me; it cannot do them
itself. All mutating calls are auditable via Kubernetes audit logs and
`kubectl auth can-i --as=system:serviceaccount:platform-engineer:platform-engineer`.
The pod uses the k3s in-cluster ServiceAccount token (`/var/run/secrets/...
/serviceaccount/token`) + the `KUBERNETES_SERVICE_HOST/PORT` env vars k3s already
injects — **no kubeconfig file, no long-lived token on disk**.
---
## 4. Image — thin derived Dockerfile
```dockerfile
FROM nousresearch/hermes-agent:latest
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl gnupg \
&& curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.35/deb/Release.key \
| gpg --dearmor -o /usr/share/keyrings/kubernetes-apt-keyring.gpg \
&& echo 'deb [signed-by=/usr/share/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.35/deb/ /' \
> /etc/apt/sources.list.d/kubernetes.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends kubectl \
&& curl -fsSL https://get.helm.sh/helm-v3.16.0-linux-amd64.tar.gz \
| tar -xz -C /usr/local/bin --strip-components=1 linux-amd64/helm \
&& rm -rf /var/lib/apt/lists/*
USER hermes
```
> Note: the cluster is mixed arch (arm64/amd64/arm). The agent pod is pinned to
> the amd64 node, so `linux-amd64` helm + `kubectl` packages are fine. If you
> later want it portable, switch to a multi-arch build with
> `TARGETARCH` and install matching helm arch.
Build & push to your Gitea registry (`git.rogi.casa/roger/...`) — same
`imagePullSecrets: gitea-registry` pattern as `gym-tracker`. Tag with the
hermes version + a short git sha.
---
## 5. Hermes configuration (mounted via ConfigMap → /opt/data/config.yaml)
```yaml
# config.yaml (seeded into the PVC on first boot)
model:
provider: openai-api
default: claude-4.5-haiku
base_url: "https://litellm.rogi.casa/v1"
api_mode: chat_completions
# Use a cheap, fast model for auxiliary tasks (titling, compression)
auxiliary:
compression:
provider: openai-api
model: gemini-3.6-flash
title_generation:
provider: openai-api
model: gemini-3.6-flash
terminal:
backend: local
cwd: /workspace # a working dir for any kubectl output / scratch
timeout: 180
home_mode: profile # isolate tool credentials under HERMES_HOME/home
# Unattended gateway → circuit-breaker on tool-call loops
tool_loop_guardrails:
hard_stop_enabled: true
hard_stop_after:
exact_failure: 5
idempotent_no_progress: 5
sessions:
auto_prune: true
retention_days: 90
cron:
wrap_response: false # cleaner Discord messages
memory:
memory_enabled: true
user_profile_enabled: true
```
`.env` (from Secret, mounted to `/opt/data/.env`):
```
OPENAI_API_KEY=<LITELLM_API_KEY value, i.e. sk-...>
OPENAI_BASE_URL=https://litellm.rogi.casa/v1
DISCORD_BOT_TOKEN=<new dedicated bot token>
DISCORD_HOME_CHANNEL=<your user/channel id for alerts>
# Dashboard auth (homelab, trusted LAN behind ingress)
HERMES_DASHBOARD_BASIC_AUTH_USERNAME=roger
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=<strong password>
```
> Why `OPENAI_API_KEY` + `OPENAI_BASE_URL`: the `openai-api` provider honours
> `OPENAI_BASE_URL`, so this is the simplest way to point Hermes at the
> in-cluster LiteLLM. `claude-4.5-haiku` / `gemini-3.6-flash` are the model names
> already exposed by your `litellm/litellm.yaml` ConfigMap.
`SOUL.md` (personality + guardrails) — see `configmap.yaml`. Key points:
- Identity: "Platform Engineer for the rogi.casa k3s cluster."
- Knows the cluster layout (3 nodes, ArgoCD GitOps, Traefik+cert-manager,
LiteLLM, services list).
- Operating rules: read-first; only act on the allowlisted verbs; never edit
RBAC / taints / namespaces / CRDs; when in doubt, notify instead of acting;
always cite the resource and the command used.
- How to reach me: `deliver="discord"`.
---
## 6. Deployment
- `replicas: 1` (Hermes data dir is single-writer — never scale >1).
- `nodeSelector: kubernetes.io/arch: amd64` + preferred `hardware: high-memory`
affinity → lands on the NUC.
- `resources`: requests 512Mi/250m, limits 2Gi/1 core (Hermes recommends
24 GiB; 1 GiB is fine without browser tools, which we keep off).
- Volume: PVC mounted at `/opt/data` (HERMES_HOME), RWX not needed (single pod).
- Ports: 8642 (gateway API, internal only) and 9119 (dashboard) → exposed via
Ingress `platform-engineer.rogi.casa` with TLS + basic-auth (already enforced by the
`HERMES_DASHBOARD_BASIC_AUTH_*` env vars).
- `imagePullSecrets: gitea-registry`.
- env from Secret; `HERMES_DASHBOARD=1`.
- Init: on first boot the s6 `01-hermes-setup` hook seeds config/SOUL/.env from
the ConfigMap if the volume is empty. We mount the ConfigMap as a readonly
projection at `/opt/seed/` and run a tiny initContainer to copy it into
`/opt/data` only when `/opt/data/config.yaml` doesn't exist (so ArgoCD
self-heal never fights the agent's live-edited config).
---
## 7. Cron jobs to seed (Hermes-native)
These are written by an init script (one-shot Job `hermes-cron-seed`) that runs
`hermes cron create ...` against the gateway on first install, and is idempotent
(it checks existing job names). All deliver to Discord. Examples:
| Name | Schedule | Prompt (abbreviated) |
|------|----------|------------------------|
| `cluster-health-check` | `every 15m` | Run `kubectl get nodes,pods -A --field-selector=status.phase!=Running,status.phase!=Succeeded` and `kubectl get events -A --field-selector type=Warning --since=20m`. If everything healthy, reply with only `[SILENT]`. Otherwise summarize failures and root-cause briefly. |
| `pod-restart-loop` | `every 10m` | Find pods in `CrashLoopBackOff`/`ImagePullBackOff` across all namespaces. For `CrashLoopBackOff`, fetch logs and if a clear transient cause (OOM, config parse, missing secret) is visible, attempt `kubectl rollout restart <deploy>`; otherwise notify me with the log excerpt. Reply `[SILENT]` if none found. |
| `pvc-pressure` | `every 30m` | `kubectl get pv` + node disk via `kubectl top nodes`. Alert if any PVC `Bound` to a near-full volume or node disk >85%. `[SILENT]` otherwise. |
| `argocd-sync-health` | `every 1h` | `kubectl get applications -n argocd -o wide` (or `argocd app sync --dry-run` if CLI present). Report any `OutOfSync`/`Degraded` app. `[SILENT]` if all `Synced`+`Healthy`. |
| `cert-expiry` | `every 1d at 09:00` | List cert-manager `Certificate` resources with expiry < 21 days. Notify only if any. `[SILENT]` otherwise. |
| `node-resource-drift` | `every 30m` | `kubectl top nodes`. Alert if any node CPU>90% or mem>90% sustained, or any node `NotReady`. `[SILENT]` otherwise. |
| `daily-cluster-report` | `0 8 * * *` | Summarize: node count/status, top 5 pods by CPU/mem, # pods not Running, # ArgoCD apps OutOfSync, cert warnings. Always deliver (no `[SILENT]`). |
Design rules baked into SOUL.md:
- **Read-only checks** run frequently (1030m) and stay silent unless wrong.
- **Mutating actions** are restricted to safe idempotent ones (rollout restart,
delete stuck pod so controller recreates). Anything riskier → notify me with
a proposed command and wait for me to run it (I can reply in Discord to the
continuable thread).
- Cron sessions are isolated and **cannot create new cron jobs** (Hermes
disables that inside cron runs) → no runaway loops.
---
## 8. Safety & guardrails
1. **RBAC is the real boundary.** Even if the agent goes rogue, the SA can't
touch other namespaces' secrets beyond read, can't change RBAC, can't taint
nodes, can't create namespaces.
2. **`tool_loop_guardrails.hard_stop_enabled: true`** — circuit-breaks a stuck
gateway (recommended in the Docker doc for unattended deployments).
3. **`skills.write_approval: false` but `memory.write_approval: true`** (so the
agent can build skills/memories but I review memory writes lazily — flip
this if it gets noisy).
4. **No `pods/exec` subresource** initially (keep the agent from shelling into
workloads). Enable later only if you want log-grep-style debugging.
5. **Dashboard behind ingress TLS + basic auth** (the June-2026 hardening makes
auth mandatory on non-loopback binds; we satisfy it with the bundled
basic-auth provider).
6. **Single replica / single-writer PVC** — the Docker doc is explicit that two
gateways on the same `/opt/data` corrupt session/memory stores. Use a
`podAntiAffinity` so an accidental scale-up doesn't co-run.
7. **ArgoCD interaction:** keep `syncPolicy.automated.prune+selfHeal` but
exclude the live-edited hermes state. Practically: Argo owns the *manifests*
(deployment, configmap, secret, pvc), while `/opt/data` (config.yaml,
cron/jobs.json, SOUL.md edits made via the dashboard) is runtime state on the
PVC and is *not* reconciled by Argo. The ConfigMap only *seeds* it on first
boot. Document this clearly in the README so future-you doesn't expect Argo
to reset the agent's personality.
---
## 9. Rollout plan
1. Build & push the derived image to `git.rogi.casa/roger/hermes-agent` (tag
`v1.35-<sha>`).
2. Create the namespace + RBAC + Secret + ConfigMap + PVC:
`kubectl apply -f platform-engineer/`.
3. Create the `platform-engineer` Discord bot, invite it, put its token + your
channel id in `secret.yaml` (base64).
4. Apply the Deployment; wait for the pod to go Running.
5. `kubectl exec` in and run the one-shot cron seed:
`hermes cron create ...` (or apply the `cron-seed` Job).
6. Trigger the first `cluster-health-check` manually: `hermes cron run cluster-health-check`.
7. Add the app to `argocd/gen-apps.sh`, regenerate, commit, push.
---
## 10. Decisions (locked in)
1. **Notifications:** dedicated `platform-engineer` Discord bot → its own token
in `secret.yaml` (`DISCORD_BOT_TOKEN`, `DISCORD_HOME_CHANNEL`).
2. **Dashboard:** public at `platform-engineer.rogi.casa` (Traefik TLS + cert-manager + the
bundled Hermes basic-auth provider). Reach the dashboard on port 9119; the
gateway API on 8642 is ClusterIP-only.
3. **Image:** derived image pushed to `git.rogi.casa/roger/hermes-agent`, pulled
via the existing `gitea-registry` imagePullSecret (must also exist in the
`platform-engineer` ns — see deploy steps).
4. **Model:** `qwen-3.6:27b` via the in-cluster Ollama box (`10.88.20.12:11434`),
exposed through LiteLLM as `qwen-3.6:27b`. Added to `litellm/litellm.yaml`.
Hermes reaches LiteLLM at `https://litellm.rogi.casa/v1` (never Ollama directly).
5. **pods/exec:** granted (`pods/exec` → `create` in the ClusterRole) so the
agent can `kubectl exec`/`kubectl logs` for debugging.
---
## 11. Deployment checklist (do in this order)
1. **Add the Ollama model to LiteLLM** (already done in `litellm/litellm.yaml`):
the `qwen-3.6:27b` entry points at `http://10.88.20.12:11434`. Make sure
`qwen3.6:27b` is actually pulled on that Ollama host
(`ollama pull qwen3.6:27b`). Apply: `kubectl apply -f litellm/` and restart
the LiteLLM pod so the new config takes effect.
2. **Create the `gitea-registry` secret in the new namespace** (ArgoCD won't
create it — it's not in the repo):
```
kubectl create namespace platform-engineer
kubectl create secret docker-registry gitea-registry \
--docker-server=git.rogi.casa \
--docker-username=<your-gitea-user> \
--docker-password=<gitea-access-token> \
--docker-email=<your-email> \
-n platform-engineer
```
3. **Build & push the image:** `./platform-engineer/build-and-push.sh`
(after `docker login git.rogi.casa`).
4. **Create the dedicated Discord bot**, invite it to your server, and put the
token + your channel id (base64) into `platform-engineer/secret.yaml`. Also
set the LiteLLM master key as `OPENAI_API_KEY` and a strong dashboard
password + a 32-byte session secret.
5. **Commit & push** the whole change. ArgoCD will create the namespace
resources, deploy the pod, and bring up the ingress at `platform-engineer.rogi.casa`.
6. **Seed the cron jobs:**
`kubectl apply -f platform-engineer/cron-seed.yaml` (one-shot Job) — it waits
for the hermes pod, then runs `hermes cron create ...` for each watchdog.
Re-run it any time you want to re-seed after a wipe.
7. **Smoke test:** trigger the first health check manually —
`kubectl exec -n platform-engineer deploy/hermes -- hermes cron run cluster-health-check` —
and confirm the message lands in Discord.
8. **ArgoCD:** the `Application` (`argocd/apps/platform-engineer.yaml`) is
already generated. After commit, Argo will reconcile it like every other app.
## 12. What ArgoCD owns vs. what is runtime state
- **ArgoCD owns** (in git): namespace, RBAC, Secret, ConfigMap (seed), PVC,
Deployment, Service, Ingress, cron-seed Job.
- **Runtime state (on the PVC, NOT reconciled):** `config.yaml`, `SOUL.md`,
`.env`, `cron/jobs.json`, `sessions/`, `memories/`, `skills/`. The ConfigMap
only *seeds* these on first boot; after that, edits you make via the
dashboard or `hermes cron edit` persist on the PVC and Argo will not revert
them. If you ever want a hard reset, delete the PVC and re-apply.
---
## Files in this directory
| File | Purpose |
|------|---------|
| `namespace.yaml` | namespace `platform-engineer` |
| `rbac.yaml` | ServiceAccount + ClusterRole (+binding), least-privilege |
| `configmap.yaml` | seed `config.yaml` + `SOUL.md` |
| `secret.yaml` | Discord token, LiteLLM key, dashboard auth (PLACEHOLDERS — fill in) |
| `pvc.yaml` | 5 Gi PVC for `/opt/data` |
| `dockerfile` | derived image: hermes-agent + kubectl + helm (linux/amd64) |
| `build-and-push.sh` | builds & pushes the image to the Gitea registry |
| `deployment.yaml` | Deployment (1 replica, Recreate, pinned to amd64 NUC) + Service |
| `ingress.yaml` | `platform-engineer.rogi.casa` → dashboard (TLS + basic auth) |
| `cron-seed.yaml` | one-shot Job that creates the Hermes cron schedule |
Also changed outside this directory:
- `litellm/litellm.yaml` — added `qwen-3.6:27b` model entry.
- `argocd/gen-apps.sh` + `argocd/apps/platform-engineer.yaml` — ArgoCD
Application for this folder.
```

View File

@@ -0,0 +1,194 @@
# Hermes configuration + SOUL.md + profile.d (seeded into the PVC on first boot).
---
apiVersion: v1
kind: ConfigMap
metadata:
name: hermes-seed
namespace: platform-engineer
data:
config.yaml: |
model:
provider: openai-api
default: qwen3.6-27b
base_url: "http://litellm-service.litellm:80/v1"
api_mode: chat_completions
auxiliary:
compression:
provider: openai-api
model: qwen3.6-35b-a3b # flash model for speed-sensitive auxiliary tasks
base_url: "http://litellm-service.litellm:80/v1"
title_generation:
provider: openai-api
model: qwen3.6-35b-a3b # flash model for speed-sensitive auxiliary tasks
base_url: "http://litellm-service.litellm:80/v1"
terminal:
backend: local
cwd: /workspace/k3s-cluster
timeout: 180
home_mode: profile
# The agent runs unattended (cron jobs). The terminal tool's security
# scanner flags curl+data patterns as 'pending_approval', which blocks
# cron jobs (no human to approve). `yolo: true` disables all approval
# prompts — safe here because the agent's blast radius is limited to git
# commits + read-only HTTP API queries (it has no k8s RBAC).
yolo: true
approvals:
mode: off
# Disable the Tirith pre-exec command scanner. It flags in-cluster plain
# HTTP URLs (http://prometheus.monitoring:9090 etc.) as 'insecure URL'
# false positives, which blocks every API query. Safe to disable because
# the agent has no k8s RBAC and yolo is already on.
security:
tirith_enabled: false
tirith_fail_open: true
tool_loop_guardrails:
hard_stop_enabled: true
hard_stop_after:
exact_failure: 5
idempotent_no_progress: 5
sessions:
auto_prune: true
retention_days: 90
cron:
wrap_response: false
discord:
allowed_channels: '1470909384162017444' # DISCORD_HOME_CHANNEL
free_response_channels: '1470909384162017444' # no @mention needed here
# Per-platform gateway auth. Paired with GATEWAY_ALLOW_ALL_USERS=true in
# the env (secret.yaml), this lets the bot reply to inbound DMs and
# group messages from anyone. Tighten later by switching to
# DISCORD_ALLOWED_USERS=<id> in the secret and dropping these two lines.
dm_policy: open
group_policy: open
memory:
memory_enabled: true
user_profile_enabled: true
write_approval: false
skills:
write_approval: false
SOUL.md: |
# Platform Engineer — rogi.casa k3s cluster
You are the autonomous Platform Engineer for the `rogi.casa` K3s cluster.
You run *inside* the cluster (namespace `platform-engineer`) and your job is
to keep it healthy, fix small problems before they grow, and notify your
owner (Roger) on Discord when something needs a human.
## The cluster you look after
- **Nodes:** `raspberrypi` (control-plane, arm64, 4 GiB), `rpi2` (arm,
~512 MiB), `roger-nucbox-evo-x2` (amd64, 24 GiB — you run here).
- **GitOps:** ArgoCD owns every app from the git repo (cloned at /workspace/k3s-cluster).
The repo is cloned at `/workspace/k3s-cluster`. Each app lives in its own
folder; manifests are reconciled with prune + selfHeal.
- **Ingress:** Traefik; TLS via cert-manager + `letsencrypt-prod`.
- **Your model provider:** LiteLLM at `http://litellm-service.litellm:80/v1`
(reached in-cluster; never Ollama directly).
- **Services:** glance, pihole, litellm, gitea, home-assistant, jellyfin,
n8n, openwebui, phoenix, vaultwarden, qbittorrent, minecraft, monitoring
(prometheus + grafana + loki), fava, myorg-assistant, gym-tracker.
## How you observe the cluster (NO kubectl — you have none)
You have NO k8s API access and NO kubectl. DO NOT try to run kubectl — it
is not installed and you have no RBAC. Use the HTTP APIs below with the
terminal tool. Use in-cluster service hostnames (name.namespace:port),
NOT public ingress URLs like loki.rogi.casa (they go through Cloudflare
which times out on long requests).
### 1. Prometheus (metrics)
Endpoint: http://prometheus.monitoring:9090/api/v1/query
Use the terminal tool to send an HTTP GET with a PromQL query parameter
named 'query'. Useful PromQL:
- Node not Ready: kube_node_status_condition{condition="Ready",status!="true"}
- Pod not Running: kube_pod_status_phase{phase!="Running"}
- Pod restarts: kube_pod_container_status_restarts_total
- PVC free percent: kubelet_volume_stats_available_bytes / kubelet_volume_stats_capacity_bytes
- Node mem free: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes
- Node disk used: 1 - (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"})
- Cert expiry (days): (certmanager_certificate_expiration_timestamp_seconds - time()) / 86400
- Top pods CPU: topk(5, rate(container_cpu_usage_seconds_total[5m]))
- Top pods mem: topk(5, container_memory_working_set_bytes)
### 2. Loki (pod logs)
Endpoint: http://loki.monitoring:3100/loki/api/v1/query_range
Use the terminal tool to send an HTTP GET with these query parameters:
'query' (a LogQL expression), 'start' and 'end' (Unix nanosecond
timestamps), and 'limit'. Useful LogQL:
- Errors in a namespace: {namespace="myorg-assistant"} |= "error"
- CrashLoop across cluster: {namespace=~".+"} |~ "(?i)backoff|crashloop"
- Pod logs: {namespace="<ns>",pod="<pod>"}
### 3. ArgoCD API (app status + sync triggers)
Endpoint: https://argocd-server.argocd:443 (internal service, use the -k
flag to skip TLS cert verification since it's a self-signed internal cert)
Auth: bearer token (read from the environment variable for the ArgoCD token). Send an
Authorization header with the token.
Endpoints: GET /api/v1/applications (list apps), POST /api/v1/applications/<app>/sync (trigger sync)
## Parsing JSON responses
The execute_code tool is BLOCKED in cron mode. To parse JSON from HTTP
responses, pipe the output through python3 or jq inside the terminal tool.
## How you remediate (git commit → ArgoCD sync)
You have NO k8s write access. Every fix is a git commit to the repo at
`/workspace/k3s-cluster` (which you push to Gitea using the token in your environment).
ArgoCD's selfHeal picks up the change; if you need it faster, trigger a sync
via the ArgoCD API.
Workflow:
cd /workspace/k3s-cluster
git pull
# ... edit the manifest(s) ...
git add -A && git commit -m "fix(<app>): <what changed>"
git push # uses the token embedded in the repo URL / environment
# optionally trigger ArgoCD sync via the API (see section 3 above).
## Operating rules
1. **Read first, act second.** Before changing anything, gather the evidence
via Prometheus + Loki + ArgoCD. Cite the exact resource (ns/name) and
the exact query/command in every report.
2. **GitOps is the ONLY write path.** Never try to use kubectl (you don't
have it). Every remediation is a git commit + push + optional ArgoCD sync
trigger. ArgoCD will reconcile; if it reverts you, your fix was wrong.
3. **Only safe, idempotent remediations.** Allowed: scaling a Deployment,
bumping the `restartedAt` annotation to trigger a rollout, fixing a
broken ConfigMap/Secret value, pinning an image tag. Never touch RBAC,
ArgoCD's own Application manifests, nodes, or CRDs.
4. **When in doubt, notify, don't act.** If a fix is risky, unusual, or would
touch state outside the repo, post the proposed change to Discord and
wait for Roger to reply.
5. **Be quiet when healthy.** Watchdog cron jobs reply with exactly `[SILENT]`
when there is nothing to report. Failed jobs always deliver.
6. **No runaway loops.** You cannot create new cron jobs from inside a cron
run (Hermes disables that). Do not try.
7. **Talk like an engineer.** Short, concrete, with resource names and
queries. No filler. When you fixed something, say what you did in one line.
8. **Language: Catalan by default.** Address Roger in Catalan in *all*
interactions — daily reports, watchdog alerts, Discord DMs, and any
other message you produce. This is the default regardless of the
language the cron prompt or instruction is written in (those are just
instructions to you, not the output language). Only switch away from
Catalan if Roger writes to you in another language or explicitly asks
you to. Never default to English or Italian.
9. **Respect GitOps.** If an app is `OutOfSync`/`Degraded`, check whether a
commit is stuck. Don't hand-edit resources — fix the source repo.
## How you reach Roger
Notifications go to Discord (your home channel). Cron jobs deliver there by
default (`deliver="discord"`). Keep messages under ~1800 chars.

Some files were not shown because too many files have changed in this diff Show More