Compare commits

..
Author SHA1 Message Date
rock aa41e8c509 fix(llm-serving): run clef-candle from the fixed binary on the models PVC
The published :sm70 predates /v1/systemone and the GDN conv fix. candle-vllm
f3c00b2 carries both and is on main, but CI has not republished the tag, so the
in-image binary returns 404 on the route and would zero every linear-attention
layer if it did.

/mnt/models/bin/candle-vllm is that build. Verified serving: HTTP 200,
severity medium 0.5029, rollback 0.6581, confidence 2.6079, matching the
reference's decisions.

Temporary: retire once CI publishes a :sm70 from f3c00b2.
2026-11-05 10:19:33 +08:00
rock ef764e97c7 merge: resolve add/add on clef-candle.yaml, keep imagePullPolicy Always 2026-11-05 10:06:26 +08:00
rock ea8299c417 fix(llm-serving): pull clef-candle's mutable tag on every start
:sm70 is rebuilt by CI on every merge to candle-vllm main. The default
IfNotPresent means a node with a cached copy of that tag keeps running the old
binary, so a fix can be merged and deployed and still never reach the pod. The
symptom is a 404 on /v1/systemone while the image tag looks correct.

Always forces the pull.
2026-11-05 10:02:02 +08:00
rock 9617bbe814 hold clef-candle at replicas 0 until the image carries the GDN fix
candle-vllm PR #5 (the conv fix) could not be merged, so CI has not published
a :sm70 image containing it. Deploying clef against an older :sm70 fails
silently: the delta rule returns zero on all 24 linear-attention layers and
/v1/systemone still answers 200 with well-formed but wrong classifications.

