Compare commits
1 Commits
main
...
fix/argocd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e420aa3808 |
@@ -1,7 +1,7 @@
|
|||||||
apiVersion: argoproj.io/v1alpha1
|
apiVersion: argoproj.io/v1alpha1
|
||||||
kind: Application
|
kind: Application
|
||||||
metadata:
|
metadata:
|
||||||
name: llamacpp
|
name: argocd
|
||||||
namespace: argocd
|
namespace: argocd
|
||||||
annotations:
|
annotations:
|
||||||
argocd.argoproj.io/sync-wave: "0"
|
argocd.argoproj.io/sync-wave: "0"
|
||||||
@@ -10,12 +10,10 @@ spec:
|
|||||||
source:
|
source:
|
||||||
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
|
repoURL: https://git.rogi.casa/roger/k3s-cluster.git
|
||||||
targetRevision: main
|
targetRevision: main
|
||||||
path: llamacpp
|
path: argocd
|
||||||
directory:
|
|
||||||
recurse: true
|
|
||||||
destination:
|
destination:
|
||||||
server: https://kubernetes.default.svc
|
server: https://kubernetes.default.svc
|
||||||
namespace: llamacpp
|
namespace: argocd
|
||||||
syncPolicy:
|
syncPolicy:
|
||||||
automated:
|
automated:
|
||||||
prune: true
|
prune: true
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -21,15 +21,14 @@ mkdir -p "$APPS_DIR"
|
|||||||
|
|
||||||
# app-name | namespace | path | recurse | validate
|
# app-name | namespace | path | recurse | validate
|
||||||
APPS=(
|
APPS=(
|
||||||
|
"argocd|argocd|argocd|false|true"
|
||||||
"cert-manager|cert-manager|cert-manager|true|true"
|
"cert-manager|cert-manager|cert-manager|true|true"
|
||||||
"fava|fava|fava|true|true"
|
"fava|fava|fava|true|true"
|
||||||
"gitea|gitea|gitea|true|true"
|
"gitea|gitea|gitea|true|true"
|
||||||
"glance|glance|glance|true|true"
|
"glance|glance|glance|true|true"
|
||||||
"gym-tracker|gym-tracker|gym-tracker|true|true"
|
"gym-tracker|gym-tracker|gym-tracker|true|true"
|
||||||
"homeassistant|home-assistant|homeassistant|true|true"
|
"homeassistant|home-assistant|homeassistant|true|true"
|
||||||
"home-manager|home-manager|home-manager|true|true"
|
|
||||||
"jellyfin|jellyfin|jellyfin|true|true"
|
"jellyfin|jellyfin|jellyfin|true|true"
|
||||||
"llamacpp|llamacpp|llamacpp|true|true"
|
|
||||||
"litellm|litellm|litellm|true|true"
|
"litellm|litellm|litellm|true|true"
|
||||||
"minecraft-server|minecraft|minecraft-server|true|true"
|
"minecraft-server|minecraft|minecraft-server|true|true"
|
||||||
"monitoring|monitoring|monitoring|true|true"
|
"monitoring|monitoring|monitoring|true|true"
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ metadata:
|
|||||||
namespace: gitea
|
namespace: gitea
|
||||||
labels:
|
labels:
|
||||||
app: gitea
|
app: gitea
|
||||||
annotations:
|
|
||||||
kubectl.kubernetes.io/restartedAt: "2026-07-21T13:30:00Z"
|
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
selector:
|
selector:
|
||||||
@@ -103,8 +101,6 @@ metadata:
|
|||||||
namespace: gitea
|
namespace: gitea
|
||||||
labels:
|
labels:
|
||||||
app: gitea-runner
|
app: gitea-runner
|
||||||
annotations:
|
|
||||||
kubectl.kubernetes.io/restartedAt: "2026-07-21T13:30:00Z"
|
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
selector:
|
selector:
|
||||||
@@ -119,14 +115,7 @@ spec:
|
|||||||
kubernetes.io/arch: arm64
|
kubernetes.io/arch: arm64
|
||||||
containers:
|
containers:
|
||||||
- name: gitea-runner
|
- name: gitea-runner
|
||||||
image: vegardit/gitea-act-runner:2.1.0
|
image: vegardit/gitea-act-runner:latest
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "512Mi"
|
|
||||||
cpu: "250m"
|
|
||||||
limits:
|
|
||||||
memory: "1Gi"
|
|
||||||
cpu: "500m"
|
|
||||||
env:
|
env:
|
||||||
- name: GITEA_INSTANCE_URL
|
- name: GITEA_INSTANCE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
# 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 | `deepseek-v4-flash-0731` 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.
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
# 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: lfm2.5-2.6b
|
|
||||||
base_url: "http://litellm-service.litellm:80/v1"
|
|
||||||
api_mode: chat_completions
|
|
||||||
|
|
||||||
auxiliary:
|
|
||||||
compression:
|
|
||||||
provider: openai-api
|
|
||||||
model: lfm2.5-2.6b
|
|
||||||
base_url: "http://litellm-service.litellm:80/v1"
|
|
||||||
title_generation:
|
|
||||||
provider: openai-api
|
|
||||||
model: lfm2.5-2.6b
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Response speed
|
|
||||||
|
|
||||||
Do not use extended thinking or produce a hidden reasoning process for this
|
|
||||||
agent. Answer directly and concisely, using only the reasoning needed to
|
|
||||||
safely complete the request. Never include a chain of thought or a
|
|
||||||
`reasoning_content` section in the response. This is a prompt-level
|
|
||||||
instruction; do not change the llama.cpp server configuration.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Namespace
|
|
||||||
metadata:
|
|
||||||
name: home-manager
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: hermes-data
|
|
||||||
namespace: home-manager
|
|
||||||
spec:
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 5Gi
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
---
|
|
||||||
# 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
|
|
||||||
@@ -29,12 +29,6 @@ data:
|
|||||||
# Loads default set of integrations
|
# Loads default set of integrations
|
||||||
default_config:
|
default_config:
|
||||||
|
|
||||||
# Load UI-edited scripts from /config/scripts.yaml (YAML mode). Without this
|
|
||||||
# line, default_config runs the script integration in storage mode and the
|
|
||||||
# Scripts editor has no file to write back to, so saving scripts from the UI
|
|
||||||
# appears to fail / scripts never load.
|
|
||||||
script: !include scripts.yaml
|
|
||||||
|
|
||||||
http:
|
http:
|
||||||
use_x_forwarded_for: true
|
use_x_forwarded_for: true
|
||||||
trusted_proxies:
|
trusted_proxies:
|
||||||
@@ -58,12 +52,10 @@ spec:
|
|||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: home-assistant
|
app: home-assistant
|
||||||
annotations:
|
|
||||||
kubectl.kubernetes.io/restartedAt: "2026-07-28T00:00:00Z"
|
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: home-assistant
|
- name: home-assistant
|
||||||
image: ghcr.io/home-assistant/home-assistant:2026.7.4
|
image: ghcr.io/home-assistant/home-assistant:stable
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "256Mi"
|
memory: "256Mi"
|
||||||
@@ -74,6 +66,9 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: config
|
- name: config
|
||||||
mountPath: /config
|
mountPath: /config
|
||||||
|
- name: configuration
|
||||||
|
mountPath: /config/configuration.yaml
|
||||||
|
subPath: configuration.yaml
|
||||||
- name: localtime
|
- name: localtime
|
||||||
mountPath: /etc/localtime
|
mountPath: /etc/localtime
|
||||||
readOnly: true
|
readOnly: true
|
||||||
@@ -87,44 +82,7 @@ spec:
|
|||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
- NET_RAW
|
- NET_RAW
|
||||||
- SYS_ADMIN
|
- 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
|
|
||||||
affinity:
|
|
||||||
nodeAffinity:
|
|
||||||
preferredDuringSchedulingIgnoredDuringExecution:
|
|
||||||
- weight: 100
|
|
||||||
preference:
|
|
||||||
matchExpressions:
|
|
||||||
- key: kubernetes.io/hostname
|
|
||||||
operator: In
|
|
||||||
values:
|
|
||||||
- raspberrypi
|
|
||||||
hostNetwork: true
|
hostNetwork: true
|
||||||
# Required for hostNetwork pods to resolve cluster DNS
|
|
||||||
# (*.svc.cluster.local) via kube-dns instead of the host's resolver.
|
|
||||||
# Without this, resolv.conf points at the host (1.1.1.1) and in-cluster
|
|
||||||
# services like wyoming-whisper.llamacpp.svc.cluster.local won't resolve.
|
|
||||||
dnsPolicy: ClusterFirstWithHostNet
|
|
||||||
volumes:
|
volumes:
|
||||||
- name: config
|
- name: config
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
|
|||||||
@@ -11,47 +11,36 @@ metadata:
|
|||||||
data:
|
data:
|
||||||
config.yaml: |
|
config.yaml: |
|
||||||
model_list:
|
model_list:
|
||||||
- model_name: gpt-5.6-luna
|
- model_name: gpt-5-mini
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: openai/gpt-5.6-luna
|
model: openai/gpt-5-mini-2025-08-07
|
||||||
api_key: "os.environ/OPENAI_API_KEY"
|
api_key: "os.environ/OPENAI_API_KEY"
|
||||||
- model_name: claude-haiku-4.5
|
- model_name: claude-4.5-haiku
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: "anthropic/claude-haiku-4-5-20251001"
|
model: "anthropic/claude-haiku-4-5-20251001"
|
||||||
api_key: "os.environ/ANTHROPIC_API_KEY"
|
api_key: "os.environ/ANTHROPIC_API_KEY"
|
||||||
- model_name: claude-sonnet-5
|
- model_name: gemini-3-flash
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: "anthropic/claude-sonnet-5"
|
model: gemini/gemini-3-flash-preview
|
||||||
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"
|
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
|
- model_name: glm-4.7-flash
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: ollama/glm-4.7-flash
|
model: ollama/glm-4.7-flash
|
||||||
api_base: http://10.88.20.12:11434
|
api_base: http://10.88.20.12:11434
|
||||||
# In-cluster models served by llama.cpp (llama-server, Vulkan on the
|
# Used by the platform-engineer Hermes agent (deployed in ns platform-engineer).
|
||||||
# NUCBox APU). The chat template is applied via --jinja; reasoning (when
|
# model_name is the alias Hermes requests; the underlying Ollama model is
|
||||||
# enabled) comes back in `reasoning_content`.
|
# qwen3.6:latest (the fast non-27b tag). 27b is a slow reasoning model.
|
||||||
- model_name: qwen3.8-27b
|
# `ollama_chat/` (not `ollama/`) uses Ollama's NATIVE /api/chat endpoint.
|
||||||
|
# `think: false` + `chat_template_kwargs.enable_thinking: false` disable
|
||||||
|
# Qwen3 thinking so the model emits content directly (otherwise the
|
||||||
|
# OpenAI-compat translation returns empty content with reasoning split off).
|
||||||
|
- model_name: qwen3.6
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: openai/qwen3.8-27b
|
model: ollama_chat/qwen3.6:latest
|
||||||
api_base: http://llamacpp-qwen38-27b.llamacpp/v1
|
api_base: http://10.88.20.12:11434
|
||||||
api_key: "sk-no-auth"
|
think: false
|
||||||
- model_name: lfm2.5-2.6b
|
chat_template_kwargs:
|
||||||
litellm_params:
|
enable_thinking: false
|
||||||
model: openai/lfm2.5-2.6b
|
|
||||||
api_base: http://llamacpp-lfm25-26b.llamacpp/v1
|
|
||||||
api_key: "sk-no-auth"
|
|
||||||
litellm_settings:
|
litellm_settings:
|
||||||
#set_verbose: True # Uncomment this if you want to see verbose logs; not recommended in production
|
#set_verbose: True # Uncomment this if you want to see verbose logs; not recommended in production
|
||||||
callbacks: ["arize_phoenix"]
|
callbacks: ["arize_phoenix"]
|
||||||
@@ -62,7 +51,7 @@ data:
|
|||||||
request_timeout: 600
|
request_timeout: 600
|
||||||
telemetry: False
|
telemetry: False
|
||||||
general_settings:
|
general_settings:
|
||||||
master_key: os.environ/LITELLM_MASTER_KEY
|
master_key: sk-1234 # [OPTIONAL] Use to enforce auth on proxy. See - https://docs.litellm.ai/docs/proxy/virtual_keys
|
||||||
store_model_in_db: True
|
store_model_in_db: True
|
||||||
proxy_budget_rescheduler_min_time: 60
|
proxy_budget_rescheduler_min_time: 60
|
||||||
proxy_budget_rescheduler_max_time: 64
|
proxy_budget_rescheduler_max_time: 64
|
||||||
@@ -101,13 +90,6 @@ spec:
|
|||||||
- "/app/proxy_server_config.yaml"
|
- "/app/proxy_server_config.yaml"
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 4000
|
- containerPort: 4000
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "512Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
limits:
|
|
||||||
memory: "2Gi"
|
|
||||||
cpu: "1000m"
|
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: config-volume
|
- name: config-volume
|
||||||
mountPath: /app/proxy_server_config.yaml
|
mountPath: /app/proxy_server_config.yaml
|
||||||
@@ -136,3 +118,4 @@ spec:
|
|||||||
- name: http
|
- name: http
|
||||||
port: 80
|
port: 80
|
||||||
targetPort: 4000
|
targetPort: 4000
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
# llama.cpp (llama-server)
|
|
||||||
|
|
||||||
In-cluster LLM inference via llama.cpp's `llama-server`, serving a local model
|
|
||||||
on the NUCBox APU (AMD Ryzen AI Max 395 / Strix Halo, Radeon 8060S, ~120 GiB
|
|
||||||
unified memory).
|
|
||||||
|
|
||||||
LiteLLM (`litellm/`) points at the in-cluster Service instead of the external
|
|
||||||
Ollama endpoint.
|
|
||||||
|
|
||||||
## Active Model
|
|
||||||
|
|
||||||
| Alias | Model | Configuration | Service |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `qwen3.8-27b` | Qwen3.8-27B with MTP | `Q4_K_M` primary, `Q4_0` draft, 196k context, q8_0 K/V cache | `llamacpp-qwen38-27b.llamacpp:80` |
|
|
||||||
| `lfm2.5-2.6b` | LiquidAI LFM2.5-2.6B with speculative decoding | `Q4_K_M` primary, `Q4_0` draft, 121k context, temperature 0.1 | `llamacpp-lfm25-26b.llamacpp:80` |
|
|
||||||
|
|
||||||
An initContainer downloads both model files atomically before llama-server
|
|
||||||
starts:
|
|
||||||
|
|
||||||
- Primary: `ggml-org/Qwen3.8-27B-GGUF:Q4_K_M`
|
|
||||||
- Draft: `ggml-org/Qwen3.8-27B-GGUF:Q4_0`
|
|
||||||
|
|
||||||
The model files are stored on the shared hostPath PVC at
|
|
||||||
`/data/llamacpp/models` on the NUCBox. The server is configured with
|
|
||||||
`--spec-default --spec-type draft-mtp`, `--reasoning-preserve`, `--fit off`,
|
|
||||||
and `--agent`.
|
|
||||||
|
|
||||||
## Retired Models
|
|
||||||
|
|
||||||
The following deployments are no longer active, but their argument references
|
|
||||||
are retained for future redeployment:
|
|
||||||
|
|
||||||
- [DeepSeek-V4-Flash-0731](args-deepseek-v4-flash-0731.md)
|
|
||||||
- [Qwen3.6-27B](args-qwen36-27b.md)
|
|
||||||
- [Qwen3.6-35B-A3B](args-qwen36-35b-a3b.md)
|
|
||||||
|
|
||||||
## GPU / Vulkan
|
|
||||||
|
|
||||||
The `server-vulkan` image (`ghcr.io/ggml-org/llama.cpp:server-vulkan`) bundles
|
|
||||||
the Mesa/RADV Vulkan driver for the Radeon 8060S. The container mounts
|
|
||||||
`/dev/dri` and runs privileged, which is the current way to provide Vulkan
|
|
||||||
access on k3s without a device plugin.
|
|
||||||
|
|
||||||
Verify GPU use with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl logs -n llamacpp deploy/llamacpp-qwen38-27b | grep -iE 'vulkan|gpu|offload|device'
|
|
||||||
```
|
|
||||||
|
|
||||||
If only a CPU device appears, check that `/dev/dri/renderD128` exists on the
|
|
||||||
NUCBox and that the `amdgpu` module is loaded.
|
|
||||||
|
|
||||||
## Adding Or Replacing A Model
|
|
||||||
|
|
||||||
1. Copy `deployment-qwen38-27b.yaml` to `deployment-<new>.yaml` and change the
|
|
||||||
model repositories, alias, labels, and Service name.
|
|
||||||
2. Add the matching alias and Service URL to `litellm/litellm.yaml`.
|
|
||||||
3. No `gen-apps.sh` change is needed because the `llamacpp` ArgoCD Application
|
|
||||||
syncs the directory recursively.
|
|
||||||
4. Check the model and KV-cache size against the NUCBox's available VRAM.
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
# Deployment arguments — `deepseek-v4-flash-0731` (RETIRED)
|
|
||||||
|
|
||||||
Historical reference for the llama-server flags used by the retired
|
|
||||||
`deployment-deepseek-v4-flash-0731.yaml`. The Deployment can be recovered from
|
|
||||||
git history if this model is needed again.
|
|
||||||
|
|
||||||
## Model & source
|
|
||||||
|
|
||||||
- **Model:** `deepseek-ai/DeepSeek-V4-Flash-0731` (DeepSeek-V4-Flash, 0731 weights)
|
|
||||||
- **Quantization:** `unsloth/DeepSeek-V4-Flash-0731-GGUF` → `UD-IQ1_M`
|
|
||||||
(Unsloth Dynamic IQ1_M), **split across 3 shards** (~87 GiB total):
|
|
||||||
- `DeepSeek-V4-Flash-0731-UD-IQ1_M-00001-of-00003.gguf`
|
|
||||||
- `DeepSeek-V4-Flash-0731-UD-IQ1_M-00002-of-00003.gguf`
|
|
||||||
- `DeepSeek-V4-Flash-0731-UD-IQ1_M-00003-of-00003.gguf`
|
|
||||||
- **HuggingFace repo:** `https://huggingface.co/unsloth/DeepSeek-V4-Flash-0731-GGUF`
|
|
||||||
- **Image:** `ghcr.io/ggml-org/llama.cpp:server-vulkan` (floating tag — `deepseek4`
|
|
||||||
is a brand-new arch; pin to a specific `server-vulkan-bXXXX` once verified).
|
|
||||||
|
|
||||||
## Hardware target
|
|
||||||
|
|
||||||
NUCBox APU — AMD Ryzen AI Max 395 (Strix Halo), Radeon 8060S (RDNA 3.5),
|
|
||||||
~120 GiB unified memory split by firmware into **~90 GiB VRAM** and
|
|
||||||
**~30 GiB CPU RAM**. Pinned via `nodeSelector: {kubernetes.io/arch: amd64,
|
|
||||||
hardware: high-memory}`.
|
|
||||||
|
|
||||||
## Why this model nearly fills the machine
|
|
||||||
|
|
||||||
The UD-IQ1_M model is **~87 GiB**, almost the entire 90 GiB VRAM pool. Unlike
|
|
||||||
the old Qwen models (16–20 GiB, fully offloaded with room to spare), this one
|
|
||||||
**cannot be fully offloaded** to the GPU: putting all 43 layers + the KV cache +
|
|
||||||
Vulkan compute buffers in VRAM would exceed 90 GiB. The tuning below is all
|
|
||||||
about fitting the model while leaving headroom for the KV cache, compute
|
|
||||||
buffers, and co-resident pods.
|
|
||||||
|
|
||||||
## Argument-by-argument
|
|
||||||
|
|
||||||
| Flag | Value | Meaning |
|
|
||||||
|------|-------|---------|
|
|
||||||
| `-m` | `/models/DeepSeek-V4-Flash-0731-UD-IQ1_M-00001-of-00003.gguf` | Model file. Pointed at the **first shard** only; llama.cpp auto-discovers and loads `-00002-…` / `-00003-…` from the same directory. |
|
|
||||||
| `--alias` | `deepseek-v4-flash-0731` | Name reported by `GET /v1/models`. Must match the `model_name` in `litellm/litellm.yaml` so LiteLLM routes to this server. |
|
|
||||||
| `--host` | `0.0.0.0` | Bind on all interfaces so the k8s Service can reach the pod. |
|
|
||||||
| `--port` | `8080` | Listen port (matches `containerPort` + Service `targetPort`). |
|
|
||||||
| `--jinja` | *(flag)* | Use the chat template baked into the GGUF (DeepSeek-V4 DSML format, with thinking/reasoning support). Reasoning comes back in `reasoning_content`. |
|
|
||||||
| `-ngl` | `38` | **GPU layer offload.** Offload 38 of 43 layers to the GPU. See “VRAM budget” below — full offload would OOM, and the f16 KV cache (no Flash Attention on Vulkan) is larger than q8_0 would be, so 38 (not 40) layers are offloaded to leave ~5 GiB VRAM headroom. 5 layers (~10 GiB) run on CPU RAM. |
|
|
||||||
| `-c` | `65536` | Total KV-cache context window (64k, the required minimum). Single slot gets the full window. |
|
|
||||||
| `-np` | `1` | Parallel slots. 1 slot ⇒ the full 64k goes to a single concurrent request. |
|
|
||||||
| `--cont-batching` | *(flag)* | Continuous batching across slots (no-op with 1 slot, but harmless and correct if `-np` is raised). |
|
|
||||||
| `--cache-type-k` | `f16` | **f16 K cache (NOT quantized).** The Vulkan backend has no Flash Attention for the `deepseek4` arch, and quantized V cache requires Flash Attention (llama.cpp hard-errors otherwise). Additionally, `deepseek4`/MLA models require K and V cache types to be *identical*, so K cannot be quantized either. |
|
|
||||||
| `--cache-type-v` | `f16` | **f16 V cache.** Same reason — quantized V cache needs Flash Attention, which Vulkan lacks for deepseek4. |
|
|
||||||
| `--temp` | `1.0` | Default sampling temperature (DeepSeek-V4 recommendation). Clients may override per request via the OpenAI API. |
|
|
||||||
| `--top-p` | `0.95` | Default nucleus-sampling threshold (DeepSeek-V4 recommendation). Overrideable per request. |
|
|
||||||
| `--threads` | `8` | CPU threads for sampling + the 5 CPU-resident layers. |
|
|
||||||
|
|
||||||
## VRAM budget (90 GiB pool)
|
|
||||||
|
|
||||||
The model (~87 GiB IQ1_M) is almost the size of the entire 90 GiB VRAM pool, so
|
|
||||||
it **cannot be fully offloaded**: `-ngl 999` would try to put all 43 layers into
|
|
||||||
VRAM and overflow once the KV cache + Vulkan compute buffers are added. Instead
|
|
||||||
`-ngl 38` offloads 38 of 43 layers to the GPU and keeps 5 layers (~10 GiB) on
|
|
||||||
CPU RAM, leaving ~5 GiB of VRAM headroom for the KV cache, compute buffers, and
|
|
||||||
fragmentation.
|
|
||||||
|
|
||||||
**KV cache is f16, not q8_0** — the Vulkan backend has no Flash Attention for
|
|
||||||
`deepseek4`, and quantized V cache requires Flash Attention (llama.cpp
|
|
||||||
hard-errors: *"quantized V cache was requested, but this requires Flash
|
|
||||||
Attention"*). `deepseek4`/MLA models also require K and V cache types to be
|
|
||||||
*identical*, so K cannot be quantized either. f16 KV at 64k is ~5.7 GiB (MLA
|
|
||||||
KV: 576 K + 512 V elements/token/layer × 43 layers × 65536 tokens × 2 bytes).
|
|
||||||
|
|
||||||
Approximate VRAM usage:
|
|
||||||
|
|
||||||
| Component | VRAM |
|
|
||||||
|---------------------------------|-------------|
|
|
||||||
| Weights (38 GPU layers) | ~77 GiB |
|
|
||||||
| KV cache (f16, 64k, 1 slot) | ~5.7 GiB |
|
|
||||||
| Vulkan compute buffers | ~2 GiB |
|
|
||||||
| **Total in VRAM** | **~85 GiB** |
|
|
||||||
| **Headroom (of 90 GiB)** | **~5 GiB** |
|
|
||||||
|
|
||||||
5 layers (~10 GiB) live in CPU RAM (counted against the pod's cgroup memory
|
|
||||||
limit, not VRAM). VRAM is exclusive to this model (no other pod uses it); the
|
|
||||||
other NUCBox pods only compete for the 30 GiB CPU RAM.
|
|
||||||
|
|
||||||
Note: several `deepseek4`-specific fused ops (Lightning Indexer, HC pre/comb/post)
|
|
||||||
are not yet implemented in the Vulkan backend and fall back to CPU (logged as
|
|
||||||
warnings, not fatal). Inference still works; it is slower than it will be once
|
|
||||||
those ops land in a future `server-vulkan` build.
|
|
||||||
|
|
||||||
## How to tune if it OOMs / has spare headroom
|
|
||||||
|
|
||||||
- **Pod OOM-killed or Vulkan out-of-device-memory during load:** lower `-ngl`
|
|
||||||
(e.g. `36`) to keep more layers on CPU, or raise the container `memory`
|
|
||||||
limit. Remember the KV cache is f16 (cannot be quantized — no Flash Attention
|
|
||||||
on Vulkan), so the only ways to free VRAM are fewer GPU layers or a smaller
|
|
||||||
context window.
|
|
||||||
- **VRAM headroom looks generous in logs:** raise `-ngl` toward `43` (full
|
|
||||||
offload) and/or raise `-c` for larger context.
|
|
||||||
- **Need more concurrent requests:** raise `-np` (each extra slot multiplies the
|
|
||||||
f16 KV cost, ~5.7 GiB/slot at 64k), and the context per slot shrinks
|
|
||||||
(`-c / -np`).
|
|
||||||
|
|
||||||
## initContainer (`fetch-model`)
|
|
||||||
|
|
||||||
- Idempotently downloads the 3 shards into the shared models PVC, skipping if
|
|
||||||
all shards are already present and non-empty (pod restart / recreate).
|
|
||||||
- **Atomic, resumable downloads:** each shard is fetched to a `.partial` file
|
|
||||||
(resumable via `curl -C -`) and only renamed to the final name on success, so
|
|
||||||
an interrupted download never leaves a half-written final file that would
|
|
||||||
wrongly skip re-download.
|
|
||||||
- **Free-space check:** requires ~95 GiB free on `/models` before downloading;
|
|
||||||
fails loudly with a clear message if the hostPath disk is too small (a PVC
|
|
||||||
capacity bump does not add physical space to a hostPath volume — the disk on
|
|
||||||
the NUCBox must be expanded).
|
|
||||||
- **Deletes the retired Qwen3.6 GGUFs** from the PVC to reclaim ~36 GiB.
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
```
|
|
||||||
requests: cpu 1000m, memory 6Gi
|
|
||||||
limits: cpu 4000m, memory 24Gi
|
|
||||||
```
|
|
||||||
|
|
||||||
VRAM (weights + KV + compute) is **not** counted against the cgroup memory
|
|
||||||
limit — the limit only covers CPU-side overhead, the mmap'd GGUF pages for the
|
|
||||||
5 CPU-resident layers (~10 GiB, resident during inference), and reclaimable
|
|
||||||
page cache during load. k8s sees ~30 GiB as the node's allocatable system RAM.
|
|
||||||
|
|
||||||
## Probes
|
|
||||||
|
|
||||||
- `readinessProbe`: `GET /health` after 30s, every 10s, 6 failures.
|
|
||||||
- `livenessProbe`: `GET /health` after **300s** (87 GiB load + Vulkan init
|
|
||||||
takes several minutes), every 30s, 5 failures.
|
|
||||||
|
|
||||||
## Security / GPU access
|
|
||||||
|
|
||||||
`privileged: true` + mounts `/dev/dri` (DRM render nodes) — simplest reliable
|
|
||||||
way to give Vulkan access to the AMD APU on k3s without a device plugin.
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# Deployment arguments — `qwen3.6-27b` (REMOVED)
|
|
||||||
|
|
||||||
> **Status:** This model was **removed** from the cluster to make VRAM room for
|
|
||||||
> `deepseek-v4-flash-0731` (UD-IQ1_M, ~87 GiB), which nearly fills the NUCBox's
|
|
||||||
> 90 GiB VRAM pool on its own. This file documents the flags used when the
|
|
||||||
> model was live, so it can be redeployed later if the DeepSeek model is taken
|
|
||||||
> down or moved to different hardware.
|
|
||||||
>
|
|
||||||
> To redeploy: restore `deployment-qwen36-27b.yaml` from git history and
|
|
||||||
> re-add the LiteLLM entry. Re-check the VRAM budget —
|
|
||||||
> co-locating with the 87 GiB DeepSeek model is **not** possible on the current
|
|
||||||
> 90 GiB pool.
|
|
||||||
|
|
||||||
## Model & source
|
|
||||||
|
|
||||||
- **Model:** `unsloth/Qwen3.6-27B-MTP-GGUF` — Qwen3.6-27B, **dense** (all
|
|
||||||
params active per token), MTP variant.
|
|
||||||
- **Quantization:** `UD-Q4_K_XL` (Unsloth Dynamic Q4_K_XL), **~16 GiB**,
|
|
||||||
single GGUF file (`Qwen3.6-27B-UD-Q4_K_XL.gguf`).
|
|
||||||
- **HuggingFace repo:** `https://huggingface.co/unsloth/Qwen3.6-27B-MTP-GGUF`
|
|
||||||
- **Image:** `ghcr.io/ggml-org/llama.cpp:server-vulkan` (Mesa/RADV Vulkan
|
|
||||||
driver, supports Radeon 8060S / RDNA 3.5).
|
|
||||||
|
|
||||||
## Hardware target
|
|
||||||
|
|
||||||
NUCBox APU — AMD Ryzen AI Max 395 (Strix Halo), Radeon 8060S, 128 GiB unified
|
|
||||||
memory (32 GiB RAM / 96 GiB VRAM via firmware). Pinned via `nodeSelector:
|
|
||||||
{kubernetes.io/arch: amd64, hardware: high-memory}`.
|
|
||||||
|
|
||||||
## Why it fit (and co-existed with the 35B-A3B)
|
|
||||||
|
|
||||||
At ~16 GiB, the Q4 model is small enough to **fully offload** to the GPU
|
|
||||||
(`-ngl 999`) and still leave ~80 GiB of VRAM — which is why it ran alongside
|
|
||||||
the Qwen3.6-35B-A3B model (see `args-qwen36-35b-a3b.md`). Combined the two
|
|
||||||
models used ~61 GiB of the 96 GiB pool. The dense 27B's constraint was **KV
|
|
||||||
cache**, not weights: dense attention KV is ~256 KiB/token (f16) / ~128
|
|
||||||
KiB/token (q8_0), so large context is expensive.
|
|
||||||
|
|
||||||
## Argument-by-argument
|
|
||||||
|
|
||||||
| Flag | Value | Meaning |
|
|
||||||
|------|-------|---------|
|
|
||||||
| `-m` | `/models/Qwen3.6-27B-UD-Q4_K_XL.gguf` | Model file (single GGUF). |
|
|
||||||
| `--alias` | `qwen3.6-27b` | Name reported by `GET /v1/models`; matched the LiteLLM `model_name`. |
|
|
||||||
| `--host` | `0.0.0.0` | Bind on all interfaces (k8s Service reach). |
|
|
||||||
| `--port` | `8080` | Listen port (matches `containerPort` + Service `targetPort`). |
|
|
||||||
| `--jinja` | *(flag)* | Use the GGUF's chat template (Qwen3 thinking format). |
|
|
||||||
| `-ngl` | `999` | **Full GPU offload** — all layers into VRAM (fits easily in 96 GiB). |
|
|
||||||
| `-c` | `131072` | Total KV-cache context (131k). Single slot gets the full window. |
|
|
||||||
| `-np` | `1` | 1 parallel slot ⇒ the full 131k goes to a single request (dense KV is the constraint, so no slot splitting). |
|
|
||||||
| `--cont-batching` | *(flag)* | Continuous batching across slots. |
|
|
||||||
| `--cache-type-k` | `q8_0` | Quantize K cache to q8_0 — halves KV VRAM (~32 GiB → ~16 GiB at 131k); essential to make 131k affordable on a dense model. |
|
|
||||||
| `--cache-type-v` | `q8_0` | Quantize V cache to q8_0 (same rationale). |
|
|
||||||
| `--threads` | `8` | CPU threads for sampling/overhead (GPU does the heavy lifting under full offload). |
|
|
||||||
|
|
||||||
## VRAM budget (when live)
|
|
||||||
|
|
||||||
| Component | VRAM |
|
|
||||||
|-----------------------|-------------|
|
|
||||||
| Weights (full offload) | ~16 GiB |
|
|
||||||
| KV cache (q8_0, 131k) | ~16 GiB |
|
|
||||||
| **Subtotal** | **~32 GiB** |
|
|
||||||
|
|
||||||
Left ~64 GiB of headroom on the 96 GiB pool — comfortable, and the basis for
|
|
||||||
co-locating the 35B-A3B model.
|
|
||||||
|
|
||||||
## Notes for redeployment
|
|
||||||
|
|
||||||
- This was the **deeper-reasoning** model (dense, all params active). The
|
|
||||||
`qwen3.6-35b-a3b` was the **fast/flash** variant. If redeploying only one,
|
|
||||||
decide based on latency-vs-quality needs.
|
|
||||||
- LiteLLM entry that went with it:
|
|
||||||
```yaml
|
|
||||||
- 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"
|
|
||||||
```
|
|
||||||
- Consumers at removal time: `platform-engineer` (default model),
|
|
||||||
`home-manager` (used the 35B-A3B, not this one). See git history for exact
|
|
||||||
config.
|
|
||||||
- The `fetch-model` initContainer downloaded the single GGUF idempotently into
|
|
||||||
the shared models PVC; the new DeepSeek pod's initContainer **deletes** this
|
|
||||||
GGUF on first boot to reclaim space, so a redeploy will re-download it.
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# Deployment arguments — `qwen3.6-35b-a3b` (REMOVED)
|
|
||||||
|
|
||||||
> **Status:** This model was **removed** from the cluster to make VRAM room for
|
|
||||||
> `deepseek-v4-flash-0731` (UD-IQ1_M, ~87 GiB), which nearly fills the NUCBox's
|
|
||||||
> 90 GiB VRAM pool on its own. This file documents the flags used when the
|
|
||||||
> model was live, so it can be redeployed later if the DeepSeek model is taken
|
|
||||||
> down or moved to different hardware.
|
|
||||||
>
|
|
||||||
> To redeploy: restore `deployment-qwen36-35b-a3b.yaml` (the manifest is
|
|
||||||
> preserved in git history) and re-add the LiteLLM entry. Re-check the VRAM
|
|
||||||
> budget — co-locating with the 87 GiB DeepSeek model is **not** possible on
|
|
||||||
> the current 90 GiB pool.
|
|
||||||
|
|
||||||
## Model & source
|
|
||||||
|
|
||||||
- **Model:** `unsloth/Qwen3.6-35B-A3B-MTP-GGUF` — Qwen3.6-35B-A3B, the "flash"
|
|
||||||
**Mixture-of-Experts** variant (35B total params, **only 3B active** per
|
|
||||||
token), MTP variant.
|
|
||||||
- **Quantization:** `UD-Q4_K_XL` (Unsloth Dynamic Q4_K_XL), **~20 GiB**,
|
|
||||||
single GGUF file (`Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf`).
|
|
||||||
- **HuggingFace repo:** `https://huggingface.co/unsloth/Qwen3.6-35B-A3B-MTP-GGUF`
|
|
||||||
- **Image:** `ghcr.io/ggml-org/llama.cpp:server-vulkan` (Mesa/RADV Vulkan
|
|
||||||
driver, supports Radeon 8060S / RDNA 3.5).
|
|
||||||
|
|
||||||
## Hardware target
|
|
||||||
|
|
||||||
NUCBox APU — AMD Ryzen AI Max 395 (Strix Halo), Radeon 8060S, 128 GiB unified
|
|
||||||
memory (32 GiB RAM / 96 GiB VRAM via firmware). Pinned via `nodeSelector:
|
|
||||||
{kubernetes.io/arch: amd64, hardware: high-memory}`.
|
|
||||||
|
|
||||||
## Why it was the "flash" model
|
|
||||||
|
|
||||||
Despite having **more total parameters** than the dense 27B, only **3B are
|
|
||||||
active per token** (MoE), so inference is significantly faster. The full ~20 GiB
|
|
||||||
of Q4 weights is still loaded into VRAM, but only a small fraction is computed
|
|
||||||
per token. Its KV cache is also tiny (~72 KiB/token), so large context is nearly
|
|
||||||
free — hence the much larger `-c` and the 2-slot split.
|
|
||||||
|
|
||||||
## Argument-by-argument
|
|
||||||
|
|
||||||
| Flag | Value | Meaning |
|
|
||||||
|------|-------|---------|
|
|
||||||
| `-m` | `/models/Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf` | Model file (single GGUF). |
|
|
||||||
| `--alias` | `qwen3.6-35b-a3b` | Name reported by `GET /v1/models`; matched the LiteLLM `model_name`. |
|
|
||||||
| `--host` | `0.0.0.0` | Bind on all interfaces (k8s Service reach). |
|
|
||||||
| `--port` | `8080` | Listen port (matches `containerPort` + Service `targetPort`). |
|
|
||||||
| `--jinja` | *(flag)* | Use the GGUF's chat template (Qwen3 thinking format). |
|
|
||||||
| `-ngl` | `999` | **Full GPU offload** — all layers into VRAM (fits easily in 96 GiB). |
|
|
||||||
| `-c` | `262144` | Total KV-cache context (262k), **split across parallel slots**. |
|
|
||||||
| `-np` | `2` | 2 parallel slots ⇒ 131k tokens per concurrent request (262k / 2). MoE KV is cheap, so splitting is affordable. |
|
|
||||||
| `--cont-batching` | *(flag)* | Continuous batching across the 2 slots. |
|
|
||||||
| `--cache-type-k` | `q8_0` | Quantize K cache to q8_0 — halves KV VRAM (~18 GiB → ~9 GiB at 262k); frees headroom for the large `-c`. Drop to `q4_0` for even less VRAM if retrieval quality allows. |
|
|
||||||
| `--cache-type-v` | `q8_0` | Quantize V cache to q8_0 (same rationale). |
|
|
||||||
| `--threads` | `8` | CPU threads for sampling/overhead (GPU does the heavy lifting under full offload). |
|
|
||||||
|
|
||||||
## VRAM budget (when live)
|
|
||||||
|
|
||||||
| Component | VRAM |
|
|
||||||
|-----------------------|------------|
|
|
||||||
| Weights (full offload) | ~20 GiB |
|
|
||||||
| KV cache (q8_0, 262k) | ~9 GiB |
|
|
||||||
| **Subtotal** | **~29 GiB** |
|
|
||||||
|
|
||||||
Left ~67 GiB of headroom on the 96 GiB pool — comfortable, and the basis for
|
|
||||||
co-locating the dense 27B model (combined ~61 GiB).
|
|
||||||
|
|
||||||
## Combined VRAM budget (both Qwen models, when live)
|
|
||||||
|
|
||||||
| 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 on the 96 GiB pool — comfortable. (DeepSeek-V4-Flash-0731 at
|
|
||||||
~87 GiB cannot coexist with either of these; that's why both were removed.)
|
|
||||||
|
|
||||||
## Notes for redeployment
|
|
||||||
|
|
||||||
- This was the **fast/flash** model (MoE, 3B active). The `qwen3.6-27b` was the
|
|
||||||
**deeper-reasoning** model (dense, all params active). If redeploying only
|
|
||||||
one, decide based on latency-vs-quality needs.
|
|
||||||
- LiteLLM entry that went with it:
|
|
||||||
```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"
|
|
||||||
```
|
|
||||||
- Consumers at removal time: `home-manager` (default + auxiliary),
|
|
||||||
`platform-engineer` (auxiliary compression + title generation). See git
|
|
||||||
history for exact config.
|
|
||||||
- The `fetch-model` initContainer downloaded the single GGUF idempotently into
|
|
||||||
the shared models PVC; the new DeepSeek pod's initContainer **deletes** this
|
|
||||||
GGUF on first boot to reclaim space, so a redeploy will re-download it.
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
# LiquidAI LFM2.5-2.6B with speculative decoding served by llama.cpp.
|
|
||||||
# The primary and draft GGUFs are downloaded into the shared model PVC before
|
|
||||||
# llama-server starts.
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: llamacpp-lfm25-26b
|
|
||||||
namespace: llamacpp
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
model: lfm2.5-2.6b
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: llamacpp
|
|
||||||
model: lfm2.5-2.6b
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
model: lfm2.5-2.6b
|
|
||||||
spec:
|
|
||||||
nodeSelector:
|
|
||||||
kubernetes.io/arch: amd64
|
|
||||||
hardware: high-memory
|
|
||||||
initContainers:
|
|
||||||
- name: fetch-models
|
|
||||||
image: alpine:3.20
|
|
||||||
command: ["/bin/sh", "-c"]
|
|
||||||
args:
|
|
||||||
- |
|
|
||||||
set -eu
|
|
||||||
apk add --no-cache curl
|
|
||||||
for entry in \
|
|
||||||
"https://huggingface.co/LiquidAI/LFM2.5-2.6B-GGUF/resolve/main/LFM2.5-2.6B-Q4_K_M.gguf|LFM2.5-2.6B-Q4_K_M.gguf" \
|
|
||||||
"https://huggingface.co/LiquidAI/LFM2.5-2.6B-GGUF/resolve/main/LFM2.5-2.6B-Q4_0.gguf|LFM2.5-2.6B-Q4_0.gguf"; do
|
|
||||||
url=${entry%%|*}
|
|
||||||
file=${entry##*|}
|
|
||||||
if [ -s "/models/$file" ]; then
|
|
||||||
echo "$file already present - skipping download."
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
echo "Downloading $file ..."
|
|
||||||
curl -fL --retry 5 --retry-delay 5 -C - \
|
|
||||||
-o "/models/$file.partial" "$url"
|
|
||||||
mv "/models/$file.partial" "/models/$file"
|
|
||||||
echo "Download complete: $(ls -lh "/models/$file")"
|
|
||||||
done
|
|
||||||
volumeMounts:
|
|
||||||
- name: models
|
|
||||||
mountPath: /models
|
|
||||||
containers:
|
|
||||||
- name: llama-server
|
|
||||||
image: ghcr.io/ggml-org/llama.cpp:server-vulkan
|
|
||||||
imagePullPolicy: IfNotPresent
|
|
||||||
args:
|
|
||||||
- -m
|
|
||||||
- /models/LFM2.5-2.6B-Q4_K_M.gguf
|
|
||||||
- --model-draft
|
|
||||||
- /models/LFM2.5-2.6B-Q4_0.gguf
|
|
||||||
- --spec-default
|
|
||||||
- --spec-type
|
|
||||||
- draft-simple
|
|
||||||
- --ctx-size
|
|
||||||
- "121000"
|
|
||||||
- --temp
|
|
||||||
- "0.1"
|
|
||||||
- --cache-type-k
|
|
||||||
- q8_0
|
|
||||||
- --cache-type-v
|
|
||||||
- q8_0
|
|
||||||
- --fit
|
|
||||||
- "off"
|
|
||||||
- --alias
|
|
||||||
- lfm2.5-2.6b
|
|
||||||
- --host
|
|
||||||
- 0.0.0.0
|
|
||||||
- --port
|
|
||||||
- "8080"
|
|
||||||
- --jinja
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 8080
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: "500m"
|
|
||||||
memory: 2Gi
|
|
||||||
limits:
|
|
||||||
cpu: "4000m"
|
|
||||||
memory: 12Gi
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 8080
|
|
||||||
initialDelaySeconds: 30
|
|
||||||
periodSeconds: 10
|
|
||||||
failureThreshold: 6
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 8080
|
|
||||||
initialDelaySeconds: 180
|
|
||||||
periodSeconds: 30
|
|
||||||
failureThreshold: 5
|
|
||||||
securityContext:
|
|
||||||
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-lfm25-26b
|
|
||||||
namespace: llamacpp
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
model: lfm2.5-2.6b
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
selector:
|
|
||||||
app: llamacpp
|
|
||||||
model: lfm2.5-2.6b
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 80
|
|
||||||
targetPort: 8080
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# Qwen3.8-27B with MTP speculative decoding served by llama.cpp.
|
|
||||||
# The primary and draft GGUFs are downloaded by an initContainer into the
|
|
||||||
# shared model PVC before llama-server starts.
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: llamacpp-qwen38-27b
|
|
||||||
namespace: llamacpp
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
model: qwen3.8-27b
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: llamacpp
|
|
||||||
model: qwen3.8-27b
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
model: qwen3.8-27b
|
|
||||||
spec:
|
|
||||||
nodeSelector:
|
|
||||||
kubernetes.io/arch: amd64
|
|
||||||
hardware: high-memory
|
|
||||||
initContainers:
|
|
||||||
- name: fetch-models
|
|
||||||
image: alpine:3.20
|
|
||||||
command: ["/bin/sh", "-c"]
|
|
||||||
args:
|
|
||||||
- |
|
|
||||||
set -eu
|
|
||||||
apk add --no-cache curl
|
|
||||||
for entry in \
|
|
||||||
"https://huggingface.co/ggml-org/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-Q4_K_M.gguf|Qwen3.8-27B-Q4_K_M.gguf" \
|
|
||||||
"https://huggingface.co/ggml-org/Qwen3.8-27B-GGUF/resolve/main/mtp-Qwen3.8-27B-Q4_0.gguf|mtp-Qwen3.8-27B-Q4_0.gguf"; do
|
|
||||||
url=${entry%%|*}
|
|
||||||
file=${entry##*|}
|
|
||||||
if [ -s "/models/$file" ]; then
|
|
||||||
echo "$file already present - skipping download."
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
echo "Downloading $file ..."
|
|
||||||
curl -fL --retry 5 --retry-delay 5 -C - \
|
|
||||||
-o "/models/$file.partial" "$url"
|
|
||||||
mv "/models/$file.partial" "/models/$file"
|
|
||||||
echo "Download complete: $(ls -lh "/models/$file")"
|
|
||||||
done
|
|
||||||
volumeMounts:
|
|
||||||
- name: models
|
|
||||||
mountPath: /models
|
|
||||||
containers:
|
|
||||||
- name: llama-server
|
|
||||||
image: ghcr.io/ggml-org/llama.cpp:server-vulkan
|
|
||||||
imagePullPolicy: IfNotPresent
|
|
||||||
args:
|
|
||||||
- -m
|
|
||||||
- /models/Qwen3.8-27B-Q4_K_M.gguf
|
|
||||||
- --model-draft
|
|
||||||
- /models/mtp-Qwen3.8-27B-Q4_0.gguf
|
|
||||||
- --spec-default
|
|
||||||
- --spec-type
|
|
||||||
- draft-mtp
|
|
||||||
- --ctx-size
|
|
||||||
- "196608"
|
|
||||||
- --cache-type-k
|
|
||||||
- q8_0
|
|
||||||
- --cache-type-v
|
|
||||||
- q8_0
|
|
||||||
- --reasoning-preserve
|
|
||||||
- --fit
|
|
||||||
- "off"
|
|
||||||
- --agent
|
|
||||||
- --chat-template-kwargs
|
|
||||||
- '{"reasoning_effort":"medium"}'
|
|
||||||
- --alias
|
|
||||||
- qwen3.8-27b
|
|
||||||
- --host
|
|
||||||
- 0.0.0.0
|
|
||||||
- --port
|
|
||||||
- "8080"
|
|
||||||
- --jinja
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 8080
|
|
||||||
resources:
|
|
||||||
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: 600
|
|
||||||
periodSeconds: 30
|
|
||||||
failureThreshold: 5
|
|
||||||
securityContext:
|
|
||||||
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-qwen38-27b
|
|
||||||
namespace: llamacpp
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
model: qwen3.8-27b
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
selector:
|
|
||||||
app: llamacpp
|
|
||||||
model: qwen3.8-27b
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 80
|
|
||||||
targetPort: 8080
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# Wyoming Speech-to-Text (rhasspy/wyoming-whisper).
|
|
||||||
#
|
|
||||||
# Exposes the Wyoming protocol over TCP on port 10300 so Home Assistant's
|
|
||||||
# Wyoming STT integration can stream audio and receive transcriptions. Uses
|
|
||||||
# faster-whisper under the hood with the language pinned to Catalan (`ca`).
|
|
||||||
#
|
|
||||||
# These are lightweight CPU-only pods (no GPU), so they are NOT pinned to the
|
|
||||||
# NUCBox via nodeSelector — k3s can schedule them on any amd64 node. Models are
|
|
||||||
# cached in a local-path PVC (`/data`) so they survive pod restarts without
|
|
||||||
# re-downloading.
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: wyoming-whisper-data
|
|
||||||
namespace: llamacpp
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
component: stt
|
|
||||||
spec:
|
|
||||||
storageClassName: local-path
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 5Gi
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: wyoming-whisper
|
|
||||||
namespace: llamacpp
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
component: stt
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: llamacpp
|
|
||||||
component: stt
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
component: stt
|
|
||||||
spec:
|
|
||||||
nodeSelector:
|
|
||||||
kubernetes.io/arch: amd64
|
|
||||||
containers:
|
|
||||||
- name: wyoming-whisper
|
|
||||||
image: rhasspy/wyoming-whisper
|
|
||||||
imagePullPolicy: IfNotPresent
|
|
||||||
args:
|
|
||||||
- --uri
|
|
||||||
- tcp://0.0.0.0:10300
|
|
||||||
- --language # pin transcription language to Catalan
|
|
||||||
- ca
|
|
||||||
- --model # faster-whisper model (tune up to medium for accuracy)
|
|
||||||
- small
|
|
||||||
ports:
|
|
||||||
- name: wyoming
|
|
||||||
containerPort: 10300
|
|
||||||
resources:
|
|
||||||
# faster-whisper `small` is ~244M params (fp16 ~500 MiB weights)
|
|
||||||
# plus CTranslate2 runtime / workspace buffers during decode.
|
|
||||||
# 1 GiB OOM-kills during model load; 2 GiB gives headroom.
|
|
||||||
requests:
|
|
||||||
cpu: "100m"
|
|
||||||
memory: "512Mi"
|
|
||||||
limits:
|
|
||||||
cpu: "1000m"
|
|
||||||
memory: "2Gi"
|
|
||||||
volumeMounts:
|
|
||||||
- name: data
|
|
||||||
mountPath: /data
|
|
||||||
volumes:
|
|
||||||
- name: data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: wyoming-whisper-data
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: wyoming-whisper
|
|
||||||
namespace: llamacpp
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
component: stt
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
selector:
|
|
||||||
app: llamacpp
|
|
||||||
component: stt
|
|
||||||
ports:
|
|
||||||
- name: wyoming
|
|
||||||
port: 10300
|
|
||||||
targetPort: 10300
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# Wyoming Text-to-Speech (rhasspy/wyoming-piper).
|
|
||||||
#
|
|
||||||
# Exposes the Wyoming protocol over TCP on port 10200 so Home Assistant's
|
|
||||||
# Wyoming TTS integration can send text and receive synthesized audio. Uses
|
|
||||||
# Piper under the hood with the Catalan voice `ca_ES-upc_ona-medium`.
|
|
||||||
#
|
|
||||||
# Lightweight CPU-only pod (no GPU), not pinned to the NUCBox. The voice is
|
|
||||||
# cached in a local-path PVC (`/data`) so it survives pod restarts.
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: wyoming-piper-data
|
|
||||||
namespace: llamacpp
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
component: tts
|
|
||||||
spec:
|
|
||||||
storageClassName: local-path
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 1Gi
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: wyoming-piper
|
|
||||||
namespace: llamacpp
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
component: tts
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: llamacpp
|
|
||||||
component: tts
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
component: tts
|
|
||||||
spec:
|
|
||||||
nodeSelector:
|
|
||||||
kubernetes.io/arch: amd64
|
|
||||||
containers:
|
|
||||||
- name: wyoming-piper
|
|
||||||
image: rhasspy/wyoming-piper
|
|
||||||
imagePullPolicy: IfNotPresent
|
|
||||||
args:
|
|
||||||
- --uri
|
|
||||||
- tcp://0.0.0.0:10200
|
|
||||||
- --voice # Catalan (Spain) medium-quality UPC Ona voice
|
|
||||||
- ca_ES-upc_ona-medium
|
|
||||||
ports:
|
|
||||||
- name: wyoming
|
|
||||||
containerPort: 10200
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: "100m"
|
|
||||||
memory: "128Mi"
|
|
||||||
limits:
|
|
||||||
cpu: "500m"
|
|
||||||
memory: "512Mi"
|
|
||||||
volumeMounts:
|
|
||||||
- name: data
|
|
||||||
mountPath: /data
|
|
||||||
volumes:
|
|
||||||
- name: data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: wyoming-piper-data
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: wyoming-piper
|
|
||||||
namespace: llamacpp
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
component: tts
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
selector:
|
|
||||||
app: llamacpp
|
|
||||||
component: tts
|
|
||||||
ports:
|
|
||||||
- name: wyoming
|
|
||||||
port: 10200
|
|
||||||
targetPort: 10200
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# Wyoming Wake Word (rhasspy/wyoming-openwakeword).
|
|
||||||
#
|
|
||||||
# Exposes the Wyoming protocol over TCP on port 10400 so Home Assistant's
|
|
||||||
# Wyoming wake word integration can stream microphone audio and receive wake
|
|
||||||
# word detections. Uses openWakeWord under the hood with ONNX models.
|
|
||||||
#
|
|
||||||
# Lightweight CPU-only pod (no GPU), not pinned to the NUCBox. Models are
|
|
||||||
# cached in a local-path PVC (`/data`) so they don't re-download on restart.
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: wyoming-openwakeword-data
|
|
||||||
namespace: llamacpp
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
component: wakeword
|
|
||||||
spec:
|
|
||||||
storageClassName: local-path
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 1Gi
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: wyoming-openwakeword
|
|
||||||
namespace: llamacpp
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
component: wakeword
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: llamacpp
|
|
||||||
component: wakeword
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
component: wakeword
|
|
||||||
spec:
|
|
||||||
nodeSelector:
|
|
||||||
kubernetes.io/arch: amd64
|
|
||||||
containers:
|
|
||||||
- name: wyoming-openwakeword
|
|
||||||
image: rhasspy/wyoming-openwakeword
|
|
||||||
imagePullPolicy: IfNotPresent
|
|
||||||
args:
|
|
||||||
- --uri
|
|
||||||
- tcp://0.0.0.0:10400
|
|
||||||
ports:
|
|
||||||
- name: wyoming
|
|
||||||
containerPort: 10400
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: "100m"
|
|
||||||
memory: "128Mi"
|
|
||||||
limits:
|
|
||||||
cpu: "500m"
|
|
||||||
memory: "512Mi"
|
|
||||||
volumeMounts:
|
|
||||||
- name: data
|
|
||||||
mountPath: /data
|
|
||||||
volumes:
|
|
||||||
- name: data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: wyoming-openwakeword-data
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: wyoming-openwakeword
|
|
||||||
namespace: llamacpp
|
|
||||||
labels:
|
|
||||||
app: llamacpp
|
|
||||||
component: wakeword
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
selector:
|
|
||||||
app: llamacpp
|
|
||||||
component: wakeword
|
|
||||||
ports:
|
|
||||||
- name: wyoming
|
|
||||||
port: 10400
|
|
||||||
targetPort: 10400
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Namespace
|
|
||||||
metadata:
|
|
||||||
name: llamacpp
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# 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.
|
|
||||||
#
|
|
||||||
# IMPORTANT: capacity is only metadata for a hostPath volume — k8s does NOT
|
|
||||||
# enforce it and bumping it does NOT add physical disk space. The active Qwen
|
|
||||||
# primary and draft GGUFs are downloaded into this directory, so the hostPath
|
|
||||||
# filesystem must have enough free space for both models.
|
|
||||||
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
|
|
||||||
volumeName: llamacpp-models # pin to the static hostPath PV by name — forces
|
|
||||||
# static binding and disables dynamic provisioning.
|
|
||||||
# The "manual" StorageClass is never created as an
|
|
||||||
# object; without volumeName a stuck PVC falls
|
|
||||||
# through to the (nonexistent) provisioner and errors
|
|
||||||
# with: storageclass.storage.k8s.io "manual" not found
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteMany
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 100Gi
|
|
||||||
@@ -14,8 +14,6 @@ spec:
|
|||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: prometheus
|
app: prometheus
|
||||||
annotations:
|
|
||||||
kubectl.kubernetes.io/restartedAt: "2026-07-26T12:00:00Z"
|
|
||||||
spec:
|
spec:
|
||||||
# Prevent scheduling on Raspberry Pi due to resource requirements (512Mi-1Gi memory, 500m-1000m CPU)
|
# Prevent scheduling on Raspberry Pi due to resource requirements (512Mi-1Gi memory, 500m-1000m CPU)
|
||||||
nodeSelector:
|
nodeSelector:
|
||||||
@@ -41,10 +39,10 @@ spec:
|
|||||||
mountPath: /prometheus
|
mountPath: /prometheus
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "1Gi"
|
memory: "512Mi"
|
||||||
cpu: "500m"
|
cpu: "500m"
|
||||||
limits:
|
limits:
|
||||||
memory: "4Gi"
|
memory: "1Gi"
|
||||||
cpu: "1000m"
|
cpu: "1000m"
|
||||||
volumes:
|
volumes:
|
||||||
- name: prometheus-config
|
- name: prometheus-config
|
||||||
|
|||||||
@@ -54,16 +54,6 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: myorg-assistant-secret
|
name: myorg-assistant-secret
|
||||||
key: LITELLM_API_KEY
|
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:
|
volumeMounts:
|
||||||
- name: myorg-data
|
- name: myorg-data
|
||||||
mountPath: /data/myorg
|
mountPath: /data/myorg
|
||||||
|
|||||||
@@ -54,16 +54,6 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: myorg-assistant-secret
|
name: myorg-assistant-secret
|
||||||
key: LITELLM_API_KEY
|
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:
|
volumeMounts:
|
||||||
- name: myorg-data
|
- name: myorg-data
|
||||||
mountPath: /data/myorg
|
mountPath: /data/myorg
|
||||||
|
|||||||
@@ -24,43 +24,6 @@ spec:
|
|||||||
restartPolicy: OnFailure
|
restartPolicy: OnFailure
|
||||||
imagePullSecrets:
|
imagePullSecrets:
|
||||||
- name: gitea-registry
|
- 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:
|
containers:
|
||||||
- name: git-sync
|
- name: git-sync
|
||||||
image: git.rogi.casa/roger/myorg-assistant/myorg-assistant:fcf79bf
|
image: git.rogi.casa/roger/myorg-assistant/myorg-assistant:fcf79bf
|
||||||
@@ -106,11 +69,6 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: myorg-assistant-secret
|
name: myorg-assistant-secret
|
||||||
key: LITELLM_API_KEY
|
key: LITELLM_API_KEY
|
||||||
- name: WEB_SECRET_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: myorg-assistant-secret
|
|
||||||
key: WEB_SECRET_KEY
|
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: myorg-data
|
- name: myorg-data
|
||||||
mountPath: /data/myorg
|
mountPath: /data/myorg
|
||||||
|
|||||||
@@ -61,16 +61,6 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: myorg-assistant-secret
|
name: myorg-assistant-secret
|
||||||
key: LITELLM_API_KEY
|
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:
|
volumeMounts:
|
||||||
- name: myorg-data
|
- name: myorg-data
|
||||||
mountPath: /data/myorg
|
mountPath: /data/myorg
|
||||||
|
|||||||
@@ -54,16 +54,6 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: myorg-assistant-secret
|
name: myorg-assistant-secret
|
||||||
key: LITELLM_API_KEY
|
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:
|
volumeMounts:
|
||||||
- name: myorg-data
|
- name: myorg-data
|
||||||
mountPath: /data/myorg
|
mountPath: /data/myorg
|
||||||
|
|||||||
@@ -53,17 +53,15 @@ spec:
|
|||||||
value: http
|
value: http
|
||||||
- name: N8N_PORT
|
- name: N8N_PORT
|
||||||
value: "5678"
|
value: "5678"
|
||||||
- name: NODE_OPTIONS
|
|
||||||
value: "--max-old-space-size=768"
|
|
||||||
image: n8nio/n8n
|
image: n8nio/n8n
|
||||||
name: n8n
|
name: n8n
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 5678
|
- containerPort: 5678
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "512Mi"
|
memory: "250Mi"
|
||||||
limits:
|
limits:
|
||||||
memory: "1Gi"
|
memory: "500Mi"
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- mountPath: /home/node/.n8n
|
- mountPath: /home/node/.n8n
|
||||||
name: n8n-claim0
|
name: n8n-claim0
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ spec:
|
|||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
tls:
|
tls:
|
||||||
- hosts:
|
- hosts:
|
||||||
- ai.rogi.casa
|
- openai.rogi.casa
|
||||||
secretName: openwebui-tls
|
secretName: openwebui-tls
|
||||||
rules:
|
rules:
|
||||||
- host: ai.rogi.casa
|
- host: openai.rogi.casa
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ platform-engineer/
|
|||||||
├── pvc.yaml # persistent /opt/data (HERMES_HOME)
|
├── pvc.yaml # persistent /opt/data (HERMES_HOME)
|
||||||
├── dockerfile # derived image: hermes-agent + kubectl + helm
|
├── dockerfile # derived image: hermes-agent + kubectl + helm
|
||||||
├── deployment.yaml # Deployment, schedules on amd64, mounts kube SA token
|
├── deployment.yaml # Deployment, schedules on amd64, mounts kube SA token
|
||||||
├── ingress.yaml # platform-engineer.rogi.casa → dashboard (optional)
|
├── ingress.yaml # hermes.rogi.casa → dashboard (optional)
|
||||||
└── README.md # this file
|
└── README.md # this file
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -130,10 +130,10 @@ model:
|
|||||||
auxiliary:
|
auxiliary:
|
||||||
compression:
|
compression:
|
||||||
provider: openai-api
|
provider: openai-api
|
||||||
model: gemini-3.6-flash
|
model: gemini-3-flash
|
||||||
title_generation:
|
title_generation:
|
||||||
provider: openai-api
|
provider: openai-api
|
||||||
model: gemini-3.6-flash
|
model: gemini-3-flash
|
||||||
|
|
||||||
terminal:
|
terminal:
|
||||||
backend: local
|
backend: local
|
||||||
@@ -173,7 +173,7 @@ HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=<strong password>
|
|||||||
|
|
||||||
> Why `OPENAI_API_KEY` + `OPENAI_BASE_URL`: the `openai-api` provider honours
|
> 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
|
> `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
|
> in-cluster LiteLLM. `claude-4.5-haiku` / `gemini-3-flash` are the model names
|
||||||
> already exposed by your `litellm/litellm.yaml` ConfigMap.
|
> already exposed by your `litellm/litellm.yaml` ConfigMap.
|
||||||
|
|
||||||
`SOUL.md` (personality + guardrails) — see `configmap.yaml`. Key points:
|
`SOUL.md` (personality + guardrails) — see `configmap.yaml`. Key points:
|
||||||
@@ -196,7 +196,7 @@ HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=<strong password>
|
|||||||
2–4 GiB; 1 GiB is fine without browser tools, which we keep off).
|
2–4 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).
|
- Volume: PVC mounted at `/opt/data` (HERMES_HOME), RWX not needed (single pod).
|
||||||
- Ports: 8642 (gateway API, internal only) and 9119 (dashboard) → exposed via
|
- Ports: 8642 (gateway API, internal only) and 9119 (dashboard) → exposed via
|
||||||
Ingress `platform-engineer.rogi.casa` with TLS + basic-auth (already enforced by the
|
Ingress `hermes.rogi.casa` with TLS + basic-auth (already enforced by the
|
||||||
`HERMES_DASHBOARD_BASIC_AUTH_*` env vars).
|
`HERMES_DASHBOARD_BASIC_AUTH_*` env vars).
|
||||||
- `imagePullSecrets: gitea-registry`.
|
- `imagePullSecrets: gitea-registry`.
|
||||||
- env from Secret; `HERMES_DASHBOARD=1`.
|
- env from Secret; `HERMES_DASHBOARD=1`.
|
||||||
@@ -208,21 +208,21 @@ HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=<strong password>
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Single daily cron job (Hermes-native)
|
## 7. Cron jobs to seed (Hermes-native)
|
||||||
|
|
||||||
The one-shot Job `hermes-cron-seed` creates one daily check at 06:00 local cluster time (`0 4 * * *` UTC; Hermes has no timezone configured). It removes the known legacy jobs first, then runs
|
These are written by an init script (one-shot Job `hermes-cron-seed`) that runs
|
||||||
`hermes cron create ...` against the gateway and is idempotent
|
`hermes cron create ...` against the gateway on first install, and is idempotent
|
||||||
(it removes old names and recreates the daily job). It delivers to Discord and combines node, resource, PVC, pod, ArgoCD, and certificate checks. It stays silent when healthy and sends one concise Catalan report when there is an issue.
|
(it checks existing job names). All deliver to Discord. Examples:
|
||||||
|
|
||||||
| Name | Schedule | Prompt (abbreviated) |
|
| Name | Schedule | Prompt (abbreviated) |
|
||||||
|------|----------|------------------------|
|
|------|----------|------------------------|
|
||||||
| `daily-cluster-check` | `0 4 * * *` UTC (06:00 local) | Combined daily platform health check; `[SILENT]` when healthy. |
|
| `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:
|
Design rules baked into SOUL.md:
|
||||||
- **Read-only checks** run frequently (10–30m) and stay silent unless wrong.
|
- **Read-only checks** run frequently (10–30m) and stay silent unless wrong.
|
||||||
@@ -274,7 +274,7 @@ Design rules baked into SOUL.md:
|
|||||||
4. Apply the Deployment; wait for the pod to go Running.
|
4. Apply the Deployment; wait for the pod to go Running.
|
||||||
5. `kubectl exec` in and run the one-shot cron seed:
|
5. `kubectl exec` in and run the one-shot cron seed:
|
||||||
`hermes cron create ...` (or apply the `cron-seed` Job).
|
`hermes cron create ...` (or apply the `cron-seed` Job).
|
||||||
6. Trigger the first `daily-cluster-check` manually: `hermes cron run daily-cluster-check`.
|
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.
|
7. Add the app to `argocd/gen-apps.sh`, regenerate, commit, push.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -283,7 +283,7 @@ Design rules baked into SOUL.md:
|
|||||||
|
|
||||||
1. **Notifications:** dedicated `platform-engineer` Discord bot → its own token
|
1. **Notifications:** dedicated `platform-engineer` Discord bot → its own token
|
||||||
in `secret.yaml` (`DISCORD_BOT_TOKEN`, `DISCORD_HOME_CHANNEL`).
|
in `secret.yaml` (`DISCORD_BOT_TOKEN`, `DISCORD_HOME_CHANNEL`).
|
||||||
2. **Dashboard:** public at `platform-engineer.rogi.casa` (Traefik TLS + cert-manager + the
|
2. **Dashboard:** public at `hermes.rogi.casa` (Traefik TLS + cert-manager + the
|
||||||
bundled Hermes basic-auth provider). Reach the dashboard on port 9119; the
|
bundled Hermes basic-auth provider). Reach the dashboard on port 9119; the
|
||||||
gateway API on 8642 is ClusterIP-only.
|
gateway API on 8642 is ClusterIP-only.
|
||||||
3. **Image:** derived image pushed to `git.rogi.casa/roger/hermes-agent`, pulled
|
3. **Image:** derived image pushed to `git.rogi.casa/roger/hermes-agent`, pulled
|
||||||
@@ -299,12 +299,11 @@ Design rules baked into SOUL.md:
|
|||||||
|
|
||||||
## 11. Deployment checklist (do in this order)
|
## 11. Deployment checklist (do in this order)
|
||||||
|
|
||||||
1. **Add the in-cluster model to LiteLLM** (already done in `litellm/litellm.yaml`):
|
1. **Add the Ollama model to LiteLLM** (already done in `litellm/litellm.yaml`):
|
||||||
the `deepseek-v4-flash-0731` entry points at the in-cluster llama.cpp server
|
the `qwen-3.6:27b` entry points at `http://10.88.20.12:11434`. Make sure
|
||||||
`http://llamacpp-deepseek-v4-flash-0731.llamacpp/v1` (served from the NUCBox
|
`qwen3.6:27b` is actually pulled on that Ollama host
|
||||||
APU; see `llamacpp/deployment-deepseek-v4-flash-0731.yaml`). Apply:
|
(`ollama pull qwen3.6:27b`). Apply: `kubectl apply -f litellm/` and restart
|
||||||
`kubectl apply -f litellm/` and restart the LiteLLM pod so the new config
|
the LiteLLM pod so the new config takes effect.
|
||||||
takes effect.
|
|
||||||
2. **Create the `gitea-registry` secret in the new namespace** (ArgoCD won't
|
2. **Create the `gitea-registry` secret in the new namespace** (ArgoCD won't
|
||||||
create it — it's not in the repo):
|
create it — it's not in the repo):
|
||||||
```
|
```
|
||||||
@@ -323,13 +322,13 @@ Design rules baked into SOUL.md:
|
|||||||
set the LiteLLM master key as `OPENAI_API_KEY` and a strong dashboard
|
set the LiteLLM master key as `OPENAI_API_KEY` and a strong dashboard
|
||||||
password + a 32-byte session secret.
|
password + a 32-byte session secret.
|
||||||
5. **Commit & push** the whole change. ArgoCD will create the namespace
|
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`.
|
resources, deploy the pod, and bring up the ingress at `hermes.rogi.casa`.
|
||||||
6. **Seed the cron job:**
|
6. **Seed the cron jobs:**
|
||||||
`kubectl apply -f platform-engineer/cron-seed.yaml` (one-shot Job) — it waits
|
`kubectl apply -f platform-engineer/cron-seed.yaml` (one-shot Job) — it waits
|
||||||
for the Hermes pod, removes the legacy schedules, and creates the single
|
for the hermes pod, then runs `hermes cron create ...` for each watchdog.
|
||||||
daily check. Re-run it to migrate an existing PVC or after a wipe.
|
Re-run it any time you want to re-seed after a wipe.
|
||||||
7. **Smoke test:** trigger the daily health check manually —
|
7. **Smoke test:** trigger the first health check manually —
|
||||||
`kubectl exec -n platform-engineer deploy/hermes -- hermes cron run daily-cluster-check` —
|
`kubectl exec -n platform-engineer deploy/hermes -- hermes cron run cluster-health-check` —
|
||||||
and confirm the message lands in Discord.
|
and confirm the message lands in Discord.
|
||||||
8. **ArgoCD:** the `Application` (`argocd/apps/platform-engineer.yaml`) is
|
8. **ArgoCD:** the `Application` (`argocd/apps/platform-engineer.yaml`) is
|
||||||
already generated. After commit, Argo will reconcile it like every other app.
|
already generated. After commit, Argo will reconcile it like every other app.
|
||||||
@@ -339,11 +338,10 @@ Design rules baked into SOUL.md:
|
|||||||
- **ArgoCD owns** (in git): namespace, RBAC, Secret, ConfigMap (seed), PVC,
|
- **ArgoCD owns** (in git): namespace, RBAC, Secret, ConfigMap (seed), PVC,
|
||||||
Deployment, Service, Ingress, cron-seed Job.
|
Deployment, Service, Ingress, cron-seed Job.
|
||||||
- **Runtime state (on the PVC, NOT reconciled):** `config.yaml`, `SOUL.md`,
|
- **Runtime state (on the PVC, NOT reconciled):** `config.yaml`, `SOUL.md`,
|
||||||
`.env`, `cron/jobs.json`, `sessions/`, `memories/`, `skills/`. The cron seed
|
`.env`, `cron/jobs.json`, `sessions/`, `memories/`, `skills/`. The ConfigMap
|
||||||
migrates the existing `cron/jobs.json` to the single daily check, but the
|
only *seeds* these on first boot; after that, edits you make via the
|
||||||
file remains runtime state afterward. The ConfigMap only *seeds* these on
|
dashboard or `hermes cron edit` persist on the PVC and Argo will not revert
|
||||||
first boot; after that, edits you make via the dashboard or `hermes cron edit`
|
them. If you ever want a hard reset, delete the PVC and re-apply.
|
||||||
persist on the PVC and Argo will not revert them. If you ever want a hard reset, delete the PVC and re-apply.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -359,7 +357,7 @@ Design rules baked into SOUL.md:
|
|||||||
| `dockerfile` | derived image: hermes-agent + kubectl + helm (linux/amd64) |
|
| `dockerfile` | derived image: hermes-agent + kubectl + helm (linux/amd64) |
|
||||||
| `build-and-push.sh` | builds & pushes the image to the Gitea registry |
|
| `build-and-push.sh` | builds & pushes the image to the Gitea registry |
|
||||||
| `deployment.yaml` | Deployment (1 replica, Recreate, pinned to amd64 NUC) + Service |
|
| `deployment.yaml` | Deployment (1 replica, Recreate, pinned to amd64 NUC) + Service |
|
||||||
| `ingress.yaml` | `platform-engineer.rogi.casa` → dashboard (TLS + basic auth) |
|
| `ingress.yaml` | `hermes.rogi.casa` → dashboard (TLS + basic auth) |
|
||||||
| `cron-seed.yaml` | one-shot Job that creates the Hermes cron schedule |
|
| `cron-seed.yaml` | one-shot Job that creates the Hermes cron schedule |
|
||||||
|
|
||||||
Also changed outside this directory:
|
Also changed outside this directory:
|
||||||
|
|||||||
@@ -9,18 +9,18 @@ data:
|
|||||||
config.yaml: |
|
config.yaml: |
|
||||||
model:
|
model:
|
||||||
provider: openai-api
|
provider: openai-api
|
||||||
default: qwen3.8-27b
|
default: qwen3.6
|
||||||
base_url: "http://litellm-service.litellm:80/v1"
|
base_url: "http://litellm-service.litellm:80/v1"
|
||||||
api_mode: chat_completions
|
api_mode: chat_completions
|
||||||
|
|
||||||
auxiliary:
|
auxiliary:
|
||||||
compression:
|
compression:
|
||||||
provider: openai-api
|
provider: openai-api
|
||||||
model: qwen3.8-27b # in-cluster llama.cpp model
|
model: qwen3.6
|
||||||
base_url: "http://litellm-service.litellm:80/v1"
|
base_url: "http://litellm-service.litellm:80/v1"
|
||||||
title_generation:
|
title_generation:
|
||||||
provider: openai-api
|
provider: openai-api
|
||||||
model: qwen3.8-27b # in-cluster llama.cpp model
|
model: qwen3.6
|
||||||
base_url: "http://litellm-service.litellm:80/v1"
|
base_url: "http://litellm-service.litellm:80/v1"
|
||||||
|
|
||||||
terminal:
|
terminal:
|
||||||
@@ -59,16 +59,6 @@ data:
|
|||||||
cron:
|
cron:
|
||||||
wrap_response: false
|
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:
|
||||||
memory_enabled: true
|
memory_enabled: true
|
||||||
user_profile_enabled: true
|
user_profile_enabled: true
|
||||||
@@ -85,14 +75,6 @@ data:
|
|||||||
to keep it healthy, fix small problems before they grow, and notify your
|
to keep it healthy, fix small problems before they grow, and notify your
|
||||||
owner (Roger) on Discord when something needs a human.
|
owner (Roger) on Discord when something needs a human.
|
||||||
|
|
||||||
## Response speed
|
|
||||||
|
|
||||||
Do not use extended thinking or produce a hidden reasoning process for this
|
|
||||||
agent. Answer directly and concisely, using only the reasoning needed to
|
|
||||||
safely complete the request. Never include a chain of thought or a
|
|
||||||
`reasoning_content` section in the response. This is a prompt-level
|
|
||||||
instruction; do not change the llama.cpp server configuration.
|
|
||||||
|
|
||||||
## The cluster you look after
|
## The cluster you look after
|
||||||
|
|
||||||
- **Nodes:** `raspberrypi` (control-plane, arm64, 4 GiB), `rpi2` (arm,
|
- **Nodes:** `raspberrypi` (control-plane, arm64, 4 GiB), `rpi2` (arm,
|
||||||
@@ -186,14 +168,7 @@ data:
|
|||||||
run (Hermes disables that). Do not try.
|
run (Hermes disables that). Do not try.
|
||||||
7. **Talk like an engineer.** Short, concrete, with resource names and
|
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.
|
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*
|
8. **Respect GitOps.** If an app is `OutOfSync`/`Degraded`, check whether a
|
||||||
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.
|
commit is stuck. Don't hand-edit resources — fix the source repo.
|
||||||
|
|
||||||
## How you reach Roger
|
## How you reach Roger
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# One-shot Job that replaces Hermes' built-in cron schedule with one daily check.
|
# One-shot Job that seeds Hermes' built-in cron schedule on first install.
|
||||||
# The seed is intentionally destructive for the known legacy jobs so existing
|
# Idempotent: skips job names that already exist.
|
||||||
# PVC state is simplified on the next ArgoCD sync.
|
|
||||||
#
|
#
|
||||||
# The prompt is written as plain-English instructions to avoid triggering
|
# Cron prompts are deliberately written as plain-English instructions (no inline
|
||||||
# Hermes' threat-pattern scanner. API endpoints and query examples are in
|
# curl commands) to avoid tripping Hermes' threat-pattern scanner, which blocks
|
||||||
# SOUL.md.
|
# cron prompts containing curl+auth-header patterns. The exact API endpoints and
|
||||||
|
# query examples are documented in the agent's SOUL.md instead.
|
||||||
---
|
---
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
kind: Job
|
kind: Job
|
||||||
@@ -48,25 +48,44 @@ spec:
|
|||||||
|
|
||||||
echo "Waiting for hermes pod to be Ready..."
|
echo "Waiting for hermes pod to be Ready..."
|
||||||
kubectl -n platform-engineer wait --for=condition=Ready pod -l app=hermes --timeout=300s || true
|
kubectl -n platform-engineer wait --for=condition=Ready pod -l app=hermes --timeout=300s || true
|
||||||
|
|
||||||
POD=$(kubectl -n platform-engineer get pod -l app=hermes -o jsonpath='{.items[0].metadata.name}')
|
POD=$(kubectl -n platform-engineer get pod -l app=hermes -o jsonpath='{.items[0].metadata.name}')
|
||||||
echo "Using pod: $POD"
|
echo "Using pod: $POD"
|
||||||
|
|
||||||
# Cron state lives on the PVC, so remove the old schedules as well
|
exists() { kubectl -n platform-engineer exec "$POD" -- hermes cron list 2>/dev/null | grep -qi " $1 "; }
|
||||||
# as creating the new one. This makes the migration idempotent.
|
|
||||||
for name in \
|
|
||||||
cluster-health-check node-health-check pod-phase-check pod-error-log-check \
|
|
||||||
pod-restart-loop pvc-pressure argocd-sync-health cert-expiry \
|
|
||||||
node-resource-drift daily-cluster-report daily-cluster-check; do
|
|
||||||
echo "removing legacy cron '$name' (if present)"
|
|
||||||
kubectl -n platform-engineer exec "$POD" -- hermes cron delete "$name" 2>/dev/null || true
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "creating daily cluster check"
|
create() {
|
||||||
# Hermes runs cron expressions in UTC. 04:00 UTC is 06:00 local time
|
name="$1"; schedule="$2"; deliver="$3"; prompt="$4"
|
||||||
# for this cluster (UTC+2).
|
if exists "$name"; then
|
||||||
kubectl -n platform-engineer exec "$POD" -- hermes cron create "0 4 * * *" \
|
echo "cron job '$name' already exists — skipping"
|
||||||
"Run the single daily platform health check for Roger using the HTTP APIs documented in your SOUL.md. Check: (1) every node is Ready and node CPU, memory, and root filesystem are below 90 percent; (2) PVCs have at least 15 percent free space; (3) pods not Running, excluding Completed/Succeeded jobs; (4) pod restart increases over the last 15 minutes and recent crash, error, panic, OOM, or backoff activity, using LogQL aggregation first and at most 10 sample lines for only an affected pod; (5) ArgoCD applications that are OutOfSync or Degraded; and (6) certificates expiring within 30 days. If everything is healthy, reply with exactly [SILENT]. Otherwise send one concise report in Catalan, grouped by issue, including namespace/resource names and relevant values. Keep it under 1800 characters. You may perform only clearly safe, idempotent GitOps remediations described in SOUL.md; otherwise report the proposed fix and wait for Roger." \
|
else
|
||||||
--name "daily-cluster-check" --deliver "discord"
|
echo "creating cron job '$name' ..."
|
||||||
|
kubectl -n platform-engineer exec "$POD" -- hermes cron create "$schedule" "$prompt" --name "$name" --deliver "$deliver"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Watchdog checks (silent unless something is wrong) ----
|
||||||
|
create "cluster-health-check" "every 6h" "discord" \
|
||||||
|
"Check cluster health using the HTTP APIs documented in your SOUL.md. Check: (1) any node that is NotReady, (2) any pod not in Running phase, (3) any recent error/panic/crashloop/backoff log lines in Loki across all namespaces in the last 20 minutes, (4) any ArgoCD app that is not Synced plus Healthy. If everything is healthy, reply with exactly [SILENT]. Otherwise give a concise per-resource summary of what is wrong."
|
||||||
|
|
||||||
|
create "pod-restart-loop" "every 1h" "discord" \
|
||||||
|
"Find pods with high restart rates using the Prometheus API documented in your SOUL.md. If any pod has more than 3 restarts in the last 15 minutes, fetch its logs from Loki to diagnose the cause. If the cause is clearly fixable via a manifest change such as bumping a memory limit, fixing a config value, or bumping the restartedAt annotation, make the edit in /workspace/k3s-cluster, commit and push, then trigger an ArgoCD sync via the API. Report what you did in one line. If not clearly fixable, post the log excerpt and proposed fix, and wait for Roger. If no high-restart pods, reply [SILENT]."
|
||||||
|
|
||||||
|
create "pvc-pressure" "every 1d" "discord" \
|
||||||
|
"Check storage health using the Prometheus API documented in your SOUL.md. Alert if any PVC has less than 15 percent free space, or if any node filesystem is over 85 percent full. If all healthy, reply [SILENT]."
|
||||||
|
|
||||||
|
create "argocd-sync-health" "every 6h" "discord" \
|
||||||
|
"Check ArgoCD app health using the API documented in your SOUL.md. If every app is Synced and Healthy, reply [SILENT]. Otherwise list the OutOfSync or Degraded apps with their status. If an app is OutOfSync and you believe a recent git push caused it, you may trigger a sync via the API. Do NOT hand-edit resources to fix them — fix the source repo."
|
||||||
|
|
||||||
|
create "cert-expiry" "0 9 * * *" "discord" \
|
||||||
|
"Check certificate expiry using the Prometheus API documented in your SOUL.md. Alert on any certificate expiring in under 21 days, with its name and namespace. If none, reply [SILENT]."
|
||||||
|
|
||||||
|
create "node-resource-drift" "every 1d" "discord" \
|
||||||
|
"Check node resources using the Prometheus API documented in your SOUL.md. Alert if any node is NotReady, or if any node has CPU over 90 percent or memory over 90 percent. Otherwise reply [SILENT]."
|
||||||
|
|
||||||
|
# ---- Daily report (always delivered) ----
|
||||||
|
create "daily-cluster-report" "0 8 * * *" "discord" \
|
||||||
|
"Produce a daily cluster report for Roger using the HTTP APIs documented in your SOUL.md. Include: (1) node count and Ready/NotReady status per node, (2) top 5 pods by CPU and by memory, (3) count of pods not Running grouped by namespace, (4) any ArgoCD apps that are OutOfSync or Degraded, (5) any certificates expiring within 30 days, (6) any recent Warning-level log lines from the last 24 hours. Keep it under 1800 chars. Always deliver (no [SILENT])."
|
||||||
|
|
||||||
echo "Done. Listing all cron jobs:"
|
echo "Done. Listing all cron jobs:"
|
||||||
kubectl -n platform-engineer exec "$POD" -- hermes cron list
|
kubectl -n platform-engineer exec "$POD" -- hermes cron list
|
||||||
|
|||||||
@@ -89,8 +89,7 @@ spec:
|
|||||||
DISCORD_ALLOW_ALL_USERS DISCORD_FREE_RESPONSE_CHANNELS \
|
DISCORD_ALLOW_ALL_USERS DISCORD_FREE_RESPONSE_CHANNELS \
|
||||||
GITEA_TOKEN GITEA_REPO_URL ARGOCD_API_TOKEN ARGOCD_SERVER \
|
GITEA_TOKEN GITEA_REPO_URL ARGOCD_API_TOKEN ARGOCD_SERVER \
|
||||||
HERMES_DASHBOARD HERMES_DASHBOARD_BASIC_AUTH_USERNAME \
|
HERMES_DASHBOARD HERMES_DASHBOARD_BASIC_AUTH_USERNAME \
|
||||||
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD HERMES_DASHBOARD_BASIC_AUTH_SECRET \
|
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD HERMES_DASHBOARD_BASIC_AUTH_SECRET; do
|
||||||
API_SERVER_KEY; do
|
|
||||||
eval "v=\${$k:-}"
|
eval "v=\${$k:-}"
|
||||||
[ -n "$v" ] && echo "$k=$v" >> /opt/data/.env
|
[ -n "$v" ] && echo "$k=$v" >> /opt/data/.env
|
||||||
done
|
done
|
||||||
@@ -126,30 +125,6 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: HERMES_HOME
|
- name: HERMES_HOME
|
||||||
value: /opt/data
|
value: /opt/data
|
||||||
# Hermes' file-write tool refuses any path outside HERMES_WRITE_SAFE_ROOT.
|
|
||||||
# When unset it defaults to HERMES_HOME (/opt/data), which blocks the
|
|
||||||
# agent's only GitOps remediation path (editing manifests under
|
|
||||||
# /workspace/k3s-cluster). Whitelist the whole filesystem — consistent
|
|
||||||
# with yolo:true, approvals.mode:off, and the agent having no k8s RBAC.
|
|
||||||
- name: HERMES_WRITE_SAFE_ROOT
|
|
||||||
value: "/"
|
|
||||||
# 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: "platform-engineer"
|
|
||||||
# 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>`.
|
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: data
|
- name: data
|
||||||
mountPath: /opt/data
|
mountPath: /opt/data
|
||||||
@@ -164,8 +139,7 @@ spec:
|
|||||||
cpu: "1000m"
|
cpu: "1000m"
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
# Probe the dashboard port (9119, always enabled via HERMES_DASHBOARD=1
|
# 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
|
# and binds 0.0.0.0). The gateway API on 8642 is off by default.
|
||||||
# API_SERVER_ENABLED=true above.
|
|
||||||
tcpSocket:
|
tcpSocket:
|
||||||
port: 9119
|
port: 9119
|
||||||
initialDelaySeconds: 90
|
initialDelaySeconds: 90
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ spec:
|
|||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
tls:
|
tls:
|
||||||
- hosts:
|
- hosts:
|
||||||
- platform-engineer.rogi.casa
|
- hermes.rogi.casa
|
||||||
secretName: platform-engineer-tls
|
secretName: hermes-tls
|
||||||
rules:
|
rules:
|
||||||
- host: platform-engineer.rogi.casa
|
- host: hermes.rogi.casa
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
|
|||||||
Reference in New Issue
Block a user