refactor(k8s): Reorganize into 5-layer structure with production kustomizations

This commit is contained in:
Story Crater Bot
2026-08-18 15:08:01 -07:00
parent 563f720d09
commit 0f30d77288
214 changed files with 405 additions and 885 deletions
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: claude-terminal
description: Persistent Claude CLI terminal running in tmux with web access via gotty
type: application
version: 1.0.0
appVersion: "1.0"
+31
View File
@@ -0,0 +1,31 @@
FROM --platform=linux/amd64 ubuntu:24.04
RUN apt-get update && apt-get install -y \
tmux \
curl \
git \
build-essential \
nodejs \
npm \
bash \
&& rm -rf /var/lib/apt/lists/*
# Install gotty (web terminal access)
RUN curl -sL https://github.com/sorenisanerd/gotty/releases/download/v1.5.0/gotty_linux_amd64.tar.gz | \
tar xz -C /usr/local/bin && chmod +x /usr/local/bin/gotty
# Install Claude CLI
RUN npm install -g claude-code-cli 2>&1 || echo "Note: Claude CLI will be available after NPM package is published"
WORKDIR /root
# Create persistent storage dir
RUN mkdir -p /root/.claude /root/.config /root/.cache
# Entrypoint: start tmux session and gotty
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]
+49
View File
@@ -0,0 +1,49 @@
# Claude Terminal — Persistent Remote Dev Environment
Runs Claude CLI in a persistent tmux session with web-based terminal access via gotty.
## Building the Image
Build for `linux/amd64`:
```bash
cd homelab
docker buildx build --platform linux/amd64 \
-t forgejo.riotpiao.homelab.com/rock/claude-terminal:latest \
-f k8s/dev-tools/Dockerfile \
k8s/dev-tools
# Log in to Forgejo registry
docker login forgejo.riotpiao.homelab.com \
--username ci-bot \
--password "$(talos get cluster/iam/agents/ci-bot --key token)"
# Push
docker push forgejo.riotpiao.homelab.com/rock/claude-terminal:latest
```
Or use the provided build script:
```bash
./k8s/dev-tools/build.sh
```
## Deployment
Update `values.yaml` if needed, then deploy via helmfile:
```bash
helmfile apply -l name=claude-terminal
```
Access the terminal at: **https://claude.riotpiao.homelab.com**
## Persistent Storage
- All Claude configuration stored in `/root/.claude` (persistent PVC, 10Gi Longhorn)
- Survives pod restarts and node reboots
- Accessible immediately after reconnecting
## SSH Access (Optional)
To add SSH access, extend the Dockerfile to include openssh-server and mount the PVC as home directory.
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
set -euo pipefail
REGISTRY="forgejo.riotpiao.homelab.com"
IMAGE_NAME="rock/claude-terminal"
TAG="latest"
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
echo "🔨 Building Claude Terminal image for linux/amd64..."
docker buildx build --platform linux/amd64 \
-t "${FULL_IMAGE}" \
-f Dockerfile \
. || { echo "❌ Build failed"; exit 1; }
echo "🔓 Logging in to Forgejo registry..."
REGISTRY_TOKEN=$(talos get cluster/iam/agents/ci-bot --key token)
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" \
--username ci-bot \
--password-stdin || { echo "❌ Login failed"; exit 1; }
echo "📤 Pushing image to registry..."
docker push "${FULL_IMAGE}" || { echo "❌ Push failed"; exit 1; }
echo "✅ Successfully pushed ${FULL_IMAGE}"
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
set -e
# Start tmux server in background
tmux new-session -d -s claude -c /root "bash"
# Give tmux a moment to stabilize
sleep 1
# Start gotty serving the tmux session
# -w: allow write (make terminal interactive)
# -p 8080: listen on port 8080
exec gotty -p 8080 -w tmux attach-session -t claude
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: dev-tools
resources: []
# Helm chart deployed via ArgoCD Helm source
@@ -0,0 +1,49 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "claude-terminal.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "claude-terminal.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "claude-terminal.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "claude-terminal.labels" -}}
helm.sh/chart: {{ include "claude-terminal.chart" . }}
{{ include "claude-terminal.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "claude-terminal.selectorLabels" -}}
app.kubernetes.io/name: {{ include "claude-terminal.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
@@ -0,0 +1,57 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "claude-terminal.fullname" . }}
labels:
{{- include "claude-terminal.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "claude-terminal.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "claude-terminal.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: claude-terminal
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 8080
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: claude-storage
mountPath: {{ .Values.persistence.mountPath }}
volumes:
- name: claude-storage
persistentVolumeClaim:
claimName: {{ include "claude-terminal.fullname" . }}-pvc
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -0,0 +1,41 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "claude-terminal.fullname" . }}
labels:
{{- include "claude-terminal.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "claude-terminal.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,15 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "claude-terminal.fullname" . }}-pvc
labels:
{{- include "claude-terminal.labels" . | nindent 4 }}
spec:
accessModes:
- ReadWriteOnce
storageClassName: {{ .Values.persistence.storageClass }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "claude-terminal.fullname" . }}
labels:
{{- include "claude-terminal.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "claude-terminal.selectorLabels" . | nindent 4 }}
+45
View File
@@ -0,0 +1,45 @@
replicaCount: 1
image:
repository: localhost:5000/claude-terminal
pullPolicy: IfNotPresent
tag: latest
service:
type: ClusterIP
port: 8080
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: homelab-ca
hosts:
- host: claude.riotpiao.homelab.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: claude-terminal-tls
hosts:
- claude.riotpiao.homelab.com
persistence:
enabled: true
storageClass: longhorn
size: 10Gi
mountPath: /root/.claude
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
nodeSelector: {}
tolerations: []
affinity: {}
@@ -0,0 +1,40 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: duckdns-updater
namespace: kube-system
spec:
replicas: 1
selector:
matchLabels:
app: duckdns-updater
template:
metadata:
labels:
app: duckdns-updater
spec:
containers:
- name: updater
image: curlimages/curl:latest
command:
- sh
- -c
- |
while true; do
curl -fsS "https://www.duckdns.org/update?domains=riotpiao&token=${DUCKDNS_TOKEN}&ip="
sleep 300
done
env:
- name: DUCKDNS_TOKEN
valueFrom:
secretKeyRef:
name: duckdns-token
key: token
resources:
requests:
cpu: 5m
memory: 16Mi
limits:
cpu: 50m
memory: 32Mi
restartPolicy: Always
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: duckdns
resources: []
# DuckDNS deployed via Helm chart or CronJob
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: forge
resources:
- pki/
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources: []
# PKI configuration, not K8s manifests
@@ -0,0 +1,95 @@
# k8s/forge/runner-gc-cronjob.yaml
# Garbage-collects the forgejo-runner's DinD layer cache (runner-dind PVC,
# 30Gi). Every CI build/pull only adds images and build-cache layers — there
# is no automatic pruning, so without this the PVC fills up and breaks builds.
#
# Runs `docker image prune` / `docker builder prune` inside the live dind
# container via `kubectl exec`, rather than a sidecar in the runner pod itself,
# so it can run on its own schedule independent of runner restarts.
apiVersion: v1
kind: ServiceAccount
metadata:
name: runner-gc
namespace: cicd
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: runner-gc
namespace: cicd
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["pods/exec"]
verbs: ["create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: runner-gc
namespace: cicd
subjects:
- kind: ServiceAccount
name: runner-gc
namespace: cicd
roleRef:
kind: Role
name: runner-gc
apiGroup: rbac.authorization.k8s.io
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: forgejo-runner-image-gc
namespace: cicd
spec:
schedule: "0 3 * * *" # daily 03:00
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 600
template:
spec:
serviceAccountName: runner-gc
restartPolicy: Never
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: gc
image: alpine/k8s:1.31.0
command:
- sh
- -c
- |
set -e
POD=$(kubectl -n cicd get pod -l app=forgejo-runner -o jsonpath='{.items[0].metadata.name}')
if [ -z "$POD" ]; then
echo "no forgejo-runner pod found, skipping"
exit 0
fi
echo "before:"
kubectl -n cicd exec "$POD" -c dind -- df -h /var/lib/docker
echo "pruning images unused for >72h on $POD"
kubectl -n cicd exec "$POD" -c dind -- docker image prune -af --filter "until=72h"
echo "pruning build cache unused for >72h on $POD"
kubectl -n cicd exec "$POD" -c dind -- docker builder prune -af --filter "until=72h"
echo "after:"
kubectl -n cicd exec "$POD" -c dind -- df -h /var/lib/docker
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
+438
View File
@@ -0,0 +1,438 @@
# k8s/forge/runner.yaml
# Forgejo Actions runner with Docker-in-Docker (DinD) sidecar.
# Phase 3.2 of talos_version_control.html.
#
# Pod layout (two containers, one pod):
# register initContainer — registers with Forgejo once; skips if .runner exists
# runner main container — long-running daemon that polls Forgejo for jobs
# dind sidecar — Docker daemon the runner talks to via mTLS on tcp://localhost:2376
#
# Prerequisites (Phase 3.1):
# TOKEN=$(kubectl -n cicd exec deploy/forgejo-gitea -- \
# gitea actions generate-runner-token 2>/dev/null | tr -d '\r\n')
# kubectl -n cicd create secret generic runner-token --from-literal=token="$TOKEN"
# # CA must come from cert-manager's homelab-ca-secret (the org-wide CA that
# # signs the live ingress cert), NOT k8s/forge/pki/ca.crt — that file is a
# # stale CA from before the "unified certificate" migration.
# kubectl get secret homelab-ca-secret -n cert-manager -o jsonpath='{.data.tls\.crt}' \
# | base64 -d | kubectl -n cicd create secret generic homelab-ca --from-file=ca.crt=/dev/stdin
#
# CA trust for ephemeral job containers (Phase 3.3):
# The homelab-ca secret above only reaches the runner/register/dind containers.
# It does NOT reach the per-job containers DinD spawns (e.g. golangci-lint,
# node:22-bookworm) — those are fresh sibling containers with their own trust
# store. Without this, any git/curl/apk call to forgejo.riotpiao.homelab.com
# from inside a job fails with "SSL certificate problem: unable to get local
# issuer certificate". Build a merged bundle (public roots + homelab CA) and
# bind-mount it over /etc/ssl/certs/ca-certificates.crt in every job
# container via forgejo-runner's config.yaml container.options.
#
# IMPORTANT: source the CA from the live cluster secret, NOT from
# k8s/forge/pki/ca.crt — that repo file is a stale CA from before the
# "unified certificate" migration (different key, fails verification
# against the cert actually served by forgejo.riotpiao.homelab.com). The
# org-wide CA that signs the live ingress cert lives in
# cert-manager/homelab-ca-secret, and cicd/homelab-ca above is already
# synced from it.
# docker run --rm docker:27-dind cat /etc/ssl/certs/ca-certificates.crt > /tmp/ca-bundle.crt
# kubectl -n cicd get secret homelab-ca -o jsonpath='{.data.ca\.crt}' | base64 -d >> /tmp/ca-bundle.crt
# kubectl -n cicd create secret generic ca-bundle --from-file=ca-certificates.crt=/tmp/ca-bundle.crt
# Re-run this whenever the homelab CA rotates (see talos-forge-trust.yaml).
#
# Apply:
# kubectl apply -f k8s/forge/runner.yaml
# kubectl -n cicd rollout status deploy/forgejo-runner
# kubectl -n cicd logs deploy/forgejo-runner -c runner -f
# # expect: "runner: daemon started" / "connected to Forgejo"
# ── PVCs ──────────────────────────────────────────────────────────────────────
# runner-reg — persists the .runner registration file so the runner doesn't
# re-register on every pod restart (token is one-use-per-registration)
# runner-dind — persists the Docker layer cache across pod restarts; keeps
# rebuilds fast — images don't need to be re-pulled every time
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: runner-reg
namespace: cicd
spec:
accessModes: [ReadWriteOnce]
storageClassName: longhorn
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: runner-dind
namespace: cicd
spec:
accessModes: [ReadWriteOnce]
storageClassName: longhorn
resources:
requests:
storage: 30Gi
---
# ── DinD TLS certs, issued by the homelab's unified CA ───────────────────────
# DinD's own entrypoint (dockerd-entrypoint.sh) self-generates a throwaway CA
# + server/client cert pair on every container start if none is supplied. Its
# server cert's SAN list only ever covers "docker", the pod hostname, and
# "localhost" - so anything reaching it via a stable Service DNS name (added
# below for story-crater-backend's release.yaml to build/push images) fails
# TLS hostname verification, even though the handshake itself succeeds.
#
# Fix: supply our own server+client cert pair, both issued by the same
# ClusterIssuer (homelab-ca) that already signs the live ingress cert, so
# they share one trust root. dockerd-entrypoint.sh skips its own generation
# step entirely once it finds $DOCKER_TLS_CERTDIR/server/{ca,cert,key}.pem
# already present and no CA private key alongside them (confirmed by reading
# the script directly: `kubectl exec -n cicd <pod> -c dind -- cat
# /usr/local/bin/dockerd-entrypoint.sh`) - exactly the "bring your own CA"
# path it's designed for.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: forgejo-runner-dind-server
namespace: cicd
spec:
secretName: forgejo-runner-dind-server-tls
issuerRef:
name: homelab-ca
kind: ClusterIssuer
commonName: docker:dind server
dnsNames:
- forgejo-runner-dind.cicd.svc.cluster.local
- forgejo-runner-dind.cicd.svc
- forgejo-runner-dind
- docker
- localhost
usages:
- server auth
- digital signature
- key encipherment
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: forgejo-runner-dind-client
namespace: cicd
spec:
secretName: forgejo-runner-dind-client-tls
issuerRef:
name: homelab-ca
kind: ClusterIssuer
commonName: docker:dind client
usages:
- client auth
- digital signature
- key encipherment
---
# Stable address for the dind sidecar's docker API (2376, mTLS) - lets
# CI workflows (e.g. story-crater-backend's release.yaml) build/push images
# by reaching this runner's own already-working outer dind directly, instead
# of the per-job `services:` sidecar pattern (confirmed broken: act-runner
# never registers a DNS alias for service containers - job container's
# /etc/hosts has no entry for it, `docker info` fails with a DNS lookup
# error, not a TLS/connection error).
apiVersion: v1
kind: Service
metadata:
name: forgejo-runner-dind
namespace: cicd
spec:
selector:
app: forgejo-runner
ports:
- port: 2376
targetPort: 2376
---
# ── Runner config ─────────────────────────────────────────────────────────────
# container.options is appended to every `docker run` DinD issues for a job
# container, so this is what actually gets the merged CA bundle (ca-bundle
# secret, see header comment) trusted inside golangci-lint, node, etc. - and
# now also what propagates the homelab-CA-signed client cert (above) into
# job containers that need to talk back to dind themselves (e.g. building
# and pushing images).
# Source paths resolve against the dind container's filesystem (it's the
# daemon creating these containers), so both are mounted into dind below.
apiVersion: v1
kind: ConfigMap
metadata:
name: forgejo-runner-config
namespace: cicd
data:
config.yaml: |
container:
options: -v /etc/forgejo-ca/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro -v /docker-certs/client:/docker-certs/client:ro
# Without this, sanitizeConfig() in forgejo/act silently drops any bind
# mount whose source isn't allowlisted here — including ones injected
# via container.options above, not just workflow-declared volumes.
valid_volumes:
- /etc/forgejo-ca/ca-certificates.crt
- /docker-certs/client
---
# ── Deployment ────────────────────────────────────────────────────────────────
apiVersion: apps/v1
kind: Deployment
metadata:
name: forgejo-runner
namespace: cicd
spec:
replicas: 1
# RWO PVCs mean only one pod can mount them at a time.
# Recreate ensures the old pod fully terminates before the new one starts.
strategy:
type: Recreate
selector:
matchLabels:
app: forgejo-runner
template:
metadata:
labels:
app: forgejo-runner
spec:
# runner/register containers run as uid 1000 (image default); fsGroup
# makes kubelet chown+chmod the Longhorn PVC's group to 1000 with
# write access, otherwise writes to /data (.runner config) fail with
# "permission denied" since the volume is root:root 755 by default.
securityContext:
fsGroup: 1000
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
initContainers:
# Registers the runner with Forgejo exactly once.
# test -f /data/.runner makes it idempotent — re-registration would
# consume the one-time token and break the runner.
- name: register
image: code.forgejo.org/forgejo/runner:6
command: ["sh", "-c"]
args:
- |
test -f /data/.runner && echo "already registered, skipping" && exit 0
forgejo-runner register --no-interactive \
--instance https://forgejo.riotpiao.homelab.com \
--token "$RUNNER_TOKEN" \
--name talos-runner \
--labels "docker:docker://node:22-bookworm"
env:
- name: RUNNER_TOKEN
valueFrom:
secretKeyRef:
name: runner-token
key: token
volumeMounts:
- name: runner-data
mountPath: /data
# CA cert so the register call can verify Forgejo's TLS cert
- name: homelab-ca
mountPath: /etc/ssl/certs/homelab-ca.pem
subPath: ca.crt
workingDir: /data
containers:
# ── Runner daemon ────────────────────────────────────────────────────
# Polls Forgejo for pending jobs and executes them inside DinD.
# The `until docker info` loop waits for the DinD sidecar to finish
# its TLS setup before starting the daemon — without this the runner
# starts before Docker is ready and immediately errors out.
- name: runner
image: code.forgejo.org/forgejo/runner:6
command: ["sh", "-c"]
args:
- |
until nc -z localhost 2376 >/dev/null 2>&1; do
echo "waiting for docker daemon..."; sleep 2
done
forgejo-runner daemon --config /data/config.yaml
workingDir: /data
env:
# Connect to the DinD sidecar via mTLS on localhost
- name: DOCKER_HOST
value: tcp://localhost:2376
- name: DOCKER_TLS_VERIFY
value: "1"
- name: DOCKER_CERT_PATH
value: /docker-certs/client
volumeMounts:
- name: runner-data
mountPath: /data
- name: docker-certs
mountPath: /docker-certs
- name: homelab-ca
mountPath: /etc/ssl/certs/homelab-ca.pem
subPath: ca.crt
# forgejo-runner's container.options, read from this file, is what
# propagates the CA bundle into per-job containers (see ca-bundle
# secret + dind mount below)
- name: runner-config
mountPath: /data/config.yaml
subPath: config.yaml
# Homelab-CA-signed client cert (overlays whatever's in the
# docker-certs emptyDir at this subpath) - matches the server
# cert dind now presents, see Certificates above.
- name: dind-client-tls
mountPath: /docker-certs/client
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "2"
memory: 4Gi
# ── DinD sidecar ──────────────────────────────────────────────────────
# Full Docker daemon running inside the pod.
# privileged: true is required for DinD — the cicd namespace is labelled
# pod-security.kubernetes.io/enforce=privileged to allow this.
# DOCKER_TLS_CERTDIR causes DinD to generate mTLS certs in /docker-certs
# on startup; the runner reads the client certs from /docker-certs/client.
# runner-dind PVC mounts /var/lib/docker so the layer cache persists
# across pod restarts.
- name: dind
image: docker:27-dind
securityContext:
privileged: true
env:
- name: DOCKER_TLS_CERTDIR
value: /docker-certs
volumeMounts:
- name: docker-certs
mountPath: /docker-certs
- name: dind-storage
mountPath: /var/lib/docker
# Trust the homelab CA so DinD can pull from Forgejo's OCI registry
- name: homelab-ca
mountPath: /etc/ssl/certs/homelab-ca.pem
subPath: ca.crt
# Merged CA bundle (public roots + homelab CA), bind-mounted from
# here into every job container by container.options above —
# this path is resolved against dind's filesystem since dind is
# the daemon actually creating those containers.
- name: ca-bundle
mountPath: /etc/forgejo-ca/ca-certificates.crt
subPath: ca-certificates.crt
# Homelab-CA-signed server+client certs (see Certificates above),
# overlaying the matching subpaths of the docker-certs emptyDir.
# dockerd-entrypoint.sh detects these and skips its own
# self-signed generation entirely (no CA private key is supplied
# alongside them, so it can't regenerate even if it wanted to).
- name: dind-server-tls
mountPath: /docker-certs/server
- name: dind-client-tls
mountPath: /docker-certs/client
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "2"
memory: 4Gi
volumes:
- name: runner-data
persistentVolumeClaim:
claimName: runner-reg
- name: dind-storage
persistentVolumeClaim:
claimName: runner-dind
# emptyDir parent mount for /docker-certs - the server/ and client/
# subpaths are now overlaid by the homelab-CA-signed dind-server-tls/
# dind-client-tls secret mounts below (dockerd-entrypoint.sh no
# longer self-generates once it finds those present). This emptyDir
# just needs to exist as the parent directory; nothing writes
# directly to it anymore.
- name: docker-certs
emptyDir: {}
- name: homelab-ca
secret:
secretName: homelab-ca
- name: ca-bundle
secret:
secretName: ca-bundle
- name: runner-config
configMap:
name: forgejo-runner-config
# cert-manager issues these as tls.crt/tls.key/ca.crt - remapped to
# the ca.pem/cert.pem/key.pem filenames dockerd-entrypoint.sh expects
# under $DOCKER_TLS_CERTDIR/{server,client}/.
- name: dind-server-tls
secret:
secretName: forgejo-runner-dind-server-tls
items:
- key: ca.crt
path: ca.pem
- key: tls.crt
path: cert.pem
- key: tls.key
path: key.pem
- name: dind-client-tls
secret:
secretName: forgejo-runner-dind-client-tls
items:
- key: ca.crt
path: ca.pem
- key: tls.crt
path: cert.pem
- key: tls.key
path: key.pem
---
# ── NetworkPolicy ─────────────────────────────────────────────────────────────
# Restrict runner egress: it may only reach Forgejo (cicd ns), CoreDNS, and
# the public internet for action dependencies and base images.
# LAN (192.168.1.0/24) and the pod network (10.244.0.0/16) are blocked to
# prevent a compromised CI job from pivoting into the cluster or LAN.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: runner-egress
namespace: cicd
spec:
podSelector:
matchLabels:
app: forgejo-runner
policyTypes: [Egress]
egress:
# Forgejo (same namespace — git push, OCI registry push/pull)
- to:
- podSelector: {}
# ingress-nginx (the runner talks to Forgejo via its public hostname,
# https://forgejo.riotpiao.homelab.com, which resolves to the ingress
# controller's ClusterIP — a different namespace on the pod network)
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- protocol: TCP
port: 443
- protocol: TCP
port: 80
# CoreDNS (DNS resolution for action deps and Forgejo hostname)
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Public internet for action dependencies and base images
# LAN and pod network are explicitly excluded
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 192.168.1.0/24
- 10.244.0.0/16
+18
View File
@@ -0,0 +1,18 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
metadata:
name: applications
# Layer 5: Applications — user services, workloads
# Dependencies: all previous layers (bootstrap, platform, security)
# Order: Applied fifth
resources:
- temporal/
- sqs/
- llm/
- portainer/
- forge/
- shadowsocks/
- dev-tools/
- duckdns/
+299
View File
@@ -0,0 +1,299 @@
# Ollama LLM Inference Service
CPU-only LLM inference server on talos-cp-1. Single model hot-loaded (DeepSeek-R1:70b), 42GB, 70Gi memory limit.
## Quick Start
### Access via port-forward
```bash
kubectl -n llm port-forward svc/ollama 11434:11434
curl http://localhost:11434/api/tags
```
### Debug pod (in-cluster)
```bash
kubectl run debug --rm -it -n llm --image=curlimages/curl \
--labels="app.kubernetes.io/role=llm-debug" \
--serviceaccount=llm-worker -- sh
# Inside pod
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
curl -H "Authorization: Bearer $TOKEN" \
http://ollama.llm.svc.cluster.local:11434/api/tags
```
## Architecture
| Component | Value |
|-----------|-------|
| Service | ClusterIP `ollama.llm.svc.cluster.local:11434` |
| Namespace | `llm` |
| Node | talos-cp-1 (pinned via nodeAffinity) |
| Memory request | 50Gi |
| Memory limit | 70Gi |
| Storage | 115Gi PVC (Longhorn) |
| Model | `deepseek-r1:70b` (~42GB) |
| Max loaded | 1 model |
| Parallelism | 1 request at a time |
## API Endpoints
### List models
```bash
curl http://ollama.llm.svc.cluster.local:11434/api/tags
```
Response:
```json
{
"models": [
{"name": "deepseek-r1:70b", "size": 42000000000, ...}
]
}
```
### Generate (non-streaming)
```bash
curl -X POST http://ollama.llm.svc.cluster.local:11434/api/generate \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-r1:70b",
"prompt": "Why is the sky blue?",
"stream": false
}'
```
### Pull model
```bash
curl -X POST http://ollama.llm.svc.cluster.local:11434/api/pull \
-H "Content-Type: application/json" \
-d '{"name": "deepseek-r1:70b", "stream": false}'
```
## Operations
### Check pod status
```bash
kubectl -n llm get pod -l app.kubernetes.io/name=ollama
kubectl -n llm describe pod -l app.kubernetes.io/name=ollama
```
### View logs
```bash
kubectl -n llm logs deployment/ollama -f
```
### Monitor download progress (bootstrap)
```bash
kubectl -n llm logs -f job/bootstrap-models -c model-download
```
### Restart deployment
```bash
kubectl -n llm rollout restart deployment/ollama
```
## Storage
- **PVC:** `ollama-models-cache`, 115Gi, Longhorn StorageClass
- **Mount:** `/root/.ollama/models` (Ollama model cache)
- **Lifecycle:** RWO (Read-Write-Once), tied to talos-cp-1
### Resize PVC
⚠️ PVCs can only expand, not shrink. Edit values.yaml and redeploy:
```yaml
pvc:
size: 120Gi # increase only
```
```bash
vsource .env && helmfile -f helmfile.yaml.gotmpl -l name=ollama apply
```
## Networking
### NetworkPolicy
- Default-deny ingress on Ollama pods
- Allow from pods labeled `app.kubernetes.io/name: llm-worker` (port 11434)
- Allow from pods labeled `app.kubernetes.io/role: llm-debug` (port 11434)
View policy:
```bash
kubectl -n llm get networkpolicy ollama
```
Test access from external pod (should fail):
```bash
kubectl run test --rm -it --image=curlimages/curl -- \
curl http://ollama.llm.svc.cluster.local:11434/
# Connection timeout (correct)
```
Test access from debug pod (should succeed):
```bash
kubectl -n llm logs job/bootstrap-models # verify bootstrap completed
# Then run debug pod as shown above
```
## Configuration
### Helm values (`k8s/llm/charts/ollama/values.yaml`)
```yaml
resources:
requests:
cpu: 8
memory: 50Gi
limits:
cpu: 16
memory: 70Gi
env:
OLLAMA_MAX_LOADED_MODELS: "1"
OLLAMA_NUM_PARALLEL: "1"
OLLAMA_MAX_QUEUE: "32"
OLLAMA_KEEP_ALIVE: "-1"
OLLAMA_HOST: "0.0.0.0:11434"
preloadJob:
enabled: true
hotModels:
- deepseek-r1:70b
```
### Environment variables
| Variable | Value | Purpose |
|----------|-------|---------|
| `OLLAMA_MODELS` | `/root/.ollama/models` | Model cache dir |
| `OLLAMA_MAX_LOADED_MODELS` | `1` | Max concurrent models in RAM |
| `OLLAMA_NUM_PARALLEL` | `1` | Parallel request threads |
| `OLLAMA_MAX_QUEUE` | `32` | Request queue depth |
| `OLLAMA_KEEP_ALIVE` | `-1` | Keep model resident (never unload) |
| `OLLAMA_HOST` | `0.0.0.0:11434` | Bind address |
Tune `OLLAMA_NUM_PARALLEL` based on CPU cores. Current: 1 (conservative, CPU bottleneck).
## Model Management
### Current model
- **Name:** `deepseek-r1:70b`
- **Size:** ~42GB
- **Quantization:** Default Ollama quant
- **Status:** Downloaded during pod init via bootstrap job
### Change model
1. Edit `values.yaml`:
```yaml
preloadJob:
hotModels:
- deepseek-r1:32b # or any available model
```
2. Redeploy:
```bash
kubectl -n llm delete job bootstrap-models --ignore-not-found
vsource .env && helmfile -f helmfile.yaml.gotmpl -l name=ollama apply
```
3. Monitor:
```bash
kubectl -n llm logs -f job/bootstrap-models -c model-download
```
### Available models
Ollama registry: https://ollama.com/library
Examples:
- `deepseek-r1:70b` (reasoning, 42GB)
- `deepseek-r1:32b` (faster, 20GB)
- `llama3.1:70b` (general, 41GB)
- `mistral:large` (26GB)
## Troubleshooting
### Pod stuck in `ContainerCreating`
```bash
kubectl -n llm describe pod -l app.kubernetes.io/name=ollama
# Check Events section for PVC/image pull issues
```
### Bootstrap job failing
```bash
kubectl -n llm logs job/bootstrap-models -c model-download --tail=50
# Common: model not found in registry, disk full, network timeout
```
### Model pull timeout
```bash
# Increase pod timeout (edit deployment directly)
kubectl -n llm edit deployment ollama
# Change readinessProbe.initialDelaySeconds, livenessProbe.periodSeconds
```
### Out of memory
Model size exceeds limit. Reduce `memory.limits` or choose smaller model.
```bash
kubectl top pod -n llm # check actual usage
```
### Cannot connect from other pods
Verify NetworkPolicy:
```bash
kubectl -n llm get networkpolicy
kubectl -n llm describe networkpolicy ollama
# Add pod label: app.kubernetes.io/name: llm-worker or app.kubernetes.io/role: llm-debug
```
## Secrets
Ollama pod receives MinIO credentials via Secret `ollama-minio` (created by helmfile presync):
```bash
kubectl -n llm get secret ollama-minio -o jsonpath='{.data}' | jq
```
Keys: `endpoint`, `bucket`, `access_key`, `secret_key`
Used by bootstrap job to upload model blobs to MinIO (future: auto-backup).
## Metrics & Observability
### Prometheus scrape (if enabled)
ServiceMonitor: Not yet configured (see `k8s/monitoring/dashboards/services/`)
Metrics to add:
- `ollama_requests_total` (counter)
- `ollama_request_duration_seconds` (histogram)
- `ollama_loaded_models` (gauge)
### Logs
Pod logs via kubectl. No log aggregation to Loki yet.
```bash
kubectl -n llm logs deployment/ollama -f --timestamps
```
## Cleanup
### Delete Ollama completely
```bash
vsource .env && helmfile -f helmfile.yaml.gotmpl -l name=ollama destroy
# Keeps PVC (data safety). To delete: kubectl -n llm delete pvc ollama-models-cache
```
### Delete just the model cache (keep deployment)
```bash
kubectl -n llm delete pvc ollama-models-cache
# Recreate: kubectl -n llm patch deployment ollama -p '{"spec":{"template":{"metadata":{"annotations":{"restart":"now"}}}}}'
```
## See Also
- Helmfile: `helmfile.yaml.gotmpl` (llm release block)
- Chart: `k8s/llm/charts/ollama/`
- Namespace: `llm`
- Bootstrap: `k8s/llm/bootstrap-models-job.yaml` (manual preload fallback)
@@ -0,0 +1,6 @@
apiVersion: v2
name: ollama
description: CPU-only Ollama LLM server with MinIO model registry
type: application
version: 0.1.0
appVersion: "latest"
@@ -0,0 +1,126 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ollama
namespace: llm
labels:
app.kubernetes.io/name: ollama
app.kubernetes.io/part-of: llm
spec:
replicas: {{ .Values.replicaCount }}
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: ollama
template:
metadata:
labels:
app.kubernetes.io/name: ollama
app.kubernetes.io/part-of: llm
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values:
- {{ .Values.nodeAffinity.zone }}
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Equal
value: ""
effect: NoSchedule
initContainers:
- name: preload-model
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command:
- sh
- -c
- |
set -e
echo "Starting Ollama server for model preload..."
ollama serve &
OLLAMA_PID=$!
sleep 10
{{- range .Values.preloadJob.hotModels }}
echo "Preloading {{ . }}..."
if ollama ls | grep -q "{{ . }}"; then
echo "✓ {{ . }} already cached"
else
ollama pull {{ . }}
fi
{{- end }}
echo "Model preload complete"
kill $OLLAMA_PID || true
wait $OLLAMA_PID 2>/dev/null || true
volumeMounts:
- name: models-cache
mountPath: /root/.ollama/models
env:
- name: OLLAMA_HOST
value: "127.0.0.1:11434"
containers:
- name: ollama
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: 11434
name: http
env:
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
value: "{{ $value }}"
{{- end }}
- name: OLLAMA_MODELS_MINIO_ENDPOINT
valueFrom:
secretKeyRef:
name: ollama-minio
key: endpoint
- name: OLLAMA_MODELS_MINIO_BUCKET
valueFrom:
secretKeyRef:
name: ollama-minio
key: bucket
- name: OLLAMA_MODELS_MINIO_ACCESS_KEY
valueFrom:
secretKeyRef:
name: ollama-minio
key: access_key
- name: OLLAMA_MODELS_MINIO_SECRET_KEY
valueFrom:
secretKeyRef:
name: ollama-minio
key: secret_key
resources:
requests:
cpu: {{ .Values.resources.requests.cpu }}
memory: {{ .Values.resources.requests.memory }}
limits:
cpu: {{ .Values.resources.limits.cpu }}
memory: {{ .Values.resources.limits.memory }}
livenessProbe:
httpGet:
path: /
port: 11434
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 11434
initialDelaySeconds: 10
periodSeconds: 5
volumeMounts:
- name: models-cache
mountPath: /root/.ollama/models
volumes:
- name: models-cache
persistentVolumeClaim:
claimName: ollama-models-cache
@@ -0,0 +1,28 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: ollama-access
namespace: llm
labels:
app.kubernetes.io/name: ollama
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: ollama
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: llm-worker
ports:
- protocol: TCP
port: 11434
- from:
- podSelector:
matchLabels:
app.kubernetes.io/role: llm-debug
ports:
- protocol: TCP
port: 11434
@@ -0,0 +1,92 @@
{{- if .Values.preloadJob.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
name: ollama-preload
namespace: llm
labels:
app.kubernetes.io/name: ollama-preload
spec:
backoffLimit: 3
template:
spec:
serviceAccountName: default
restartPolicy: Never
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values:
- az-a
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Equal
effect: NoSchedule
initContainers:
- name: model-cache-init
image: ollama/ollama:latest
imagePullPolicy: IfNotPresent
command:
- sh
- -c
- |
set -e
echo "Starting Ollama server to cache models..."
ollama serve &
OLLAMA_PID=$!
sleep 10
echo "Caching hot-tier models..."
{{- range .Values.preloadJob.hotModels }}
echo "Checking if {{ . }} is cached..."
if ollama ls | grep -q "{{ . }}"; then
echo "✓ {{ . }} already cached, skipping"
else
echo "Pulling {{ . }}..."
ollama pull {{ . }}
fi
{{- end }}
echo "Model cache initialization complete"
kill $OLLAMA_PID || true
wait $OLLAMA_PID 2>/dev/null || true
volumeMounts:
- name: models
mountPath: /root/.ollama
env:
- name: OLLAMA_HOST
value: "127.0.0.1:11434"
containers:
- name: cache-populate
image: curlimages/curl:latest
imagePullPolicy: IfNotPresent
command:
- sh
- -c
- |
set -e
echo "Waiting for Ollama pod to be ready..."
until curl -f http://ollama.llm.svc.cluster.local:11434/api/tags 2>/dev/null; do
echo "Ollama not ready, waiting..."
sleep 5
done
echo "Ollama is ready, populating local cache..."
{{- range .Values.preloadJob.hotModels }}
echo "Checking if {{ . }} is already cached..."
if curl -s http://ollama.llm.svc.cluster.local:11434/api/tags | grep -q "{{ . }}"; then
echo "✓ {{ . }} already cached, skipping"
else
echo "Caching {{ . }} locally..."
curl -X POST http://ollama.llm.svc.cluster.local:11434/api/pull \
-H "Content-Type: application/json" \
-d '{"name":"{{ . }}","stream":false}'
fi
{{- end }}
echo "Local cache population complete"
volumes:
- name: models
emptyDir: {}
{{- end }}
@@ -0,0 +1,14 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ollama-models-cache
namespace: llm
labels:
app.kubernetes.io/name: ollama
spec:
accessModes:
- ReadWriteOnce
storageClassName: {{ .Values.pvc.storageClassName }}
resources:
requests:
storage: {{ .Values.pvc.size }}
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: ollama
namespace: llm
labels:
app.kubernetes.io/name: ollama
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
app.kubernetes.io/name: ollama
@@ -0,0 +1,12 @@
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: longhorn-llm
labels:
app.kubernetes.io/name: ollama
provisioner: driver.longhorn.io
parameters:
numberOfReplicas: "1"
staleReplicaTimeout: "2880"
reclaimPolicy: Retain
allowVolumeExpansion: true
@@ -0,0 +1,40 @@
replicaCount: 1
image:
repository: ollama/ollama
pullPolicy: IfNotPresent
tag: "latest"
service:
type: ClusterIP
port: 11434
resources:
requests:
cpu: 8
memory: 60Gi
limits:
cpu: 16
memory: 100Gi
pvc:
enabled: true
size: 115Gi
storageClassName: longhorn-llm
nodeAffinity:
zone: az-a
env:
OLLAMA_MODELS: /root/.ollama/models
OLLAMA_MAX_LOADED_MODELS: "2"
OLLAMA_NUM_PARALLEL: "2"
OLLAMA_MAX_QUEUE: "64"
OLLAMA_KEEP_ALIVE: "-1"
OLLAMA_HOST: "0.0.0.0:11434"
preloadJob:
enabled: false
hotModels:
- ornith:35b
- deepseek-r1:70b
+6
View File
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: llm
resources:
- scripts/
# Helm charts deployed via ArgoCD Helm source
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources: []
# Shell scripts, not K8s manifests
@@ -0,0 +1,71 @@
#!/bin/bash
# setup-minio-bucket.sh
# Creates MinIO bucket and Kubernetes secrets for Ollama LLM server
# Runs as helmfile presync hook; all commands are idempotent
# Dependencies: kubectl, access to minio-az-a pod in storage namespace
# Environment: MINIO_ROOT_USER, MINIO_ROOT_PASSWORD (from Vault), AUTHENTIK_OLLAMA_CLIENT_ID, AUTHENTIK_OLLAMA_CLIENT_SECRET
set -e
echo "=== Step 1: Create and label llm namespace ==="
kubectl create namespace llm --dry-run=client -o yaml | kubectl apply -f -
kubectl label namespace llm \
pod-security.kubernetes.io/enforce=baseline \
pod-security.kubernetes.io/enforce-version=latest \
--overwrite
echo "✓ llm namespace created/labeled"
echo ""
echo "=== Step 2: Create MinIO bucket riotpiao-models ==="
# Configure mc host inside MinIO pod
kubectl -n storage exec deployment/minio-az-a -- \
mc config host add local http://localhost:9000 \
"${MINIO_ROOT_USER}" "${MINIO_ROOT_PASSWORD}"
echo "✓ mc host configured"
# Create bucket (idempotent)
kubectl -n storage exec deployment/minio-az-a -- \
mc mb --ignore-existing local/riotpiao-models
echo "✓ MinIO bucket riotpiao-models created (or already exists)"
# Enable versioning for model rollback safety
kubectl -n storage exec deployment/minio-az-a -- \
mc version enable local/riotpiao-models
echo "✓ Versioning enabled on riotpiao-models bucket"
echo ""
echo "=== Step 3: Create ollama-minio Secret (MinIO credentials) ==="
kubectl create secret generic ollama-minio -n llm \
--from-literal=endpoint="http://minio-az-a.storage:9000" \
--from-literal=bucket="riotpiao-models" \
--from-literal=access_key="${MINIO_ROOT_USER}" \
--from-literal=secret_key="${MINIO_ROOT_PASSWORD}" \
--dry-run=client -o yaml | kubectl apply -f -
echo "✓ Secret ollama-minio created/updated"
echo ""
echo "=== Step 4: Create ollama-oidc Secret (Authentik credentials) ==="
kubectl create secret generic ollama-oidc -n llm \
--from-literal=client_id="${AUTHENTIK_OLLAMA_CLIENT_ID}" \
--from-literal=client_secret="${AUTHENTIK_OLLAMA_CLIENT_SECRET}" \
--dry-run=client -o yaml | kubectl apply -f -
echo "✓ Secret ollama-oidc created/updated"
echo ""
echo "=== Verification ==="
echo ""
echo "Run these commands to verify:"
echo " kubectl -n llm get secret ollama-minio ollama-oidc"
echo " kubectl -n storage exec deployment/minio-az-a -- mc ls local/riotpiao-models"
echo ""
echo "Setup complete!"
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# portainer/bootstrap.sh
# Deploys Portainer CE into the dashboard namespace.
# No credentials needed — Portainer prompts you to create an admin account
# on first browser visit.
#
# Prerequisites:
# - kubectl configured (KUBECONFIG pointing to cluster-config/kubeconfig)
# - helm >= 3.x
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
KUBECONFIG="${KUBECONFIG:-${REPO_ROOT}/cluster-config/kubeconfig}"
export KUBECONFIG
# ── Namespace ─────────────────────────────────────────────────────────────────
echo "==> Creating dashboard namespace..."
kubectl create namespace dashboard --dry-run=client -o yaml | kubectl apply -f -
kubectl label namespace dashboard \
pod-security.kubernetes.io/enforce=privileged \
pod-security.kubernetes.io/enforce-version=latest \
--overwrite
# ── Helm repo ─────────────────────────────────────────────────────────────────
echo "==> Adding Portainer Helm repo..."
helm repo add portainer https://portainer.github.io/k8s/
helm repo update portainer
# ── Portainer ─────────────────────────────────────────────────────────────────
echo "==> Installing Portainer..."
helm upgrade --install portainer portainer/portainer \
--namespace dashboard \
--values "${SCRIPT_DIR}/portainer-values.yaml" \
--wait \
--timeout 5m
echo "==> Waiting for Portainer Deployment to be ready..."
kubectl rollout status deployment/portainer -n dashboard --timeout=120s
# ── Done ──────────────────────────────────────────────────────────────────────
echo ""
echo "==> Portainer is up."
echo ""
echo "Access Portainer UI:"
echo " make pf-portainer"
echo " http://localhost:9000"
echo ""
echo "First-time setup: Portainer will prompt you to create an admin account."
echo "Choose 'Manage the local Kubernetes environment' when asked."
echo ""
echo "Node failure resilience tip:"
echo " For faster PVC failover on hard node failure, enable in Longhorn UI → Settings:"
echo " nodeDownPodDeletionPolicy = delete-deployment-pod"
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: portainer
resources: []
# Portainer deployed via Helm chart or existing manifests
@@ -0,0 +1,53 @@
# k8s/portainer/portainer-values.yaml
# Portainer — web UI for browsing cluster workloads, exec-ing into pods,
# and viewing logs without kubectl. Operator-only access (ClusterIP + port-forward).
#
# Node failure behaviour:
# Portainer is a Deployment (not StatefulSet), so K8s auto-evicts and
# reschedules it ~5 min after a node becomes unreachable. Longhorn
# reattaches the PVC on the new node in ~1-2 min. Worst case: ~7-10 min.
#
# To cut that down: in Longhorn UI → Settings set
# nodeDownPodDeletionPolicy = delete-deployment-pod
# Longhorn will force-delete the stuck pod immediately when the node is
# fenced rather than waiting for Kubernetes' eviction timeout.
# ── Service ───────────────────────────────────────────────────────────────────
# ClusterIP — no external exposure. Access via:
# kubectl -n dashboard port-forward svc/portainer 9000:9000
# Portainer holds cluster-admin credentials; never expose as LoadBalancer.
service:
type: ClusterIP
# ── TLS ───────────────────────────────────────────────────────────────────────
# Portainer by default redirects HTTP → HTTPS using a self-signed cert.
# force: false disables the redirect so plain HTTP over port-forward works
# without browser cert warnings. TLS is terminated at the ingress layer
# if/when an ingress rule is added.
tls:
force: false
# ── Persistence ───────────────────────────────────────────────────────────────
# Stores Portainer's own config: environment registrations, user accounts,
# stack definitions, and access control settings. Longhorn provides the
# RWO block volume. 10Gi is generous for config data but cheap on Longhorn.
persistence:
enabled: true
storageClass: "longhorn"
size: 10Gi
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
# ── Scheduling ────────────────────────────────────────────────────────────────
# Allow scheduling on talos-cp-1 (carries NoSchedule taint) so Portainer
# keeps running even when the worker node is down.
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
@@ -0,0 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: shadowsocks
resources: []
@@ -0,0 +1,159 @@
# k8s/shadowsocks/shadowsocks.yaml
# Personal Shadowsocks proxy (for Shadowrocket/other SS clients) — an
# alternative tunnel to the WireGuard setup in cluster-config/, useful when
# a network blocks/throttles WireGuard but not generic TLS-looking traffic.
#
# Everything that varies between deployments (port, password, method) lives
# in the shadowsocks-config Secret below — the Deployment/Service never
# hardcode a value, so re-pointing this at a new port or rotating the
# password is a Secret edit + rollout restart, no YAML edit.
#
# Prerequisites:
# talos put cluster/SHADOWSOCKS_PASSWORD SHADOWSOCKS_PASSWORD="$(openssl rand -base64 24)"
# talos put cluster/SHADOWSOCKS_PORT SHADOWSOCKS_PORT="8388"
#
# Apply:
# kubectl create namespace vpn --dry-run=client -o yaml | kubectl apply -f -
# kubectl -n vpn create secret generic shadowsocks-config \
# --from-literal=SERVER_PORT="$(talos get cluster/SHADOWSOCKS_PORT --key SHADOWSOCKS_PORT)" \
# --from-literal=PASSWORD="$(talos get cluster/SHADOWSOCKS_PASSWORD --key SHADOWSOCKS_PASSWORD)" \
# --from-literal=METHOD="aes-256-gcm" \
# --from-literal=TIMEOUT="300"
# kubectl apply -f k8s/shadowsocks/shadowsocks.yaml
#
# Rotate password (or change port) later:
# kubectl -n vpn delete secret shadowsocks-config && <recreate with new values>
# kubectl -n vpn rollout restart deploy/shadowsocks
#
# Client config: SERVER_PORT/METHOD/PASSWORD above feed directly into the
# Shadowrocket/SS client's server, method, and password fields. SERVER_ADDR
# for the client is the LB IP below (192.168.1.166), or your router's WAN
# address/DDNS hostname (riotpiao.duckdns.org) with port-forwarding to it —
# same pattern as the wg1 WireGuard peer in cluster-config/phone_config.conf.
apiVersion: v1
kind: Namespace
metadata:
name: vpn
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: shadowsocks
namespace: vpn
spec:
replicas: 1
selector:
matchLabels:
app: shadowsocks
template:
metadata:
labels:
app: shadowsocks
spec:
containers:
- name: shadowsocks
image: shadowsocks/shadowsocks-libev:latest
env:
- name: SERVER_ADDR
value: "0.0.0.0"
- name: SERVER_PORT
valueFrom:
secretKeyRef:
name: shadowsocks-config
key: SERVER_PORT
- name: PASSWORD
valueFrom:
secretKeyRef:
name: shadowsocks-config
key: PASSWORD
- name: METHOD
valueFrom:
secretKeyRef:
name: shadowsocks-config
key: METHOD
- name: TIMEOUT
valueFrom:
secretKeyRef:
name: shadowsocks-config
key: TIMEOUT
# containerPort is informational only (no portRange support for
# env-driven SERVER_PORT) — the Service below is what actually
# routes traffic, matched on the same Secret key via downward API
# isn't available for Service ports, so targetPort uses the literal
# port name instead; see Service ports comment.
ports:
- containerPort: 8388
protocol: TCP
- containerPort: 8388
protocol: UDP
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
cpu: 500m
memory: 128Mi
---
# LoadBalancer via Cilium LB-IPAM (see k8s/cilium/lb-ipam-pool.yaml) — pinned
# to .166 so router port-forwarding and the DDNS hostname stay stable across
# pod/service recreates, same pattern forgejo uses at .165.
#
# NOTE: SERVER_PORT in the Secret must match port/targetPort/nodePort here.
# If you change the port, update both the Secret and this Service together.
apiVersion: v1
kind: Service
metadata:
name: shadowsocks
namespace: vpn
annotations:
io.cilium/lb-ipam-ips: "192.168.1.166"
spec:
type: LoadBalancer
selector:
app: shadowsocks
ports:
- name: tcp
protocol: TCP
port: 8388
targetPort: 8388
- name: udp
protocol: UDP
port: 8388
targetPort: 8388
---
# Restrict egress like the forgejo-runner pattern (k8s/forge/runner.yaml) —
# a proxy server is, by design, an open relay to the internet for whoever
# holds the password; LAN/pod-network egress is blocked so a compromised
# password can't be used to pivot into the cluster or LAN. CoreDNS is
# explicitly allowed — shadowsocks-libev resolves client-requested hostnames
# itself, so blanket-blocking the service subnet would break that.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: shadowsocks-egress
namespace: vpn
spec:
podSelector:
matchLabels:
app: shadowsocks
policyTypes: [Egress]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 192.168.1.0/24
- 10.244.0.0/16
- 10.96.0.0/12
@@ -0,0 +1,25 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: strimzi-operator
namespace: cicd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: kmsvc
source:
repoURL: https://strimzi.io/charts/
chart: strimzi-kafka-operator
targetRevision: 0.46.0
helm:
values: |
watchNamespaces: ["sqs"]
destination:
server: https://kubernetes.default.svc
namespace: sqs
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
@@ -0,0 +1,31 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: kafka-cluster
namespace: cicd
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
project: kmsvc
source:
repoURL: https://forgejo.riotpiao.homelab.com/rock/kafaka-management-service.git
targetRevision: main
path: k8s/charts/kafka-cluster
helm:
values: |
namespace: sqs
nodePool:
replicas: 3
storage:
class: longhorn
sizeGi: 50
resources:
memory: 5Gi
cpu: "2"
destination:
server: https://kubernetes.default.svc
namespace: sqs
syncPolicy:
automated:
prune: true
selfHeal: true
@@ -0,0 +1,43 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: kmsvc-redis
namespace: cicd
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
project: kmsvc
source:
repoURL: https://charts.bitnami.com/bitnami
chart: redis
targetRevision: 20.6.0
helm:
values: |
architecture: standalone
# docker.io/bitnami stopped publishing version-pinned tags; bitnamilegacy
# mirrors them for free. allowInsecureImages silences the chart's
# container-image allowlist check, which doesn't know about that mirror.
global:
security:
allowInsecureImages: true
image:
repository: bitnamilegacy/redis
auth:
enabled: false
master:
persistence:
enabled: true
storageClass: longhorn
size: 2Gi
resources:
limits:
memory: 1Gi
requests:
memory: 1Gi
destination:
server: https://kubernetes.default.svc
namespace: sqs
syncPolicy:
automated:
prune: true
selfHeal: true
@@ -0,0 +1,30 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: queue-crd
namespace: cicd
annotations:
argocd.argoproj.io/sync-wave: "2"
spec:
project: kmsvc
source:
repoURL: https://forgejo.riotpiao.homelab.com/rock/kafaka-management-service.git
targetRevision: main
path: k8s/charts/queue-crd
helm:
values: |
namespace: sqs
kafkaBrokers: "kmsvc-kafka-bootstrap.sqs.svc.cluster.local:9092"
redisAddr: "kmsvc-redis-master.sqs.svc.cluster.local:6379"
image:
repository: forgejo.riotpiao.homelab.com/rock/kafka-management-service-queue-operator
# CI (.forgejo/workflows/release.yaml) writes the released git tag
# here and pushes the commit -- ArgoCD picks it up on its next sync.
tag: latest
destination:
server: https://kubernetes.default.svc
namespace: sqs
syncPolicy:
automated:
prune: true
selfHeal: true
@@ -0,0 +1,37 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: management-service
namespace: cicd
annotations:
argocd.argoproj.io/sync-wave: "2"
spec:
project: kmsvc
source:
repoURL: https://forgejo.riotpiao.homelab.com/rock/kafaka-management-service.git
targetRevision: main
path: k8s/charts/management-service
helm:
values: |
namespace: sqs
image:
repository: forgejo.riotpiao.homelab.com/rock/kafka-management-service
# CI (.forgejo/workflows/release.yaml) writes the released git tag
# here and pushes the commit -- ArgoCD picks it up on its next sync.
tag: latest
env:
kafkaBrokers: "kmsvc-kafka-bootstrap.sqs.svc.cluster.local:9092"
redisAddr: "kmsvc-redis-master.sqs.svc.cluster.local:6379"
authentikIssuerURL: "https://authentik.riotpiao.homelab.com/application/o/kafaka/"
authentikAudience: "QI0gPtR99ar8VvhK8Tqox4SDkTKzbNU7lbgwBNSc"
ingress:
enabled: true
host: kmsvc.riotpiao.homelab.com
clusterIssuer: homelab-ca
destination:
server: https://kubernetes.default.svc
namespace: sqs
syncPolicy:
automated:
prune: true
selfHeal: true
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: sqs
resources:
- 02-redis.yaml
- 03-queue-crd.yaml
- 04-management-service.yaml
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: sqs
resources:
- project.yaml
- root.yaml
- apps/
+23
View File
@@ -0,0 +1,23 @@
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: kmsvc
namespace: cicd
spec:
description: Kafka Management Service (design.md) -- Strimzi/Kafka, Redis, queue-operator, message-plane server
sourceRepos:
- https://forgejo.riotpiao.homelab.com/rock/kafaka-management-service.git
- https://strimzi.io/charts/
- https://charts.bitnami.com/bitnami
destinations:
- namespace: sqs
server: https://kubernetes.default.svc
- namespace: cicd
server: https://kubernetes.default.svc
clusterResourceWhitelist:
- group: "apiextensions.k8s.io"
kind: CustomResourceDefinition
- group: "rbac.authorization.k8s.io"
kind: ClusterRole
- group: "rbac.authorization.k8s.io"
kind: ClusterRoleBinding
+22
View File
@@ -0,0 +1,22 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: kmsvc-root
namespace: cicd
spec:
project: kmsvc
source:
repoURL: https://forgejo.riotpiao.homelab.com/rock/kafaka-management-service.git
targetRevision: main
path: k8s/argocd/apps
directory:
recurse: false
destination:
server: https://kubernetes.default.svc
namespace: cicd
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
@@ -0,0 +1,5 @@
apiVersion: v2
name: kafka-cluster
description: Strimzi Kafka/KafkaNodePool CRs for the kmsvc Kafka cluster (design.md §7)
type: application
version: 0.1.0
@@ -0,0 +1,30 @@
apiVersion: kafka.strimzi.io/v1beta2
kind: Kafka
metadata:
name: {{ .Values.clusterName }}
namespace: {{ .Values.namespace }}
annotations:
strimzi.io/node-pools: enabled
strimzi.io/kraft: enabled
spec:
kafka:
version: 4.0.0
metadataVersion: 4.0-IV3
listeners:
- name: plain
port: 9092
type: internal
tls: false
- name: tls
port: 9093
type: internal
tls: true
config:
default.replication.factor: {{ .Values.kafka.replicationFactor }}
min.insync.replicas: {{ .Values.kafka.minInsyncReplicas }}
offsets.topic.replication.factor: {{ .Values.kafka.replicationFactor }}
transaction.state.log.replication.factor: {{ .Values.kafka.replicationFactor }}
transaction.state.log.min.isr: {{ .Values.kafka.minInsyncReplicas }}
entityOperator:
topicOperator: {}
userOperator: {}
@@ -0,0 +1,39 @@
apiVersion: kafka.strimzi.io/v1beta2
kind: KafkaNodePool
metadata:
name: {{ .Values.clusterName }}-pool
namespace: {{ .Values.namespace }}
labels:
strimzi.io/cluster: {{ .Values.clusterName }}
spec:
replicas: {{ .Values.nodePool.replicas }}
roles:
- controller
- broker
storage:
type: persistent-claim
size: {{ .Values.nodePool.storage.sizeGi }}Gi
class: {{ .Values.nodePool.storage.class }}
deleteClaim: false
resources:
limits:
memory: {{ .Values.nodePool.resources.memory }}
cpu: {{ .Values.nodePool.resources.cpu | quote }}
requests:
memory: {{ .Values.nodePool.resources.memory }}
cpu: {{ .Values.nodePool.resources.cpu | quote }}
template:
pod:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: {{ .Values.nodePool.antiAffinityTopologyKey }}
labelSelector:
matchLabels:
strimzi.io/cluster: {{ .Values.clusterName }}
kafkaContainer:
env:
- name: KAFKA_HEAP_OPTS
value: {{ .Values.nodePool.heapOpts | quote }}
@@ -0,0 +1,19 @@
{{- if eq .Values.nodePool.storage.class "longhorn-kafka" }}
# The default "longhorn" StorageClass requests 3 replicas across 3 zone-labeled
# nodes (az-a/az-b/az-c). With Longhorn's zone-aware anti-affinity, replicas
# spread 1-per-zone for durability.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: longhorn-kafka
provisioner: driver.longhorn.io
allowVolumeExpansion: true
reclaimPolicy: Delete
volumeBindingMode: Immediate
parameters:
numberOfReplicas: "3"
staleReplicaTimeout: "30"
fromBackup: ""
fsType: "ext4"
dataLocality: "disabled"
{{- end }}
@@ -0,0 +1,26 @@
clusterName: kmsvc
namespace: sqs
nodePool:
replicas: 3
storage:
class: longhorn-kafka
# Longhorn's per-node scheduling budget on the current 2-node cluster has
# only ~36Gi of headroom left (other PVCs already reserve the rest), and
# each node hosts one replica of all 3 broker volumes -- so 3 * sizeGi
# must fit in that headroom. Revisit once the 3rd node joins.
sizeGi: 10
resources:
memory: 5Gi
cpu: "2"
heapOpts: "-Xms2g -Xmx2g"
# design.md §7: 3 real zones now exist (talos-cp-1=az-a, talos-worker-1=az-b,
# talos-worker-2=az-c), so anti-affinity keys off zone instead of hostname —
# spreads the 3 broker pods one-per-zone/one-per-node (equivalent today,
# but zone is the correct long-term key if a node ever gets replaced within
# the same zone).
antiAffinityTopologyKey: topology.kubernetes.io/zone
kafka:
replicationFactor: 3
minInsyncReplicas: 2
@@ -0,0 +1,5 @@
apiVersion: v2
name: management-service
description: kmsvc message-plane gRPC+REST server (design.md §1, §7a, §9)
type: application
version: 0.1.0
@@ -0,0 +1,12 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: management-service-config
namespace: {{ .Values.namespace }}
data:
KMSVC_KAFKA_BROKERS: {{ .Values.env.kafkaBrokers | quote }}
KMSVC_REDIS_ADDR: {{ .Values.env.redisAddr | quote }}
KMSVC_AUTHENTIK_ISSUER_URL: {{ .Values.env.authentikIssuerURL | quote }}
KMSVC_AUTHENTIK_AUDIENCE: {{ .Values.env.authentikAudience | quote }}
KMSVC_GRPC_LISTEN_ADDR: ":{{ .Values.grpcPort }}"
KMSVC_HTTP_LISTEN_ADDR: ":{{ .Values.httpPort }}"
@@ -0,0 +1,49 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: management-service
namespace: {{ .Values.namespace }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: management-service
template:
metadata:
labels:
app: management-service
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: management-service
containers:
- name: management-service
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: grpc
containerPort: {{ .Values.grpcPort }}
- name: http
containerPort: {{ .Values.httpPort }}
env:
- name: GOMEMLIMIT
value: {{ .Values.goMemLimit | quote }}
envFrom:
- configMapRef:
name: management-service-config
resources:
{{- toYaml .Values.resources | nindent 12 }}
readinessProbe:
tcpSocket:
port: {{ .Values.httpPort }}
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
tcpSocket:
port: {{ .Values.httpPort }}
initialDelaySeconds: 10
periodSeconds: 20
@@ -0,0 +1,27 @@
{{- if .Values.hpa.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: management-service
namespace: {{ .Values.namespace }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: management-service
minReplicas: {{ .Values.hpa.minReplicas }}
maxReplicas: {{ .Values.hpa.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.hpa.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.hpa.targetMemoryUtilizationPercentage }}
{{- end }}
@@ -0,0 +1,27 @@
{{- if and .Values.ingress.enabled .Values.ingress.grpcEnabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: management-service-grpc
namespace: {{ .Values.namespace }}
annotations:
cert-manager.io/cluster-issuer: {{ .Values.ingress.clusterIssuer }}
nginx.ingress.kubernetes.io/backend-protocol: "GRPC"
spec:
ingressClassName: {{ .Values.ingress.className }}
tls:
- hosts:
- {{ .Values.ingress.host }}
secretName: {{ .Values.ingress.tlsSecretName }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: {{ .Values.ingress.grpcPathPrefix }}
pathType: Prefix
backend:
service:
name: management-service
port:
number: {{ .Values.grpcPort }}
{{- end }}
@@ -0,0 +1,26 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: management-service
namespace: {{ .Values.namespace }}
annotations:
cert-manager.io/cluster-issuer: {{ .Values.ingress.clusterIssuer }}
spec:
ingressClassName: {{ .Values.ingress.className }}
tls:
- hosts:
- {{ .Values.ingress.host }}
secretName: {{ .Values.ingress.tlsSecretName }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: management-service
port:
number: {{ .Values.httpPort }}
{{- end }}
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: management-service
namespace: {{ .Values.namespace }}
spec:
selector:
app: management-service
ports:
- name: grpc
port: {{ .Values.grpcPort }}
targetPort: {{ .Values.grpcPort }}
- name: http
port: {{ .Values.httpPort }}
targetPort: {{ .Values.httpPort }}
type: ClusterIP
@@ -0,0 +1,50 @@
namespace: sqs
replicaCount: 3
image:
repository: ghcr.io/riotpiaole/kmsvc-management-service
tag: latest
pullPolicy: Always
grpcPort: 9090
httpPort: 8080
env:
kafkaBrokers: "kmsvc-kafka-bootstrap.sqs.svc.cluster.local:9092"
redisAddr: "kmsvc-redis-master.sqs.svc.cluster.local:6379"
authentikIssuerURL: ""
authentikAudience: ""
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
# Go's GC only reacts to GOGC by default and has no idea about the cgroup
# memory limit above -- it'll happily grow heap until the kernel OOMKills it.
# Setting GOMEMLIMIT to ~90% of the container limit makes the GC self-throttle
# before that happens. Keep this in sync with resources.limits.memory.
goMemLimit: "460MiB"
hpa:
enabled: true
minReplicas: 3
maxReplicas: 9
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
ingress:
enabled: true
className: nginx
clusterIssuer: homelab-ca
host: kmsvc.riotpiao.homelab.com
tlsSecretName: kmsvc-tls
# kmsvc-cli connects via gRPC directly to --server/KMSVC_SERVER (default
# kmsvc.riotpiao.homelab.com:443, see kmsvc-cli's README), so raw gRPC needs an
# external path too — scoped to the gRPC service's own path prefix on the
# same host/port, rather than opening the whole host to gRPC passthrough.
grpcEnabled: true
grpcPathPrefix: /kafkamgmt.v1.QueueService/
@@ -0,0 +1,5 @@
apiVersion: v2
name: queue-crd
description: Queue CRD definition + queue-operator Deployment/RBAC (design.md §2a)
type: application
version: 0.1.0
@@ -0,0 +1,274 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.21.0
name: queues.kmsvc.io
spec:
group: kmsvc.io
names:
kind: Queue
listKind: QueueList
plural: queues
shortNames:
- queue
- queues
singular: queue
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.fifoQueue
name: FIFO
type: boolean
- jsonPath: .status.phase
name: Phase
type: string
name: v1
schema:
openAPIV3Schema:
description: Queue is the Schema for the queues API — see design.md §2a.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: QueueSpec defines the desired state of a Queue (design.md
§2a).
properties:
deadLetterTargetQueue:
description: |-
DeadLetterTargetQueue is the name of another Queue to route exhausted
messages to. Must not point at itself or at another DLQ (design.md §5).
type: string
delaySeconds:
description: DelaySeconds is the default delivery delay applied to
sent messages.
format: int32
maximum: 900
minimum: 0
type: integer
fifoQueue:
default: false
description: FIFOQueue enables per-MessageGroupId ordering and deduplication
semantics.
type: boolean
isDLQ:
description: |-
IsDLQ marks this queue as itself a dead-letter queue, used to enforce
the no-DLQ-chaining validation rule in design.md §5.
type: boolean
maxReceiveCount:
default: 5
description: |-
MaxReceiveCount is how many times a message may be redelivered before
being routed to DeadLetterTargetQueue.
format: int32
minimum: 1
type: integer
maxShards:
default: 8
description: MaxShards is the ceiling on shard count the operator
may split up to (design.md §2c).
format: int32
minimum: 1
type: integer
messageRetentionPeriodSeconds:
default: 345600
description: MessageRetentionPeriodSeconds maps to the underlying
Kafka topic's retention.ms.
format: int32
maximum: 1209600
minimum: 60
type: integer
minShards:
default: 1
description: MinShards is the floor on shard count; the operator never
merges below this.
format: int32
minimum: 1
type: integer
partitionsPerShard:
default: 6
description: PartitionsPerShard is the Kafka partition count on each
shard's topic.
format: int32
minimum: 1
type: integer
shardSplitCooldownSeconds:
default: 300
description: |-
ShardSplitCooldownSeconds is the minimum age a shard must reach before it
is eligible to be split again, preventing rapid re-splitting of a child
that hasn't yet absorbed its share of traffic.
format: int32
minimum: 0
type: integer
shardSplitThresholdBytesPerSec:
default: 5242880
description: |-
ShardSplitThresholdBytesPerSec is the sustained per-shard throughput that
triggers a split into two child shards (design.md §2c).
format: int64
minimum: 1
type: integer
visibilityTimeoutSeconds:
default: 30
description: |-
VisibilityTimeoutSeconds is how long a received-but-unacked message stays
invisible to other consumers before being redelivered.
format: int32
maximum: 43200
minimum: 0
type: integer
type: object
status:
description: QueueStatus defines the observed state of a Queue.
properties:
conditions:
description: Conditions hold detailed status information.
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
phase:
description: Phase is the current reconciliation phase.
enum:
- Pending
- Ready
- Failed
type: string
shards:
description: |-
Shards lists every shard backing this queue, active or draining
(design.md §2a/§2c).
items:
description: ShardStatus describes one shard backing a Queue (design.md
§2a/§2c).
properties:
availabilityZones:
description: |-
AvailabilityZones lists the topology.kubernetes.io/zone values of every
node currently hosting a Kafka replica of this shard's topic, resolved
from the broker pods' node placement each reconcile. Empty until the
first successful resolution (e.g. node lookup failed transiently).
items:
type: string
type: array
createdAt:
description: |-
CreatedAt timestamps when this shard was created, used to enforce
ShardSplitCooldownSeconds.
format: date-time
type: string
hashRangeEnd:
format: int64
type: integer
hashRangeStart:
description: |-
HashRangeStart/HashRangeEnd define the [start, end) murmur2 hash range
this shard owns over the 32-bit key space. Stored as int64 (not uint32)
because controller-gen maps Go uint32 to OpenAPI format:int32, whose max
(2147483647) is smaller than FullHashRangeEnd (0xFFFFFFFF) and the
apiserver rejects the status update.
format: int64
type: integer
id:
description: ID is the shard's identifier, used in its topic
name (kmsvc.{queue}.shard-{id}).
type: string
parentId:
description: |-
ParentID is the shard ID this shard was split from, empty for the
original shard-0.
type: string
phase:
description: Phase is this shard's lifecycle state.
enum:
- Active
- Closing
- Closed
type: string
topic:
description: Topic is the underlying Kafka topic name for this
shard.
type: string
required:
- hashRangeEnd
- hashRangeStart
- id
- phase
- topic
type: object
type: array
type: object
type: object
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,38 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: queue-operator
namespace: {{ .Values.namespace }}
spec:
replicas: 1
selector:
matchLabels:
app: queue-operator
template:
metadata:
labels:
app: queue-operator
spec:
serviceAccountName: queue-operator
containers:
- name: queue-operator
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["/queue-operator"]
env:
- name: KMSVC_KAFKA_BROKERS
value: {{ .Values.kafkaBrokers | quote }}
- name: KMSVC_REDIS_ADDR
value: {{ .Values.redisAddr | quote }}
- name: GOMEMLIMIT
value: {{ .Values.goMemLimit | quote }}
- name: KMSVC_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: KMSVC_KAFKA_CLUSTER_NAME
value: {{ .Values.kafkaClusterName | quote }}
- name: KMSVC_KAFKA_POOL_NAME
value: {{ .Values.kafkaPoolName | quote }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
@@ -0,0 +1,42 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: queue-operator
namespace: {{ .Values.namespace }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: queue-operator
rules:
- apiGroups: ["kmsvc.io"]
resources: ["queues"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: ["kmsvc.io"]
resources: ["queues/status"]
verbs: ["get", "update", "patch"]
- apiGroups: ["kmsvc.io"]
resources: ["queues/finalizers"]
verbs: ["update"]
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
- apiGroups: [""]
resources: ["pods", "nodes"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: queue-operator
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: queue-operator
subjects:
- kind: ServiceAccount
name: queue-operator
namespace: {{ .Values.namespace }}
@@ -0,0 +1,26 @@
namespace: sqs
image:
repository: ghcr.io/riotpiaole/kmsvc-management-service
tag: latest
pullPolicy: Always
kafkaBrokers: "kmsvc-kafka-bootstrap.sqs.svc.cluster.local:9092"
redisAddr: "kmsvc-redis-master.sqs.svc.cluster.local:6379"
# Must match kafka-cluster chart's clusterName/derived pool name -- used to
# resolve "<kafkaClusterName>-<kafkaPoolName>-<brokerID>" broker pod names
# for AZ-aware Queue status (design.md §2a).
kafkaClusterName: kmsvc
kafkaPoolName: kmsvc-pool
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
# See management-service/values.yaml's goMemLimit comment -- same reasoning.
goMemLimit: "230MiB"
@@ -0,0 +1,29 @@
# design.md §7b: cluster-specific values for the homelab environment.
# No secrets here — Authentik client secret etc. flow through the existing
# Vault/talos-cli pattern, referenced at deploy time, not inlined.
namespace: sqs
kafkaCluster:
nodePool:
replicas: 3
storage:
# longhorn-kafka now uses numberOfReplicas: 3 across 3 zone-labeled nodes.
class: longhorn-kafka
# Per-node headroom: with 3 nodes and existing PVCs, estimate ~100+ Gi total
# available. Each node hosts one replica of all 3 broker volumes, so 3 *
# sizeGi must fit. Monitor usage during Kafka deployment.
sizeGi: 10
resources:
memory: 5Gi
cpu: "2"
redis:
storageClass: longhorn
memoryLimit: 1Gi
managementService:
ingress:
host: kmsvc.riotpiao.homelab.com
clusterIssuer: homelab-ca
authentikIssuerURL: "https://authentik.riotpiao.homelab.com/application/o/kafaka/"
authentikAudience: "QI0gPtR99ar8VvhK8Tqox4SDkTKzbNU7lbgwBNSc"
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: sqs
resources: []
# homelab.yaml is configuration, not a K8s manifest
+99
View File
@@ -0,0 +1,99 @@
environments:
default:
values:
- environments/homelab.yaml
homelab:
values:
- environments/homelab.yaml
---
helmDefaults:
wait: true
timeout: 600
repositories:
- name: strimzi
url: https://strimzi.io/charts/
- name: bitnami
url: https://charts.bitnami.com/bitnami
releases:
- name: strimzi-operator
namespace: {{ .Values.namespace }}
chart: strimzi/strimzi-kafka-operator
version: 0.46.0
values:
- watchNamespaces: ["{{ .Values.namespace }}"]
- name: kafka-cluster
namespace: {{ .Values.namespace }}
chart: charts/kafka-cluster
needs:
- {{ .Values.namespace }}/strimzi-operator
values:
- namespace: {{ .Values.namespace }}
nodePool:
replicas: {{ .Values.kafkaCluster.nodePool.replicas }}
storage:
class: {{ .Values.kafkaCluster.nodePool.storage.class }}
sizeGi: {{ .Values.kafkaCluster.nodePool.storage.sizeGi }}
resources:
memory: {{ .Values.kafkaCluster.nodePool.resources.memory }}
cpu: {{ .Values.kafkaCluster.nodePool.resources.cpu | quote }}
- name: kmsvc-redis
namespace: {{ .Values.namespace }}
chart: bitnami/redis
version: 20.6.0
values:
- architecture: standalone
# Bitnami stopped publishing version-pinned tags under docker.io/bitnami
# (only `latest` remains there); bitnamilegacy/* mirrors the old
# versioned tags for free, so pin there instead of floating on `latest`.
# The chart's container-image allowlist check doesn't know about the
# legacy mirror, hence allowInsecureImages.
global:
security:
allowInsecureImages: true
image:
repository: bitnamilegacy/redis
auth:
enabled: false
master:
persistence:
enabled: true
storageClass: {{ .Values.redis.storageClass }}
size: 2Gi
resources:
limits:
memory: {{ .Values.redis.memoryLimit }}
requests:
memory: {{ .Values.redis.memoryLimit }}
- name: queue-crd
namespace: {{ .Values.namespace }}
chart: charts/queue-crd
needs:
- {{ .Values.namespace }}/kafka-cluster
- {{ .Values.namespace }}/kmsvc-redis
values:
- namespace: {{ .Values.namespace }}
kafkaBrokers: "kmsvc-kafka-bootstrap.{{ .Values.namespace }}.svc.cluster.local:9092"
redisAddr: "kmsvc-redis-master.{{ .Values.namespace }}.svc.cluster.local:6379"
- name: management-service
namespace: {{ .Values.namespace }}
chart: charts/management-service
needs:
- {{ .Values.namespace }}/kafka-cluster
- {{ .Values.namespace }}/kmsvc-redis
values:
- namespace: {{ .Values.namespace }}
env:
kafkaBrokers: "kmsvc-kafka-bootstrap.{{ .Values.namespace }}.svc.cluster.local:9092"
redisAddr: "kmsvc-redis-master.{{ .Values.namespace }}.svc.cluster.local:6379"
authentikIssuerURL: {{ .Values.managementService.authentikIssuerURL | quote }}
authentikAudience: {{ .Values.managementService.authentikAudience | quote }}
ingress:
enabled: true
host: {{ .Values.managementService.ingress.host | quote }}
clusterIssuer: {{ .Values.managementService.ingress.clusterIssuer | quote }}
+8
View File
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: sqs
resources:
- argocd/
- environments/
- queues/
# Helm charts deployed via ArgoCD Helm source
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- orders-fifo.yaml
@@ -0,0 +1,32 @@
apiVersion: kmsvc.io/v1
kind: Queue
metadata:
name: orders-fifo
namespace: sqs
spec:
fifoQueue: true
visibilityTimeoutSeconds: 30
messageRetentionPeriodSeconds: 345600
maxReceiveCount: 5
deadLetterTargetQueue: orders-fifo-dlq
delaySeconds: 0
partitionsPerShard: 6
minShards: 1
maxShards: 8
shardSplitThresholdBytesPerSec: 5242880
shardSplitCooldownSeconds: 300
---
apiVersion: kmsvc.io/v1
kind: Queue
metadata:
name: orders-fifo-dlq
namespace: sqs
spec:
fifoQueue: true
isDLQ: true
visibilityTimeoutSeconds: 30
messageRetentionPeriodSeconds: 1209600
maxReceiveCount: 5
partitionsPerShard: 6
minShards: 1
maxShards: 1
@@ -0,0 +1,237 @@
# Phase 2: Namespace-Scoped Auto-Provisioning Testing Guide
## Overview
Phase 2 implements **namespace-scoped automatic TemporalWorker provisioning** (Kafka broker model).
One TemporalWorker per Temporal namespace processes ALL task queues in that namespace. When multiple Queues share the same `temporal.io/namespace` label, they trigger creation of a single TemporalWorker that handles all of them.
```
Queues (labeled temporal.io/namespace: "production")
├── orders-fifo
├── payments
└── notifications
queue-operator creates 1 TemporalWorker (worker-production)
TemporalWorker controller creates 1 Deployment
Worker pod(s) connect to Temporal namespace "production"
Process ALL task queues in that namespace (scale horizontally by replicas)
```
## Implementation Changes
### 1. TemporalWorker CRD (`apis/kmsvc/v1/temporalworker_types.go`)
- New Kubernetes resource type to manage namespace-scoped workers
- Fields: Namespace (required), Image, Replicas, Resources, NodeSelector, Affinity, Tolerations
- Status: Phase (Pending/Ready/Failed), Replicas, ReadyReplicas, Conditions
- Model: 1 TemporalWorker per Temporal namespace (not per queue)
### 2. QueueReconciler Extension (`internal/operator/queue_controller.go`)
- New method: `reconcileTemporalWorker()`
- Logic: If Queue has `temporal.io/namespace` label, create TemporalWorker for that namespace
- Idempotent: multiple queues with same namespace label create same TemporalWorker (no duplicates)
### 3. TemporalWorkerReconciler (`internal/operator/temporal_worker_controller.go`)
- New controller watching TemporalWorker objects
- Creates/updates Kubernetes Deployment with:
- Pod spec: container image, env vars (TEMPORAL_FRONTEND_ADDRESS, TEMPORAL_TASK_QUEUE)
- Replicas, resources, node selector, affinity, tolerations from TemporalWorker spec
- Updates TemporalWorker status with deployment replica counts and phase
### 4. Operator Main (`cmd/queue-operator/main.go`)
- Registers TemporalWorker CRD in scheme
- Registers TemporalWorkerReconciler controller
- Controller watches TemporalWorker objects; owns Deployment objects
## Testing Procedure
### Prerequisites
- kmsvc queue-operator must be running (built and deployed)
- Temporal cluster must be ready (temporal-frontend service available at `temporal-frontend.temporal.svc.cluster.local:7233`)
- story-crater-backend Docker image must exist (used as default worker image)
### Step 1: Build and Deploy kmsvc Operator
```bash
cd /Users/rockliang/workplace/kmsvc-manage
make build # builds queue-operator binary
make docker-build # builds Docker image
make deploy # deploys to cluster (requires Helm chart)
```
Or manually:
```bash
cd /Users/rockliang/workplace/kmsvc-manage
go build -o bin/queue-operator ./cmd/queue-operator
kubectl apply -f k8s/queue-operator-rbac.yaml
kubectl apply -f k8s/queue-operator-deployment.yaml
```
### Step 2: Create Queues with Temporal Namespace Labels
```bash
kubectl apply -f /Users/rockliang/workplace/homelab/k8s/temporal/queues/example-queue.yaml
```
Verify Queues are Ready:
```bash
kubectl get queue -n sqs -l temporal.io/namespace=production
kubectl describe queue -n sqs story-crater-tasks
```
Expected:
```
NAME FIFO PHASE AGE
story-crater-tasks false Ready 5s
story-crater-notifications false Ready 5s
```
### Step 3: Verify TemporalWorker CRD Auto-Created (1 per namespace)
```bash
kubectl get temporalworker -n temporal
kubectl describe temporalworker -n temporal worker-production
```
Expected:
```
NAME PHASE READY DESIRED AGE
worker-production Pending 0 1 5s
```
Only ONE TemporalWorker for all queues in "production" namespace!
### Step 4: Verify Deployment Auto-Created
```bash
kubectl get deploy -n temporal -l app.kubernetes.io/managed-by=kmsvc-temporal-operator
kubectl get pods -n temporal -l app.kubernetes.io/instance=worker-production
```
Expected:
```
NAME READY UP-TO-DATE AVAILABLE AGE
worker-production 1/1 1 1 10s
NAME READY STATUS RESTARTS AGE
worker-production-5f8b4c... 1/1 Running 0 10s
```
### Step 5: Verify Worker Connected to Temporal Namespace
Check Temporal UI for namespace "production":
```bash
open https://temporal.riotpiao.homelab.com/namespaces/production/task-queues
```
Look for all task queues with worker count > 0:
- `story-crater-tasks`
- `story-crater-notifications`
- (worker processes all of them)
Or via CLI:
```bash
kubectl port-forward -n temporal svc/temporal-frontend 7233 &
curl http://localhost:7233/api/v1/task-queues?namespace=production
```
### Step 6: Verify TemporalWorker Status Updated
```bash
kubectl get temporalworker -n temporal
kubectl describe temporalworker -n temporal worker-production
```
Expected:
```
NAME PHASE READY DESIRED AGE
worker-production Ready 1 1 15s
Status:
Phase: Ready
Ready Replicas: 1
Replicas: 1
```
### Step 7: Test Namespace-Level Scaling
Create more queues in the same namespace:
```yaml
apiVersion: kmsvc.io/v1
kind: Queue
metadata:
name: story-crater-llm-processing
namespace: sqs
labels:
temporal.io/namespace: "production" # same namespace
```
Verify: No new TemporalWorker created (same worker handles all 3 queues):
```bash
kubectl get temporalworker -n temporal # still just 1 worker-production
kubectl get deploy -n temporal worker-production # same deployment
```
Worker auto-discovers new task queue in namespace and processes it.
### Step 8: Test Cascading Deletion
Delete a Queue; worker should remain (other queues still need it):
```bash
kubectl delete queue -n sqs story-crater-notifications
```
Verify:
```bash
kubectl get temporalworker -n temporal # worker-production still exists
kubectl get pods -n temporal worker-production # still running
```
Delete all queues in namespace:
```bash
kubectl delete queue -n sqs -l temporal.io/namespace=production
```
Verify: TemporalWorker now has no owner (not cascade-deleted; manual cleanup needed):
```bash
kubectl get temporalworker -n temporal # worker-production still there (manual cleanup)
kubectl delete temporalworker -n temporal worker-production # cleanup manually
```
## Debugging
### Queue stuck in Pending
Check queue-operator logs:
```bash
kubectl logs -n sqs deploy/kmsvc-queue-operator -f
kubectl logs -n sqs deploy/kmsvc-queue-operator --tail=50 | grep -i error
```
### TemporalWorker not created
- Verify Queue has the label: `kubectl get queue -o yaml | grep temporal.io`
- Check queue-operator logs for "reconcileTemporalWorker" errors
### Deployment not created
- Check TemporalWorker controller logs: `kubectl logs -n sqs deploy/kmsvc-queue-operator -f`
- Verify TemporalWorker exists: `kubectl get temporalworker -n temporal`
- Check Deployment errors: `kubectl describe deploy -n temporal worker-story-crater-tasks`
### Worker not showing in Temporal UI
- Check pod logs: `kubectl logs -n temporal deploy/worker-story-crater-tasks`
- Verify env vars: `kubectl set env pod -n temporal <pod-name> --list | grep TEMPORAL`
- Test connectivity: `kubectl exec -n temporal <pod-name> -- nc -zv temporal-frontend.temporal.svc.cluster.local 7233`
## Next Steps
Once Phase 2 is working:
1. **Phase 3 (Future):** Implement autoscaling based on queue depth metrics
2. **Production Hardening:**
- Add QueueRef validation (ensure Queue exists in sqs namespace)
- Add image validation/defaults from ConfigMap
- Add worker readiness probe configuration
- Add graceful shutdown/drain behavior
## Files Modified/Created
| File | Change |
|------|--------|
| `apis/kmsvc/v1/temporalworker_types.go` | NEW: CRD type definitions |
| `internal/operator/queue_controller.go` | MODIFIED: Added reconcileTemporalWorker() |
| `internal/operator/temporal_worker_controller.go` | NEW: TemporalWorker → Deployment reconciler |
| `cmd/queue-operator/main.go` | MODIFIED: Register TemporalWorker CRD + controller |
| `k8s/temporal/queues/example-queue.yaml` | NEW: Example Queue with label |
@@ -0,0 +1,220 @@
# Temporal OAuth2-Proxy Setup (Authentik OIDC)
## Overview
Protects Temporal UI with Authentik OIDC authentication. Traffic flow:
```
Browser → Ingress (TLS) → oauth2-proxy (OIDC check) → temporal-web (internal)
Redirects to Authentik login
JWT cookie issued
Forwards to temporal-web
```
## Prerequisites
✅ Authentik OIDC provider `temporal` already exists with:
- Client ID: `temporal`
- Client Secret: stored in Kubernetes secret `temporal-oidc` (key: `clientSecret`)
- Redirect URI: `https://temporal.riotpiao.homelab.com/oauth2/callback`
## Secrets
The `temporal-oidc` secret must contain:
| Key | Value | Source |
|-----|-------|--------|
| `clientSecret` | OAuth2 client secret from Authentik | Authentik → Applications → temporal |
| `cookieSecret` | Session encryption key (base64 32-byte) | Generate: `openssl rand -base64 32` |
### Check existing secret:
```bash
kubectl get secret -n temporal temporal-oidc
kubectl describe secret -n temporal temporal-oidc
```
### If missing, create it:
```bash
# Get client secret from Authentik UI
# Applications → temporal → copy "Client Secret"
CLIENT_SECRET="..."
# Generate cookie secret
COOKIE_SECRET=$(openssl rand -base64 32)
# Create secret
kubectl create secret generic temporal-oidc \
-n temporal \
--from-literal=clientSecret="${CLIENT_SECRET}" \
--from-literal=cookieSecret="${COOKIE_SECRET}"
```
## Deployment Steps
### Step 1: Apply OAuth2-Proxy Manifests
```bash
kubectl apply -f k8s/temporal/oauth2-proxy.yaml
```
Verify:
```bash
kubectl get deploy -n temporal oauth2-proxy
kubectl logs -n temporal deploy/oauth2-proxy
```
Expected log:
```
[<timestamp>] [oauthproxy.go:...] Listening on 0.0.0.0:4180
```
### Step 2: Apply OAuth2-Proxy Ingress
```bash
kubectl apply -f k8s/temporal/temporal-ingress-oauth2.yaml
```
Verify:
```bash
kubectl get ingress -n temporal
```
Expected:
```
NAME CLASS HOSTS ADDRESS PORTS AGE
temporal nginx temporal.riotpiao.homelab.com ... 80, 443 10s
```
### Step 3: Test Access
1. **Open Temporal UI (unauthenticated):**
```bash
open https://temporal.riotpiao.homelab.com
```
Expected: Redirects to Authentik login page
2. **Login with Authentik credentials**
- Username/email
- Password
- Should redirect back to `temporal.riotpiao.homelab.com` and display UI
3. **Verify auth:**
```bash
# Check for oauth2_proxy cookie
curl -v https://temporal.riotpiao.homelab.com 2>&1 | grep -i cookie
```
4. **Check oauth2-proxy logs:**
```bash
kubectl logs -n temporal deploy/oauth2-proxy -f
```
Look for:
```
[timestamp] [auth_test.go:...] Authentication successful
```
## Troubleshooting
### Redirect URI mismatch
Error in oauth2-proxy logs:
```
redirect_uri_mismatch: The redirect_uri does not match the one registered in Authentik
```
Fix:
- Verify Authentik application (Applications → temporal) has redirect URI: `https://temporal.riotpiao.homelab.com/oauth2/callback`
- Ensure HTTPS (not HTTP)
### Missing secret
Error:
```
clientSecret: key not found in temporal-oidc secret
```
Fix:
```bash
kubectl get secret -n temporal temporal-oidc -o yaml
# If missing, create per "Secrets" section above
```
### Cookie secret expiration
OAuth2-Proxy won't start if `cookieSecret` is empty or invalid.
Fix:
```bash
COOKIE_SECRET=$(openssl rand -base64 32)
kubectl patch secret temporal-oidc -n temporal \
-p "{\"data\":{\"cookieSecret\":\"$(echo -n $COOKIE_SECRET | base64)\"}}}"
kubectl rollout restart deploy/oauth2-proxy -n temporal
```
### oauth2-proxy crashes with "connection refused"
Error in logs:
```
upstream connect error or disconnect/reset before headers
```
Likely cause: `temporal-web` service not accessible.
Check:
```bash
kubectl get svc -n temporal temporal-web
kubectl exec -n temporal deploy/oauth2-proxy -- curl http://temporal-web:8080
```
## File Structure
```
k8s/temporal/
├── oauth2-proxy.yaml # oauth2-proxy Deployment + Service + SA
├── temporal-ingress-oauth2.yaml # Ingress routing to oauth2-proxy
├── oauth2-proxy-values.yaml # Helm values (reference only)
└── temporal-values.yaml # Modified: ingress.enabled=false
```
## Next: Add to Helmfile
If integrating with helmfile.yaml.gotmpl:
```yaml
releases:
- name: temporal
# ... existing config ...
hooks:
postSync:
- events: ["success"]
showlogs: true
command: "sh"
args:
- -c
- |
kubectl apply -f k8s/temporal/oauth2-proxy.yaml
kubectl apply -f k8s/temporal/temporal-ingress-oauth2.yaml
```
Or add separate releases:
```yaml
- name: oauth2-proxy-temporal
namespace: temporal
chart: oauth2-proxy/oauth2-proxy
version: "6.x.x"
values:
- k8s/temporal/oauth2-proxy-values.yaml
set:
- name: config.clientSecret
value: "{{ (env "TEMPORAL_OIDC_CLIENT_SECRET") }}"
- name: config.cookieSecret
value: "{{ (env "TEMPORAL_OIDC_COOKIE_SECRET") }}"
```
Then add to `.env`:
```bash
TEMPORAL_OIDC_CLIENT_SECRET=<from Authentik>
TEMPORAL_OIDC_COOKIE_SECRET=$(openssl rand -base64 32)
```
@@ -0,0 +1,102 @@
# Elasticsearch 7.17.0 for Temporal visibility store
# Deployed to worker nodes (not control plane to save CP resources for LLM work)
# 2Gi heap + 4Gi memory limit for stable operation
apiVersion: v1
kind: ConfigMap
metadata:
name: elasticsearch-config
namespace: temporal
data:
elasticsearch.yml: |
cluster.name: temporal-elasticsearch
node.name: temporal-elasticsearch-0
discovery.type: single-node
network.host: 0.0.0.0
http.host: 0.0.0.0
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: temporal-elasticsearch
namespace: temporal
spec:
replicas: 1
selector:
matchLabels:
app: temporal-elasticsearch
template:
metadata:
labels:
app: temporal-elasticsearch
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-role.kubernetes.io/worker
operator: Exists
containers:
- name: elasticsearch
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.0
env:
- name: discovery.type
value: single-node
- name: "ES_JAVA_OPTS"
value: "-Xms2g -Xmx2g"
ports:
- containerPort: 9200
name: http
- containerPort: 9300
name: transport
livenessProbe:
httpGet:
path: /_cluster/health
port: 9200
initialDelaySeconds: 180
periodSeconds: 10
timeoutSeconds: 10
failureThreshold: 5
readinessProbe:
httpGet:
path: /_cluster/health?local=true
port: 9200
initialDelaySeconds: 150
periodSeconds: 10
timeoutSeconds: 10
failureThreshold: 5
resources:
requests:
cpu: 500m
memory: 2Gi
limits:
cpu: 2000m
memory: 4Gi
volumeMounts:
- name: config
mountPath: /usr/share/elasticsearch/config/elasticsearch.yml
subPath: elasticsearch.yml
volumes:
- name: config
configMap:
name: elasticsearch-config
---
apiVersion: v1
kind: Service
metadata:
name: temporal-elasticsearch
namespace: temporal
spec:
selector:
app: temporal-elasticsearch
ports:
- port: 9200
targetPort: 9200
name: http
- port: 9300
targetPort: 9300
name: transport
type: ClusterIP
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: temporal
resources:
- elasticsearch.yaml
- queues/
# SOPS secrets (*.enc.yaml) handled by ArgoCD SOPS plugin at sync time
# temporal deployed via ArgoCD Helm source
@@ -0,0 +1,38 @@
# Example Queues for "production" Temporal namespace
# When applied, queue-operator creates 1 TemporalWorker (worker-production)
# that processes ALL queues in the "production" namespace
---
apiVersion: kmsvc.io/v1
kind: Queue
metadata:
name: story-crater-tasks
namespace: sqs
labels:
temporal.io/namespace: "production"
spec:
fifoQueue: false
visibilityTimeoutSeconds: 30
messageRetentionPeriodSeconds: 345600
maxReceiveCount: 5
partitionsPerShard: 6
minShards: 1
maxShards: 8
shardSplitThresholdBytesPerSec: 5242880
---
apiVersion: kmsvc.io/v1
kind: Queue
metadata:
name: story-crater-notifications
namespace: sqs
labels:
temporal.io/namespace: "production"
spec:
fifoQueue: false
visibilityTimeoutSeconds: 60
messageRetentionPeriodSeconds: 345600
maxReceiveCount: 3
partitionsPerShard: 3
minShards: 1
maxShards: 4
shardSplitThresholdBytesPerSec: 2621440
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- example-queue.yaml
@@ -0,0 +1,18 @@
temporal:
oidc_client_id: ENC[AES256_GCM,data:y6eSNJpCNCg=,iv:4n5fIeiVG2JZOCD4yZ4Asm/gWvRl0QNFiRGTR1bs8ws=,tag:liLSr9sadR82UPq6d3gqGA==,type:str]
oidc_client_secret: ENC[AES256_GCM,data:ebk0FVtSfq7JbIT+84cigdLg8zfN+4HTfGxlgqLw6hs1H92SFRHQFXO1l3U=,iv:qxa8aqUVoN31PpzAhs6bNMDCM6yPvOT27urIrM1rfo0=,tag:Vo25X8Znw7eh5DNq1eIJEw==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArZ1pyMXZ0Y3RFQVZkcTg5
aENyemRueXpIM0FaUVhMc2ZyVDRyWWlzK1JzCjA0OTZ0aGJDNVVSM29CY1RzQWJL
eVNiSCtMSXdmbEtKeGt1L3NYMy9ibUEKLS0tIGJnU3A4MjVnZ1pCa1lPMnVoM0xo
dWs1cVM5ZCszbXp6eFltRVhGbFc0ajQKyCc8lClnSqWUxhNOr1FDCwn5V7nvjxPN
7kjQpldseaRbsy+TM5sFQ1w6Acmun9uYjzs8PtmTNaayc/AFfspufA==
-----END AGE ENCRYPTED FILE-----
recipient: age1smu533f803gmd0jq60s2zaj9zlznajy0ca6rtewd4r37mr2hs3uqsrldfh
lastmodified: "2026-07-15T23:22:55Z"
mac: ENC[AES256_GCM,data:0YuRxQHfdeKbjJzxHI4iBc7I2KnalNJmybB6bVQxvdRutsxKXW/bu99yqHkD4y/nI66Pi6d7LhrPhhGat3x2c6Drs7QRYVWnZ75thil6yWsOcpH7H1esxEWTP3cpoMzGdS0aF6zwU9H+WDvfm/t1wpeDpJly3eczcPvfbK1joDU=,iv:TgwfpI+ZVLyd2c/PKWsJbCI9yKkADpPPafJ5No0fLks=,tag:nqwQHtZ263CI+fY9iTUAzQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -0,0 +1,110 @@
# k8s/temporal/temporal-values.yaml
# Temporal — workflow engine for story-crater backend async task orchestration.
# Chart: temporal/temporal from https://go.temporal.io/helm-charts
#
# Uses Cassandra for default store (workflow history/events)
# Uses Elasticsearch for visibility store (namespace/workflow queries)
# This is the chart's native, well-tested configuration.
# ── Datastores configuration ────
# Disable auto-deployed PostgreSQL (we use external ddb for other services)
postgresql:
enabled: false
# Enable Elasticsearch for visibility store (deployed to worker node, 2Gi/4Gi memory)
elasticsearch:
enabled: true
scheme: http
host: temporal-elasticsearch
port: 9200
version: v7
logLevel: error
auth:
enabled: false
indices:
visibility: temporal_visibility_v1
# Cassandra enabled for template validation; server.config overrides with actual hosts
# Schema job template requires cassandra config to exist at top level
cassandra:
enabled: true
replicas: 3
cluster:
seedSize: 1
port: 9042
# Was landing 2 of 3 replicas on talos-cp-1 -- spread across nodes so a
# single overloaded node can't stall gossip/join for the whole ring.
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: cassandra
release: temporal
topologyKey: kubernetes.io/hostname
# ── Disable schema auto-setup (will initialize manually) ─────────
jobs:
autoSetup:
enabled: false
# ── Temporal server config (Cassandra + Elasticsearch persistence) ──────────────────────────────
server:
replicaCount: 1
jobService:
enabled: false
# Spread frontend/history/matching/worker across nodes instead of letting
# them stack on whichever node the scheduler prefers (was: 59 of ~80
# cluster pods on talos-cp-1 alone). History's ringpop gossip join was
# timing out because 3 of its 4 peers sat on that overloaded node.
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app.kubernetes.io/instance: temporal
topologyKey: kubernetes.io/hostname
config:
logLevel: "info"
persistence:
defaultStore: default
visibilityStore: visibility
numHistoryShards: 512
datastores:
default:
# Cassandra for workflow history and events
driver: cassandra
cassandra:
hosts: "temporal-cassandra"
port: 9042
keyspace: temporal
user: user
password: "" # Cassandra auth disabled in deployment
replicationFactor: 3
consistency:
default:
consistency: local_quorum
serialConsistency: local_serial
service:
type: ClusterIP
# ── Temporal Web UI ────────────────────────────────────────────────────────
web:
replicaCount: 1
service:
type: ClusterIP
# ── Ingress ────────────────────────────────────────────────────────
# Note: ingress is disabled here. Instead, we route via oauth2-proxy.
# The ingress is applied separately as k8s/temporal/temporal-ingress-oauth2.yaml
# which terminates TLS and routes to oauth2-proxy service.
ingress:
enabled: false
# ── Monitoring ────────────────────────────────────────────────────────
prometheus:
enabled: false