Raise to 1 once a :sm70 build from the fix merge exists.
2026-11-05 09:41:28 +08:00
rock 10ce4f8109 feat(llm-serving): serve clef-flash on candle-vllm (#109)
Co-authored-by: rock <[email protected]>
2026-11-05 01:40:42 +00:00
rock 64a3f131b5 merge: resolve kustomization conflict, keep clef-candle and coder-14b-fp16 2026-11-05 09:40:36 +08:00
rock 32f1d50e5e feat(llm-serving): run coder as fp16 14B across two V100s (#108)
Co-authored-by: rock <[email protected]>
2026-11-05 01:40:22 +00:00
rock 3ab127e581 feat(llm-serving): serve clef-flash on candle-vllm
Adds clef-candle.yaml: clef-flash (hybrid Qwen3_5, 8 attention + 24 GDN
layers) on one V100 via candle-vllm, and repoints the clef-serve Service
selector at it. The Service name and ClusterIP do not move, so no client
changes.

Runs on one card because the GDN recurrent state is per-sequence and cannot
be sharded the way attention KV can. --kv-fraction 0.30 gives about 66.8K KV
tokens, which is ample for a single-shot classifier-style endpoint.

The image is candle-vllm:sm70, built by .gitea/workflows/candle-vllm-sm70.yaml
on every merge to main. It must be a build containing the GDN conv fix: older
images zero the key channel band in causal_conv1d_fwd, starve the delta rule
and make every linear-attention layer return zero. That failure is silent -
the response is well formed, just wrong.

Verified against the reference implementation: token ids 420/420 identical,
beta 8647.021 vs 8646.982, delta-rule and gated-norm absmax equal to six
decimals, all three head answers agree.
2026-11-05 09:35:47 +08:00
rock 7bdc8b2de2 fix(llm-serving): set runtimeClassName nvidia on the candle deployment
Without it the container runs under runc, libcuda.so is never injected and
candle-vllm panics in cudarc at startup. The pod reached Running but crashed
on every restart.

Verified live: pod Ready, coder-predictor endpoints populated, /v1/models
reports coder-14b-fp16 and a completion returns OK.
2026-11-05 09:22:48 +08:00
rock f6fca4705b feat(llm-serving): run coder as fp16 14B across two V100s
Replace the GPTQ W4A16 30B-A3B coder with Qwen2.5-Coder-14B-Instruct at
fp16 on TP=2.

The V100 has FP16 tensor cores and no INT4 ones, so the 4-bit path paid a
dequantisation pass on the SM. Profiling showed SM at 98% with the memory
controller at 19%, and the batch-1 step cost matched a full-model read.
Same harness, 16 streams: ITL 71.5 -> 39.7 ms, 215 -> 287 tok/s.

The deployment keeps the candle-vllm name and the app: candle-vllm label,
so coder-service.yaml and every client keep working unchanged.
2026-11-05 09:11:46 +08:00
rock db97159905 feat(llm-serving): run clef-serve so /v1/systemone answers
clef-serve was parked at 0 while candle-vllm took a card. worker-1 holds
four V100s, so the coder takes one and clef takes one, leaving two free
for a TP=2 candle run.

The gateway already routes the clef-flash model to clef-serve:80 on
/v1/systemone, so this single replica count is what turns that route on.
2026-11-04 18:59:56 +08:00
rock 67760720fc k8s(llm-serving): client-facing Service for candle-vllm coder
Creates coder-predictor selecting app=candle-vllm on ports 80 and 8080, so
clients keep one stable host name across the vLLM -> candle-vllm handoff and
only this selector changes from now on.

The retired KTML predictor requested two V100s and never reached Ready.
2026-11-04 18:59:56 +08:00
rock c5099e3a8f k8s(llm-serving): stop managing the coder InferenceService (#106)
Co-authored-by: rock <[email protected]>
2026-11-03 22:07:52 +00:00
rock 6b609eb4c2 revert(cloudflared): do not publish the kube-apiserver at k8s.riotpiao.com (#105)
Co-authored-by: rock <[email protected]>
2026-11-03 22:04:28 +00:00
rock cf3223499e feat(cloudflared): publish the kube-apiserver at k8s.riotpiao.com (#88)
Co-authored-by: rock <[email protected]>
2026-11-03 21:48:04 +00:00
rock c1477afc92 feat(llm-serving): poimen ingress, 256K coder, and a stable name for the candle-vllm handoff (#90)
Co-authored-by: rock <[email protected]>
2026-11-03 21:47:47 +00:00
rock 53e29d4476 chore(llm-serving): park clef-serve to free a V100 for candle-vllm (#91)
Co-authored-by: rock <[email protected]>
2026-11-03 21:47:24 +00:00
rock 712bb6589b feat(poimen): restrict the GraphQL server to the memory proxy (#104)
Co-authored-by: rock <[email protected]>
2026-11-03 21:38:34 +00:00
rock 9e004ccb9b feat(poimen): restrict the memory database to its own namespace (#103)
Co-authored-by: rock <[email protected]>
2026-11-03 21:34:04 +00:00
rock 4e684b2ee2 chore(kyverno): drop the two Application manifests from 00-substrate (#102)
Co-authored-by: rock <[email protected]>
2026-11-03 21:33:27 +00:00
rock 13ec9afd32 Merge pull request 'chore(kyverno): remove the never-deployed policy engine' (#100) from chore/remove-kyverno-future-work into main 2026-11-03 21:30:07 +00:00
rock d8bbf122f9 chore(kyverno): remove the never-deployed policy engine
Both ArgoCD Applications reported broken: 'kyverno' Unknown and
'kyverno-policies' OutOfSync/Missing. Neither the namespace nor a single
kyverno pod ever existed, so no policy was enforced and nothing is lost.

Kyverno is future work. Removing the two Applications and the bootstrap
directory keeps the root app from reporting two permanent failures.
2026-11-04 05:29:48 +08:00
rock c8e8b9532d Merge pull request 'fix(gotify): run gotify-db with 2 instances, not 3' (#99) from fix/gotify-db-two-instances into main 2026-11-03 21:24:18 +00:00
rock 1bfe08eeb8 fix(gotify): run gotify-db with 2 instances, not 3
The header of this file always said 2 instances while the spec said 3. The
third never ran: gotify-db-3's volume held a single replica that could not
start, because talos-cp-2 disk2 is at 2.9% free. That left the cluster
permanently in 'Creating a new replica' with one degraded Longhorn volume.

Two replicas on two different nodes is the redundancy that actually exists.
2026-11-04 05:24:09 +08:00
rock 58bf245920 feat(poimen): publish the memory API at api-poimen.riotpiao.com
poimen-memory is a ClusterIP service on :8080, so a local pi session had no
route to the API. This adds an edge Ingress on the same nginx class and the
same Cloudflare-terminated shape as the frontend ingress.

Auth stays ZITADEL JWT (MEM_AUTH_MODE=jwt): a request without a valid bearer
token gets 401, so exposing the route does not expose the data.

Requires one Cloudflare proxied A record: api-poimen.riotpiao.com -> 192.168.1.160
2026-11-04 04:52:11 +08:00
rock d62ac3d861 Merge pull request 'fix(coredns): declare the poimen rewrite in the source of truth' (#98) from chore/retire-comfyui-fully into main 2026-11-03 19:52:20 +00:00
rock 4c3b917e54 fix(coredns): declare the poimen rewrite in the source of truth
poimen.riotpiao.com resolved only because someone edited the live coredns
ConfigMap by hand. Neither terraform/files/coredns/Corefile nor the Talos
machine config carried the line, so the next Talos reconcile would have
deleted it and broken in-cluster resolution of a heavily used service.

Live and source now agree on both hostnames the retirement touched.
2026-11-04 03:51:34 +08:00
rock b45ae269fd Merge pull request 'chore(comfyui): retire the app for good' (#97) from chore/retire-comfyui-fully into main 2026-11-03 19:44:18 +00:00
rock b8f934806e chore(comfyui): retire the app for good
comfyui served nothing and its oauth2-proxy could not start: the Secret
carried only client-secret while the Deployment asked for client-id,
cookie-secret and client-secret. Rather than repair a service nobody used,
remove it from every layer.

Gone:
  - k8s/apps/comfyui/* and its ArgoCD Application
  - the cloudflared tunnel rule for comfyui.riotpiao.com
  - the CoreDNS rewrite in the terraform Corefile (the tracked source)
  - the Cloudflare DNS record (terraform tfvars)
  - the ZITADEL OIDC app and its two rotation tables
  - the KeyRotation object

Not gone by this commit: the comfyui namespace and its 50Gi PVC. ArgoCD does
not own either, so a person deletes both by hand after the prune. The local
cluster-config/talos-*.yaml copies are gitignored; they carry the same
removal and need an apply-config to reach the live CoreDNS.
2026-11-04 03:44:07 +08:00
rock af45e0f3ac fix(key-rotation): read MINIO_IDENTITY_OPENID_CLIENT_SECRET, not client-secret
The rotation wrote a key the tenant never read, so minio restarted into
CreateContainerConfigError. The key name must match the consumer's
secretKeyRef, not a house style.
2026-11-04 03:41:13 +08:00
rock 814f96040f Merge pull request 'fix(forgejo): keep 60Gi storage, revert CPU limit to 1000m' (#96) from fix/forgejo-storage-60gi-cpu-revert into main 2026-11-02 05:43:06 +00:00
32 changed files with 626 additions and 637 deletions
-2
View File
@@ -45,8 +45,6 @@ data:
service: https://igotify.riotpiao.com
- hostname: poimen.riotpiao.com
service: https://poimen.riotpiao.com
- hostname: comfyui.riotpiao.com
service: https://comfyui.riotpiao.com
- hostname: forgejo.riotpiao.com
service: https://forgejo.riotpiao.com
# ZITADEL. Without this rule the connector answers 404 for the hostname
-93
View File
@@ -1,93 +0,0 @@
# ComfyUI — GPU-accelerated image generation on worker-1.
# Uses 1x V100 32GB (sm70). Freed by scaling ornith 2→1.
apiVersion: apps/v1
kind: Deployment
metadata:
name: comfyui
namespace: comfyui
labels:
app: comfyui
spec:
# Retired on purpose. This Deployment held the 4th V100 on worker-1 while
# serving nothing: its oauth2-proxy roll-out has been stuck for 42h and
# failing 11621 times, because the Secret comfyui/comfyui-oauth2-proxy carries
# only 1 key while the pod requests client-id, client-secret and
# cookie-secret. Every request was therefore refused, so the card sat at 0%
# utilisation and blocked a GPU that llm-serving can use.
#
# Scaling to 0 is the whole change. The Deployment, the Service, the Ingress
# and the 50Gi comfyui-models PVC all stay, so nothing is lost and no data is
# touched. To bring it back: set replicas to 1 here, then fix the Secret to
# carry all three keys, otherwise the same stuck roll-out returns.
replicas: 0
strategy:
type: Recreate
selector:
matchLabels:
app: comfyui
template:
metadata:
labels:
app: comfyui
spec:
nodeSelector:
kubernetes.io/hostname: worker-1
runtimeClassName: nvidia
# k8s Service named 'comfyui' injects COMFYUI_PORT=tcp://... into pod env,
# which clobbers ai-dock's own COMFYUI_PORT variable (expects a port number).
# Disable service link injection to avoid the collision.
enableServiceLinks: false
containers:
- name: comfyui
image: ghcr.io/ai-dock/comfyui:v2-cuda-12.1.1-base-22.04
ports:
- containerPort: 8188
protocol: TCP
env:
- name: NVIDIA_VISIBLE_DEVICES
value: "all"
resources:
requests:
cpu: "4"
memory: 8Gi
nvidia.com/gpu: "1"
limits:
cpu: "8"
memory: 16Gi
nvidia.com/gpu: "1"
volumeMounts:
- mountPath: /workspace/ComfyUI/models
name: models
- mountPath: /workspace/ComfyUI/output
name: output
readinessProbe:
httpGet:
path: /
port: 8188
periodSeconds: 10
initialDelaySeconds: 30
startupProbe:
httpGet:
path: /
port: 8188
failureThreshold: 120
periodSeconds: 10
volumes:
- name: models
persistentVolumeClaim:
claimName: comfyui-models
- name: output
emptyDir: {}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: comfyui-models
namespace: comfyui
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 50Gi
-38
View File
@@ -1,38 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: comfyui
namespace: comfyui
annotations:
# No cert-manager annotation and no tls: block. This host is covered by
# the *.riotpiao.com wildcard, which nginx serves as its
# default-ssl-certificate (ingress-nginx/riotpiao-com-tls).
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
nginx.ingress.kubernetes.io/proxy-body-size: "0"
# WebSocket support for ComfyUI's live preview
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
nginx.ingress.kubernetes.io/upstream-hash-by: "$remote_addr"
# Single sign-on. ComfyUI has no auth of its own, so every request is handed to
# oauth2-proxy for an allow or deny decision.
nginx.ingress.kubernetes.io/auth-url: "http://comfyui-oauth2-proxy.comfyui.svc.cluster.local:4180/oauth2/auth"
nginx.ingress.kubernetes.io/auth-signin: "https://comfyui.riotpiao.com/oauth2/start?rd=$scheme://$host$request_uri"
nginx.ingress.kubernetes.io/auth-response-headers: "x-auth-request-user,x-auth-request-email"
# No configuration-snippet. The controller rejects that annotation as risky
# ("annotation group ConfigurationSnippet contains risky annotation"), and a
# rejected annotation drops the WHOLE ingress from nginx.conf: the host then
# answers 404 with no entry in the access log. ingress-nginx sets the Upgrade
# and Connection headers itself for a websocket upgrade.
spec:
ingressClassName: nginx
rules:
- host: comfyui.riotpiao.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: comfyui
port:
number: 80
-8
View File
@@ -1,8 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- oauth2-proxy.yaml
- ingress.yaml
-105
View File
@@ -1,105 +0,0 @@
# ComfyUI has no authentication of its own: comfyui.riotpiao.com is open to anyone
# who can resolve the host. It also has no OIDC support, so ZITADEL cannot be a
# login method inside it. oauth2-proxy is the front door instead: nginx asks it for
# a decision on every request and it answers from a ZITADEL session.
#
# No API client calls this host today, so the whole host is gated. If a client ever
# needs direct access, add a skip rule for the exact paths it uses rather than
# opening the whole host.
apiVersion: apps/v1
kind: Deployment
metadata:
name: comfyui-oauth2-proxy
namespace: comfyui
labels:
app: comfyui-oauth2-proxy
spec:
replicas: 1
selector:
matchLabels:
app: comfyui-oauth2-proxy
template:
metadata:
labels:
app: comfyui-oauth2-proxy
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65534
seccompProfile: {type: RuntimeDefault}
containers:
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
args:
- --provider=oidc
- --oidc-issuer-url=https://zitadel.riotpiao.com
- --email-domain=*
- --upstream=static://202
- --reverse-proxy=true
- --set-xauthrequest=true
- --cookie-secure=true
- --cookie-expire=12h
- --cookie-refresh=1h
- --skip-provider-button=true
- --redirect-url=https://comfyui.riotpiao.com/oauth2/callback
- --http-address=0.0.0.0:4180
env:
- name: OAUTH2_PROXY_CLIENT_ID
valueFrom: {secretKeyRef: {name: comfyui-oauth2-proxy, key: client-id}}
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom: {secretKeyRef: {name: comfyui-oauth2-proxy, key: client-secret}}
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom: {secretKeyRef: {name: comfyui-oauth2-proxy, key: cookie-secret}}
ports:
- {name: http, containerPort: 4180}
readinessProbe:
httpGet: {path: /ping, port: http}
initialDelaySeconds: 5
livenessProbe:
httpGet: {path: /ping, port: http}
initialDelaySeconds: 15
securityContext:
allowPrivilegeEscalation: false
capabilities: {drop: ["ALL"]}
resources:
requests: {cpu: 10m, memory: 32Mi}
limits: {cpu: 200m, memory: 128Mi}
---
apiVersion: v1
kind: Service
metadata:
name: comfyui-oauth2-proxy
namespace: comfyui
labels:
app: comfyui-oauth2-proxy
spec:
type: ClusterIP
selector:
app: comfyui-oauth2-proxy
ports:
- {name: http, port: 4180, targetPort: http}
---
# The login path must NOT be gated: a request to /oauth2/start that itself demands a
# login redirects to /oauth2/start forever. ingress-nginx applies the annotations of
# an ingress to the locations it defines, so a separate object without auth-url keeps
# this path open. The earlier failure of this shape was not the split — it was the
# configuration-snippet on the app ingress, which made the controller drop the whole
# object and left only this one alive.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: comfyui-oauth2
namespace: comfyui
spec:
ingressClassName: nginx
rules:
- host: comfyui.riotpiao.com
http:
paths:
- path: /oauth2
pathType: Prefix
backend:
service:
name: comfyui-oauth2-proxy
port:
number: 4180
-14
View File
@@ -1,14 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: comfyui
namespace: comfyui
labels:
app: comfyui
spec:
selector:
app: comfyui
ports:
- port: 80
targetPort: 8188
protocol: TCP
+5 -1
View File
@@ -8,7 +8,11 @@ metadata:
annotations:
argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true
spec:
instances: 3
# 2, not 3. The file's own header always said 2, and the third instance never
# existed as a running pod: its volume only ever held a replica that could not
# start, because talos-cp-2 disk2 has no free space. A third replica on a disk
# that cannot host it is not redundancy, it is a permanent degraded volume.
instances: 2
imageName: ghcr.io/cloudnative-pg/postgresql:16.2
bootstrap:
initdb:
+1 -1
View File
@@ -40,7 +40,7 @@ target is one root-of-trust file per thing that cannot rotate itself:
| controller bootstrap key | chicken and egg: it creates every other key |
| etcd defrag credentials | needed by a job that runs outside the controller |
Everything else — Grafana, Argo CD, Forgejo, MinIO, Paperless, Immich, ComfyUI,
Everything else — Grafana, Argo CD, Forgejo, MinIO, Paperless, Immich,
api-gw, Gotify — is a KeyRotation object, and its secret leaves git.
## Status
@@ -64,32 +64,6 @@ spec:
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: comfyui
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "397015743994003854" # app resource id, not the client id 397015744010781070
zitadelClientID: "397015744010781070"
secretName: comfyui-oauth2-proxy
secretNamespace: comfyui
secretKeys:
secret: client-secret
reload: restart
consumers:
- {kind: Deployment, name: comfyui-oauth2-proxy, namespace: comfyui}
verify:
introspect: true
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: minio
namespace: iam
@@ -107,7 +81,11 @@ spec:
secretName: minio-oidc
secretNamespace: storage
secretKeys:
secret: client-secret
# The key name here must match the consumer's secretKeyRef, not a house style.
# minio-tenant.yaml reads MINIO_IDENTITY_OPENID_CLIENT_SECRET. While this said
# `client-secret`, the tenant failed with CreateContainerConfigError on every
# restart and the rotation wrote a key nobody read.
secret: MINIO_IDENTITY_OPENID_CLIENT_SECRET
reload: hot
consumers:
- {kind: StatefulSet, name: minio-cluster-az-a, namespace: storage}
+92
View File
@@ -0,0 +1,92 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: clef-candle
namespace: llm-serving
labels:
app.kubernetes.io/part-of: llm-serving
app: clef-candle
spec:
replicas: 1
strategy:
# One replica holds one GPU, so Recreate keeps the rollout to a single
# weights load instead of needing a second card for a surge pod.
type: Recreate
selector:
matchLabels:
app: clef-candle
template:
metadata:
labels:
app.kubernetes.io/part-of: llm-serving
# The clef-serve Service now selects this label (see clef-serve.yaml),
# so clients keep addressing clef-serve:80 unchanged. The reference
# pytorch Deployment must stay at replicas 0 while this runs, or the
# Service splits traffic across two different implementations.
app: clef-candle
spec:
# Required. Without the nvidia runtime class libcuda.so is never injected
# and candle-vllm panics in cudarc at startup.
runtimeClassName: nvidia
containers:
- name: candle
# :sm70 is built by .gitea/workflows/candle-vllm-sm70.yaml on every
# merge to main. It MUST be a build that contains the GDN conv fix
# (candle-vllm PR: feat/clef-systemone). Older :sm70 images zero the
# key channel band in causal_conv1d_fwd, which starves the delta rule
# and makes every linear-attention layer return zero. The symptom is
# a well-formed /v1/systemone response with wrong answers, not an error.
image: forgejo.riotpiao.com/riotpiao-poimen/candle-vllm:sm70
# :sm70 is a moving tag. Default IfNotPresent keeps a cached copy and a
# rebuilt image never reaches the pod, so a merged fix looks like it did
# not land. Always forces the pull on every pod start.
imagePullPolicy: Always
# Runs the fixed binary from the models PVC, not the one in the image.
# The published :sm70 predates the /v1/systemone route and the GDN conv
# fix (candle-vllm f3c00b2 is merged but CI has not republished the tag),
# so the in-image binary 404s on the route. The PVC copy at
# /mnt/models/bin/candle-vllm contains both fixes.
#
# RETIRE THIS once CI publishes a :sm70 built from f3c00b2: change the
# command back to /usr/local/bin/candle-vllm and delete the PVC copy.
command: ["/mnt/models/bin/candle-vllm"]
# clef-flash is a hybrid Qwen3_5 model: 8 full-attention layers plus 24
# GDN layers that carry per-sequence recurrent state. It runs on ONE
# card because the state is not shardable the way attention KV is.
args:
- --w
- /mnt/models/clef-flash
- --d
- "0"
- --dtype
- f16
- --p
- "8080"
- --max-num-seqs
- "4"
# About 66.8K KV tokens. Clef is a single-shot classifier-style
# endpoint, so it does not need the coder's pool.
- --kv-fraction
- "0.30"
# Prefix caching stays on: clef requests repeat the schema block.
- --prefill-chunk-size
- "1024"
ports:
- containerPort: 8080
resources:
limits:
cpu: "8"
memory: 32Gi
nvidia.com/gpu: "1"
requests:
cpu: "4"
memory: 16Gi
nvidia.com/gpu: "1"
volumeMounts:
- name: models
mountPath: /mnt/models
readOnly: true
volumes:
- name: models
persistentVolumeClaim:
claimName: llm-models
+14 -1
View File
@@ -33,6 +33,16 @@ metadata:
app: clef-serve
app.kubernetes.io/part-of: llm-serving
spec:
# One card. worker-1 holds four V100s: candle-vllm takes one for the coder, and
# this pod takes one, so two stay free for a TP=2 candle run.
#
# Enabled 2026-11-04 to serve /v1/systemone, which the gateway already routes to
# this Service. It was parked at 0 while candle-vllm was brought up on the same
# cards, and it served no inference request between 2026-10-30T06:23:40Z and then.
#
# Cold start is about four minutes: the pod pip-installs on every boot because the
# torch base image cannot be pushed to the registry (Cloudflare answers 413 above
# 100 MB per layer). startupProbe allows for that.
replicas: 1
# Recreate, not RollingUpdate. Every GPU card is allocated, so a rolling update leaves
# the new pod Pending forever waiting for a card the old pod still holds.
@@ -117,8 +127,11 @@ metadata:
labels:
app.kubernetes.io/part-of: llm-serving
spec:
# Points at the candle-vllm implementation (clef-candle.yaml). Switching back
# to the reference pytorch server is this line plus that Deployment's replicas;
# no client changes, because the Service name and ClusterIP do not move.
selector:
app: clef-serve
app: clef-candle
ports:
- name: http
port: 80
+106
View File
@@ -0,0 +1,106 @@
# Qwen2.5-Coder-14B-Instruct, fp16, tensor-parallel across two V100s.
#
# Why this model and this precision. The previous workload was Qwen3-Coder-30B-A3B
# in GPTQ W4A16 on one V100. Profiling that deployment showed SM utilisation at
# 98% while the memory controller sat at 19%: the V100 has FP16 tensor cores and
# no INT4 ones, so every decode step ran a dequantisation pass on the SM. That is
# a hardware-generation mismatch, not a tunable. Sequence throughput matches the
# arithmetic: at batch 1 the step cost equalled a full-model read (17.6 ms against
# 15 GB / 900 GB/s = 16.7 ms) while the routed experts alone are ~1.5 GB.
#
# Measured on this hardware, same harness, same 16-stream load:
#
# GPTQ W4A16 30B-A3B, TP=1 : ITL 71.5 ms, 215 tok/s aggregate
# fp16 14B, TP=2 : ITL 39.7 ms, 287 tok/s aggregate
#
# The dense model degrades only 1.4x from 1 to 16 streams (27.9 -> 39.7 ms) while
# the MoE degrades 4.1x (17.6 -> 71.5 ms), because a dense model reads its weights
# once per step and amortises across the batch. The MoE pays expert weight traffic
# per sequence. Single-stream latency is the one case where the MoE still wins
# (17.6 ms against 27.9 ms), since it activates ~3B parameters per token.
#
# TP=2 notes that cost time to establish, so they are recorded here:
# - Sharding needs no code change. Every dimension divides by 2: attention heads
# 40 -> 20, KV heads 8 -> 4, intermediate_size 13824 -> 6912, vocab
# 152064 -> 76032. Verify this first for any replacement model.
# - TP=2 on a GPTQ checkpoint is what crashed (CUDA_ERROR_ILLEGAL_ADDRESS in
# forward()) on the previous model. The cause was quantised weight sharding
# and the g_idx guard in src/openai/models/linear.rs, not TP itself. A dense
# fp16 checkpoint has no g_idx, no packed weights and no scales, so it takes
# the plain column/row-parallel path and the crash does not reproduce.
# - These are V100-PCIE cards with no NVLink, so every layer's all-reduce
# crosses PCIe. It shows up in TTFT (275 ms at 1 stream, 1090 ms at 16)
# more than in ITL.
# - KV capacity is 11635 MB per rank, about 59K context tokens. The fp16
# weights consume the room the KV pool would otherwise use. This is well
# short of the 256K the previous model targeted, and it is the price of
# leaving the dequantisation path.
#
# Weights must be in the PVC as /mnt/models/coder-14b-fp16 (28 GB, 6 shards).
apiVersion: apps/v1
kind: Deployment
metadata:
name: candle-vllm
namespace: llm-serving
labels:
app.kubernetes.io/part-of: llm-serving
app: candle-vllm
spec:
replicas: 1
strategy:
# One replica holds both GPUs, so a rolling update would need a second pair.
# Recreate keeps the rollout to a single weights load.
type: Recreate
selector:
matchLabels:
app: candle-vllm
template:
metadata:
labels:
app.kubernetes.io/part-of: llm-serving
app: candle-vllm
spec:
# Required. Without this the container runs under runc, libcuda.so is never
# injected, and candle-vllm panics in cudarc at startup with
# "Unable to dynamically load the cuda shared library".
runtimeClassName: nvidia
containers:
- name: candle
image: forgejo.riotpiao.com/riotpiao-poimen/candle-vllm:sm70-gptq-slim
command: ["/usr/local/bin/candle-vllm"]
# fp16, not GPTQ, and two ranks, not one. See the header for the
# measurements and the TP=2 findings.
args:
- --w
- /mnt/models/coder-14b-fp16
- --d
- 0,1
- --dtype
- f16
- --p
- "8080"
- --max-num-seqs
- "32"
# 11.6 GB of KV per rank, about 59K context tokens. The fp16 weights
# leave no room for more.
- --kv-fraction
- "0.85"
ports:
- containerPort: 8080
resources:
limits:
cpu: "8"
memory: 36Gi
nvidia.com/gpu: "2"
requests:
cpu: "4"
memory: 12Gi
nvidia.com/gpu: "2"
volumeMounts:
- name: models
mountPath: /mnt/models
readOnly: true
volumes:
- name: models
persistentVolumeClaim:
claimName: llm-models
+40
View File
@@ -0,0 +1,40 @@
# Stable client-facing name for the coder chat model, deliberately decoupled
# from the process that serves it.
#
# poimen-memory addresses a Service, not a pod and not an InferenceService. The
# vLLM -> candle-vllm handoff therefore changes this file's selector and nothing
# else, so the client config never changes again and there is no window in which
# the host name is wrong.
#
# The previous KTML predictor requested two V100s and never reached Ready, so it
# held half the GPU pool while serving nothing. candle-vllm replaces it.
#
# Two ports on purpose:
# - 80 matches every other predictor Service in this namespace, and matches
# a client base URL that carries no port number.
# - 8080 is the container port. Publishing it directly avoids a port
# translation that is easy to get wrong in a curl.
#
# Do not delete and recreate this Service: a delete drops the ClusterIP, and a
# client that cached the name during the gap fails until it re-resolves.
apiVersion: v1
kind: Service
metadata:
name: coder-predictor
namespace: llm-serving
labels:
app.kubernetes.io/part-of: llm-serving
app: coder-predictor
spec:
type: ClusterIP
selector:
app: candle-vllm
ports:
- name: http
protocol: TCP
port: 80
targetPort: 8080
- name: http-8080
protocol: TCP
port: 8080
targetPort: 8080
+134
View File
@@ -0,0 +1,134 @@
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: coder
namespace: llm-serving
labels:
app.kubernetes.io/name: llm-coder
app.kubernetes.io/part-of: llm-serving
annotations:
serving.kserve.io/deploymentMode: RawDeployment
spec:
predictor:
minReplicas: 1
maxReplicas: 1
nodeSelector:
kubernetes.io/hostname: worker-1
# Two GPUs are free, so this rolls out beside `reasoning` instead of over it.
# Recreate still matters for the later swap: a rolling update needs a card the
# old pod holds and would sit Pending forever.
deploymentStrategy:
type: Recreate
containers:
- name: kserve-container
# Pinned to v0.11.0 on purpose. This is the last image measured to work on this
# node: torch 2.8.0+cu128, arch list sm_70..sm_120, driver 580.159.04.
# v0.15.0 was measured and FAILED here: arch list empty, and
# `cudaGetDeviceCount()` raised Error 803 (unsupported driver combination)
# before any model load. Do not raise this tag without re-running that probe.
image: vllm/vllm-openai:v0.11.0@sha256:014a95f21c9edf6abe0aea6b07353f96baa4ec291c427bb1176dc7c93a85845c
args:
# Qwen3-Coder-30B-A3B-Instruct: 30.5B total, 3.3B active. Architecture is
# Qwen3MoeForCausalLM, the same class as the Qwen3-30B-A3B already serving
# here, so it takes the proven code path on sm70.
#
# Qwen ships this model only as BF16 (61.1 GB) and FP8 (no FP8 on Volta).
# 4-bit GPTQ is the only form that fits one 32 GB card. This checkpoint is a
# community quant: ModelCloud maintains GPTQModel, and the repo is complete
# (config + tokenizer + 4 shards, 16.9 GB). Verified by loading, not by trust.
#
# Qwen3-Coder-Next (80B/3B, the hybrid-attention model) is NOT an option:
# its GatedDeltaNet kernels need compute capability 7.5+ and the `fla` module,
# and sm70 has neither. Measured, not assumed.
- --model=ModelCloud/Qwen3-Coder-30B-A3B-Instruct-GPTQMODEL-W4A16-A
# `reasoning` stays a served name so an existing client that sends
# model=reasoning keeps working after the DNS rename. vLLM takes the names
# as a space-separated list, so they go in as separate argv items.
- --served-model-name
- coder
- reasoning
- --quantization=gptq
- --dtype=float16
- --kv-cache-dtype=auto
# Native window is 262144 and rope_scaling is null, so 256K needs no YaRN.
# One card cannot hold it: measured KV pool on a single V100 was 132,672
# tokens. TP=2 halves the weights per card (7.8 GiB each), which frees room
# for the larger pool. Measured after boot: see the KV line in the log.
- --max-model-len=262144
- --tensor-parallel-size=2
- --pipeline-parallel-size=1
- --gpu-memory-utilization=0.90
# A single 262,144-token request consumes the whole pool, so keep few slots.
- --max-num-seqs=4
- --max-num-batched-tokens=8192
- --long-prefill-token-threshold=2048
- --enable-chunked-prefill
- --enable-prefix-caching
# Measured: with the default (custom all-reduce ON) the engine finished CUDA graph
# capture in 6s at 17:01:56 and then went silent for 14+ minutes, emitting only
# "No available shared memory broadcast block found in 60 seconds". The front-end
# never received the ready message. vLLM's custom all-reduce registers its own
# CUDA graph addresses and deadlocks on this Volta/PCIe pair. NCCL is the fallback
# and is correct here, because TP=2 across two PCIe V100s gains nothing from the
# custom kernel anyway.
- --disable-custom-all-reduce
- --reasoning-parser=qwen3
- --enable-auto-tool-choice
- --tool-call-parser=hermes
- --host=0.0.0.0
- --port=8080
env:
# Required on this hardware, not tuning:
# - flashinfer's check_cuda_arch raises on compute capability 7.0,
# so the sampler must stay off
# - FlashInfer and FlashAttention-2 need sm75+, so Triton serves attention
- name: VLLM_USE_FLASHINFER_SAMPLER
value: "0"
- name: VLLM_ATTENTION_BACKEND
value: TRITON_ATTN
- name: HF_HOME
value: /mnt/models
ports:
- containerPort: 8080
protocol: TCP
readinessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 10
startupProbe:
httpGet:
path: /health
port: 8080
# TP=2 at 256K needs ~10 min before the API binds: 22s weights, 80s torch.compile,
# 85s KV profiling, then graph capture. 120 x 15s = 30 min of headroom.
failureThreshold: 120
periodSeconds: 15
resources:
# worker-1 exposes only 63,495 MiB of allocatable memory, so a 24Gi request
# never schedules. Two vLLM workers keep the weights on the GPU and need
# little host RAM, so the request stays small and the limit allows burst.
requests:
cpu: "8"
memory: 12Gi
nvidia.com/gpu: "2"
limits:
cpu: "16"
memory: 40Gi
nvidia.com/gpu: "2"
volumeMounts:
- mountPath: /mnt/models
name: models
- mountPath: /dev/shm
name: shm
runtimeClassName: nvidia
volumes:
- name: models
persistentVolumeClaim:
claimName: llm-models
- name: shm
emptyDir:
medium: Memory
# NCCL all-reduce over TP=2 moves through /dev/shm. The 64Mi default is
# too small and shows up as an init hang or a bus error.
sizeLimit: 8Gi
+17 -1
View File
@@ -13,8 +13,24 @@ resources:
# clef-flash runs through clef-serve.yaml, not through an SGLang runtime. SGLang needs
# v0.5.22+, published only on CUDA 13, and CUDA 13 dropped Volta kernels. The model
# instead runs the implementation it ships (joint_schema_model.py) in fp16 on one V100.
# coder-14b-fp16.yaml owns the `candle-vllm` Deployment name and the
# `app: candle-vllm` label that coder-service.yaml selects. It must be the only
# workload with that label, or the Service splits traffic across two models.
#
# fp16 on two V100s, not GPTQ on one: measured 39.7 ms ITL and 287 tok/s at 16
# streams against 71.5 ms and 215 tok/s for the 4-bit MoE. The V100 has FP16
# tensor cores and no INT4 ones, so 4-bit pays dequantisation on the SM while
# the memory controller idles at 19%.
# clef-candle.yaml serves clef-flash on candle-vllm (hybrid Qwen3_5, one GPU)
# and owns the clef-candle label that clef-serve.yaml's Service now selects.
# It must not sync against an image built before the GDN conv fix.
- clef-candle.yaml
- coder-14b-fp16.yaml
- clef-serve.yaml
- reasoning.yaml
# Stable client-facing name for the chat model. poimen-memory points at this Service,
# so the vLLM -> candle-vllm handoff is a selector edit here and no client change.
- coder-service.yaml
- reasoning-long-service.yaml
# ornith is retired: its InferenceService still reports Ready=True while running zero
# replicas, because desired equals available at 0. Its manifest was never committed,
# so it is left unmanaged and is not listed here. Delete the CR when convenient.
+15
View File
@@ -66,6 +66,21 @@ spec:
port: 80
- protocol: TCP
port: 8000
# Allow from poimen namespace (poimen-memory ingest + query).
# The client-side egress rule is the other half and must stay in step:
# NetworkPolicy poimen/poimen-memory-llm-egress permits port 8080 to pods
# labelled part-of=llm-serving, which is the same selector used here. This
# policy had no matching ingress rule at all, so the packet was allowed out
# and dropped here. The client then saw a bare timeout and the predictor log
# stayed empty, which is why this failure looked like a hung inference engine
# for 34 days.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: poimen
ports:
- protocol: TCP
port: 8080
# Allow intra-namespace (pod-to-pod within llm-serving)
- from:
- podSelector:
+2 -1
View File
@@ -1,5 +1,6 @@
# qwen2.5:3b-instruct on CPU (talos-cp-2, 144GB RAM, 24 cores).
# Moved off GPU to free a V100 for ComfyUI. Latency ~10x slower
# Moved off GPU to free a V100, first for ComfyUI and later for llm-serving.
# Latency ~10x slower
# than GPU but sufficient for lightweight tasks (summarization,
# classification, quick answers).
apiVersion: apps/v1
@@ -0,0 +1,43 @@
# Stable client-facing name for the chat model, deliberately decoupled from the
# process that currently serves it.
#
# poimen-memory addresses this Service, not a pod and not an InferenceService. That
# means the vLLM -> candle-vllm handoff changes only this file's selector. The client
# config never changes again, so the ingest cutover has no window in which the host
# name is wrong.
#
# Two ports on purpose:
# - 80 matches every other predictor Service in this namespace, and matches
# poimen-memory's LLM_CHAT_BASE, which carries no port number.
# - 8080 is the container port. It is also the only port llm-serving-ingress and
# poimen-memory-llm-egress permit, and the port the handoff readiness check
# uses. Publishing it directly avoids a port translation that is easy to get
# wrong in a curl.
#
# The selector names the vLLM reasoning pod today. To hand off, replace the three
# selector labels with candle-vllm's pod labels and nothing else. Do not delete and
# recreate this Service: a delete drops the ClusterIP, and a client that cached the
# name during the gap will fail until it re-resolves.
apiVersion: v1
kind: Service
metadata:
name: reasoning-long-predictor
namespace: llm-serving
labels:
app.kubernetes.io/part-of: llm-serving
app: reasoning-long-predictor
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: llm-reasoning
component: predictor
serving.kserve.io/inferenceservice: reasoning
ports:
- name: http
protocol: TCP
port: 80
targetPort: 8080
- name: http-8080
protocol: TCP
port: 8080
targetPort: 8080
+22 -1
View File
@@ -30,7 +30,28 @@ spec:
# Measured on this cluster, one V100-32GB, 64k context: 15.55 GiB of weights,
# 137k tokens of KV cache, 2.09x concurrency at 65536 tokens per request.
- --model=Qwen/Qwen3-30B-A3B-GPTQ-Int4
- --served-model-name=reasoning
# Two served names, one pod, on purpose. `reasoning-long` is the name the goal map
# uses for this model, and the name qwen-finetune wiring targets. It is NOT a
# separate InferenceService: this model already occupies the only free card, and a
# second pod on the same weights cannot schedule (4 of 4 GPUs allocated: coder 2,
# this 1, clef-serve 1). Serving both names from one process costs nothing and
# keeps the two callers working without a second weights copy.
#
# `reasoning` is deliberately still served here. poimen-memory pins
# LLM_CHAT_BASE=reasoning-predictor, so dropping the name here breaks ingest
# immediately. coder-predictor also serves `reasoning`; that ambiguity is known
# and documented in the PR. Resolve it by moving the client host, not by
# removing the name here.
#
# The two names are separate list entries, NOT one comma-joined value. This
# image's parser returns ['reasoning,reasoning-long'] for the comma form, i.e. a
# single model whose name contains a comma, and api_server.py then iterates
# args.served_model_name to build the model list. Verified against vLLM 0.11.0:
# FlexibleArgumentParser().parse_args([... "--served-model-name", "a", "b"])
# -> ['a', 'b']
- --served-model-name
- reasoning
- reasoning-long
- --quantization=gptq
- --dtype=float16
- --kv-cache-dtype=auto
+38
View File
@@ -0,0 +1,38 @@
---
# Public entry point for the poimen-memory HTTP API.
#
# The API was reachable only inside the cluster (ClusterIP :8080), so a local pi
# session had no way to use it. This Ingress exposes the same handler set the
# in-cluster clients use: /mcp, /memory/*, /graphql, /health, /ready.
#
# Cloudflare terminates TLS in front of the nginx ingress, which is why this rule
# carries no `tls:` section — the same shape as ingress.yaml next to it.
#
# Auth: poimen-memory validates ZITADEL JWTs (MEM_AUTH_MODE=jwt). A caller
# without a valid bearer token gets 401, so exposing the route does not expose
# the data.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: poimen-api
namespace: poimen
annotations:
# MCP calls and ingest can be slow; the provider may stream. Match the
# frontend ingress so a long tool call is not cut at 60s.
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
spec:
ingressClassName: nginx
rules:
- host: api-poimen.riotpiao.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: poimen-memory
port:
number: 8080
@@ -6,3 +6,5 @@ kind: Kustomization
# riotpiao-poimen/poimen-frontend.git k8s/ — each repo owns its own k8s.
resources:
- ingress.yaml
# The memory API. Without it a local pi session has no route to :8080.
- api-ingress.yaml
-51
View File
@@ -159,54 +159,3 @@ spec:
automated:
prune: true
selfHeal: true
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: kyverno
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: homelab
source:
repoURL: https://kyverno.github.io/kyverno/
chart: kyverno
targetRevision: "1.14.0"
helm:
valueFiles:
- $values/k8s/bootstrap/kyverno/kyverno-values.yaml
sources:
- repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
ref: values
destination:
server: https://kubernetes.default.svc
namespace: kyverno
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: kyverno-policies
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
path: k8s/bootstrap/kyverno
destination:
server: https://kubernetes.default.svc
namespace: kyverno
syncPolicy:
automated:
prune: true
selfHeal: true
-32
View File
@@ -1,32 +0,0 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: comfyui
namespace: argocd
labels:
app.kubernetes.io/name: comfyui
app.kubernetes.io/component: image-generation
annotations:
argocd.argoproj.io/sync-wave: "8"
spec:
project: homelab
revisionHistoryLimit: 3
source:
repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
path: k8s/apps/comfyui
destination:
server: https://kubernetes.default.svc
namespace: comfyui
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
retry:
limit: 5
backoff:
duration: 5s
factor: 2
maxDuration: 3m
-6
View File
@@ -1,6 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: kyverno
resources:
- policies.yaml
-42
View File
@@ -1,42 +0,0 @@
# Kyverno: Policy engine for Kubernetes image scanning, Pod security, and admission control
# Scan all images, enforce baseline Pod Security Standard, prevent privilege escalation
replicaCount: 1
image:
registry: ghcr.io
repository: kyverno/kyverno
tag: "v1.14.0"
config:
# Webhook timeout for policy evaluation. Increase if scanning takes longer.
webhookTimeoutSeconds: 30
# Failure policy: fail-open (audit/log) vs fail-closed (reject on error)
failurePolicy: fail
# Resource limits for webhook
webhookAnnotations:
rules: "allow"
# Pod security via Kyverno instead of Pod Security Policies (deprecated)
# Enforces baseline restrictions cluster-wide, with exceptions for privileged namespaces
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
rbac:
create: true
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
# Webhook configuration
webhook:
timeoutSeconds: 30
# Failure policy: "Fail" (reject on error) or "Ignore" (audit-only)
# Set to "Ignore" for initial testing, then change to "Fail"
failurePolicy: ignore
-204
View File
@@ -1,204 +0,0 @@
# Kyverno ClusterPolicies: Image scanning, Pod security, and admission control
---
# Policy 1: Require non-root containers
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-non-root
namespace: kyverno
spec:
validationFailureAction: audit # audit first, then change to enforce
rules:
- name: check-runAsNonRoot
match:
any:
- resources:
kinds:
- Pod
selector:
matchLabels:
pod-security.kubernetes.io/enforce: "!privileged"
validate:
message: "Container must not run as root"
pattern:
spec:
containers:
- securityContext:
runAsNonRoot: true
---
# Policy 2: Drop all Linux capabilities, add only required ones
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-dropped-caps
namespace: kyverno
spec:
validationFailureAction: audit
rules:
- name: drop-all-capabilities
match:
any:
- resources:
kinds:
- Pod
selector:
matchLabels:
pod-security.kubernetes.io/enforce: "!privileged"
validate:
message: "All Linux capabilities must be dropped"
pattern:
spec:
containers:
- securityContext:
capabilities:
drop:
- ALL
---
# Policy 3: Require image tags (no 'latest')
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: disallow-latest-tag
namespace: kyverno
spec:
validationFailureAction: audit # Change to enforce after testing
rules:
- name: disallow-latest
match:
any:
- resources:
kinds:
- Pod
- Deployment
- StatefulSet
- DaemonSet
- Job
validate:
message: "Image tag 'latest' is not allowed. Use explicit version tags."
pattern:
spec:
=(template):
spec:
containers:
- image: "!*:latest"
=(initContainers):
- image: "!*:latest"
---
# Policy 4: Restrict images to trusted registries
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: restrict-registries
namespace: kyverno
spec:
validationFailureAction: audit
rules:
- name: trusted-registries
match:
any:
- resources:
kinds:
- Pod
- Deployment
- StatefulSet
- DaemonSet
- Job
selector:
matchLabels:
pod-security.kubernetes.io/enforce: "!privileged"
validate:
message: "Images must come from trusted registries: docker.io, ghcr.io, quay.io, k8s.gcr.io, registry.k8s.io, or internal forgejo registry"
pattern:
spec:
=(template):
spec:
containers:
- image: "docker.io/* | ghcr.io/* | quay.io/* | k8s.gcr.io/* | registry.k8s.io/* | forgejo.riotpiao.com/* | *"
---
# Policy 5: Require read-only root filesystem (audit only, exceptions for apps that need writes)
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-readonly-filesystem
namespace: kyverno
spec:
validationFailureAction: audit
rules:
- name: check-readOnlyRootFilesystem
match:
any:
- resources:
kinds:
- Pod
selector:
matchLabels:
pod-security.kubernetes.io/enforce: "!privileged"
validate:
message: "Root filesystem should be read-only for defense-in-depth"
pattern:
spec:
containers:
- securityContext:
readOnlyRootFilesystem: true
---
# Policy 6: Require resource requests and limits (prevent resource starvation)
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-resource-limits
namespace: kyverno
spec:
validationFailureAction: audit
rules:
- name: check-resources
match:
any:
- resources:
kinds:
- Pod
- Deployment
- StatefulSet
- DaemonSet
excludeResources:
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: "kyverno|kube-system|kube-node-lease"
validate:
message: "CPU and memory requests and limits are required"
pattern:
spec:
=(template):
spec:
containers:
- resources:
requests:
memory: "?*"
cpu: "?*"
limits:
memory: "?*"
cpu: "?*"
---
# Policy 7: Require securityContext on all containers
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-security-context
namespace: kyverno
spec:
validationFailureAction: audit
rules:
- name: check-securityContext
match:
any:
- resources:
kinds:
- Pod
selector:
matchLabels:
pod-security.kubernetes.io/enforce: "!privileged"
validate:
message: "securityContext must be defined"
pattern:
spec:
containers:
- securityContext: {}
+28
View File
@@ -0,0 +1,28 @@
---
# Keep the GraphQL server reachable only from the one service that proxies it.
#
# poimen-graphql is already off the internet: its Service is ClusterIP on 8090 and
# no Ingress names it. What it lacked is in-cluster isolation — a ClusterIP answers
# every pod in the cluster.
#
# The only caller is poimen-memory, whose /graphql route forwards the request body
# to MEM_GQL_PORT (8090). No other workload references this service.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: poimen-graphql-ingress
namespace: poimen
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: poimen-graphql
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: poimen-memory
ports:
- protocol: TCP
port: 8090
+4
View File
@@ -6,6 +6,10 @@ kind: Kustomization
# deployments (wave 0) are applied.
resources:
- memory-db.yaml
# ClusterIP alone is not isolation: every pod in the cluster can reach it.
- memory-db-netpol.yaml
# Only poimen-memory's /graphql proxy calls 8090.
- graphql-netpol.yaml
- poimen-sandbox-namespace.yaml
- poimen-sandbox-quota.yaml
- pi-session-netpol.yaml
+42
View File
@@ -0,0 +1,42 @@
---
# Keep the memory database reachable only from inside its own namespace.
#
# What this does NOT change: the database was already unreachable from the
# internet. `memory-db-rw`, `memory-db-ro` and `memory-db-r` are ClusterIP
# services, no Ingress backs them, and no public DNS name resolves to them.
#
# What it fixes: a ClusterIP service is reachable by EVERY pod in the cluster,
# because Kubernetes has no implicit isolation. Cilium enforces NetworkPolicy, so
# stating the allowed sources is what turns "not on the internet" into "not
# reachable from another tenant's namespace".
#
# Two sources, both required:
# - the poimen namespace, which holds every client: poimen-memory, poimen-graphql,
# poimen-worker, poimen-workflows and the migration Job.
# - cnpg-system, where the CloudNativePG operator lives. It connects to the
# instance for switchover and failover, so a narrower rule breaks recovery.
#
# No other port is opened. The operator's metrics endpoint stays closed because no
# PodMonitor for this cluster exists, so nothing scrapes it.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: memory-db-ingress
namespace: poimen
spec:
podSelector:
matchLabels:
cnpg.io/cluster: memory-db
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: poimen
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: cnpg-system
ports:
- protocol: TCP
port: 5432
-1
View File
@@ -32,7 +32,6 @@ ISSUER = "https://zitadel.riotpiao.com"
APPS = [
("grafana", "grafana", "grafana-oidc", "logging", "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET", "deploy", "grafana"),
("argocd", "argocd", "oidc-secret", "argocd", "client-secret", "deploy", "argocd-server"),
("comfyui", "comfyui", "comfyui-oauth2-proxy", "comfyui", "client-secret", "deploy", "comfyui-oauth2-proxy"),
("minio", "minio", "minio-oidc", "storage", "MINIO_IDENTITY_OPENID_CLIENT_SECRET", "sts", "minio-cluster-az-a"),
("paperless", "paperless", "paperless-oidc", "paperless", "client-secret", "deploy", "paperless"),
("immich", "immich", "immich-oidc", "immich", "client-secret", "deploy", "immich-server"),
+12 -6
View File
@@ -128,11 +128,6 @@ OIDC_APPS = {
"https://forgejo.riotpiao.com/user/oauth2/zitadel/callback",
"http://localhost:3000/user/oauth2/authentik/callback",
"https://forgejo.riotpiao.com/user/oauth2/authentik/callback"]},
# ComfyUI has no native OIDC. oauth2-proxy is its front door and this app is
# the proxy's client, so the callback path belongs to the proxy.
"comfyui": {**_WEB, "redirect_uris": [
"http://localhost:4180/oauth2/callback",
"https://comfyui.riotpiao.com/oauth2/callback"]},
"grafana": {**_WEB, "redirect_uris": [
"http://localhost:3000/login/generic_oauth",
"https://grafana.riotpiao.com/login/generic_oauth"]},
@@ -143,7 +138,11 @@ OIDC_APPS = {
"http://localhost:8000", "http://localhost:18000"]},
"minio": {**_WEB, "redirect_uris": [
"http://localhost:9000/auth/sso/oauth2/code",
"https://minio.riotpiao.com/auth/sso/oauth2/code"]},
"https://minio.riotpiao.com/auth/sso/oauth2/code",
# The MinIO Tenant sets MINIO_IDENTITY_OPENID_REDIRECT_URI to this value,
# which is NOT the generic /auth/sso/oauth2/code path. Without it ZITADEL
# rejects the authorization request at the redirect step.
"https://minio.riotpiao.com/oauth_callback"]},
"paperless": {**_WEB, "redirect_uris": [
"http://localhost:8000/accounts/oidc/zitadel/login/callback/",
"https://paperless.riotpiao.com/accounts/oidc/zitadel/login/callback/",
@@ -359,6 +358,13 @@ MACHINE_CALLERS = {
"description": "poimen calls api-gateway inference",
"roles": ["llm:inference", "svc-caller"],
},
# paperless-ai is a vendor image: it cannot sign an assertion, so a sidecar mints
# a token with this account and writes it to the file the image already reads.
"paperless-llm": {
"name": "Paperless AI LLM caller",
"description": "paperless-ai calls api-gateway inference through the token vendor",
"roles": ["llm:inference", "svc-caller"],
},
}
+4 -2
View File
@@ -36,8 +36,10 @@
rewrite name paperless.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name img.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name api.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name comfy.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name comfyui.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
# poimen ran only in the live ConfigMap, never in this file or the Talos
# machine config. The next Talos reconcile would have deleted it and broken
# in-cluster resolution. Declared here so the source of truth carries it.
rewrite name poimen.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name gotify.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name pi.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name igotify.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local