k8s/services: add ingress networking portainer llm and project guides
- Nginx ingress + TLS termination (homelab-ca) - Portainer container UI - CoreDNS internal DNS rewrites - DuckDNS DDNS updater - Ollama LLM inference - 8 project-usage guides (team reference)
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# k8s/coredns/coredns-configmap.yaml
|
||||
# Patches the CoreDNS Corefile to rewrite homelab hostnames to internal services.
|
||||
#
|
||||
# Why this is needed:
|
||||
# Grafana v10+ does OIDC auto-discovery by fetching
|
||||
# /.well-known/openid-configuration from Authentik. When Grafana reaches
|
||||
# Authentik via the external hostname (authentik.riotpiao.homelab.com), the
|
||||
# HTTP Host header is preserved and Authentik returns external URLs in the
|
||||
# discovery response. Without this rewrite, the hostname doesn't resolve
|
||||
# inside the cluster and Grafana falls back to the internal service DNS,
|
||||
# causing all OAuth redirects to go to authentik-server.iam.svc.cluster.local.
|
||||
#
|
||||
# Applied by helmfile presync hook on the ingress-nginx release.
|
||||
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: coredns
|
||||
namespace: kube-system
|
||||
data:
|
||||
Corefile: |
|
||||
.:53 {
|
||||
errors
|
||||
health {
|
||||
lameduck 5s
|
||||
}
|
||||
ready
|
||||
log . {
|
||||
class error
|
||||
}
|
||||
prometheus :9153
|
||||
|
||||
# Forgejo: route to HTTP service (LoadBalancer handles both HTTPS:443 + SSH:2222 via same IP)
|
||||
rewrite name forgejo.riotpiao.homelab.com forgejo-gitea-http.cicd.svc.cluster.local
|
||||
|
||||
# Rewrite homelab hostnames to the nginx ingress controller so in-cluster pods
|
||||
# hit nginx TLS termination (cert-manager cert) and preserve the Host header.
|
||||
# Routing through nginx — not directly to the backend service — is critical:
|
||||
# direct rewrites to the backend bypass nginx TLS and expose each app's own
|
||||
# self-signed cert, which nothing in the cluster trusts.
|
||||
rewrite name authentik.riotpiao.homelab.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
||||
rewrite name grafana.riotpiao.homelab.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
||||
rewrite name minio.riotpiao.homelab.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
||||
rewrite name minio-api.riotpiao.homelab.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
||||
rewrite name argocd.riotpiao.homelab.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
||||
rewrite name vault.riotpiao.homelab.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
||||
rewrite name loki.riotpiao.homelab.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
||||
rewrite name prometheus.riotpiao.homelab.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
||||
rewrite name portainer.riotpiao.homelab.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
||||
rewrite name longhorn.riotpiao.homelab.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
||||
|
||||
kubernetes cluster.local in-addr.arpa ip6.arpa {
|
||||
pods insecure
|
||||
fallthrough in-addr.arpa ip6.arpa
|
||||
ttl 30
|
||||
}
|
||||
forward . 8.8.8.8 1.1.1.1 {
|
||||
max_concurrent 1000
|
||||
}
|
||||
cache 30 {
|
||||
disable success cluster.local
|
||||
disable denial cluster.local
|
||||
}
|
||||
loop
|
||||
reload
|
||||
loadbalance
|
||||
}
|
||||
@@ -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,297 @@
|
||||
# k8s/ingress/ingress.yaml
|
||||
# Ingress rules for all homelab services.
|
||||
# TLS is handled centrally: nginx serves the wildcard-tls cert (*.riotpiao.homelab.com)
|
||||
# as its default-ssl-certificate. No per-rule tls: blocks or cert-manager annotations
|
||||
# are needed — cert-manager manages one cert, nginx uses it for all hosts.
|
||||
#
|
||||
# DNS: *.riotpiao.homelab.com must resolve to 10.6.0.1 (WireGuard) or 192.168.1.160 (LAN).
|
||||
|
||||
# ── Grafana ───────────────────────────────────────────────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: grafana
|
||||
namespace: logging
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: grafana.riotpiao.homelab.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: grafana
|
||||
port:
|
||||
number: 80
|
||||
|
||||
---
|
||||
# ── Loki (API access for external tools) ─────────────────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: loki
|
||||
namespace: logging
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: loki.riotpiao.homelab.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: loki
|
||||
port:
|
||||
number: 3100
|
||||
|
||||
---
|
||||
# ── Authentik ─────────────────────────────────────────────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: authentik
|
||||
namespace: iam
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: authentik.riotpiao.homelab.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: authentik-server
|
||||
port:
|
||||
number: 80
|
||||
|
||||
---
|
||||
# ── Vault ─────────────────────────────────────────────────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: vault
|
||||
namespace: storage
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: vault.riotpiao.homelab.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: vault
|
||||
port:
|
||||
number: 8200
|
||||
|
||||
---
|
||||
# ── MinIO console (storage namespace) ────────────────────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: minio
|
||||
namespace: storage
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "0"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
||||
nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||
nginx.ingress.kubernetes.io/session-cookie-name: "minio-console-affinity"
|
||||
nginx.ingress.kubernetes.io/session-cookie-max-age: "3600"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: minio.riotpiao.homelab.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: minio-console
|
||||
port:
|
||||
number: 9001
|
||||
|
||||
---
|
||||
# ── MinIO S3 API ──────────────────────────────────────────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: minio-api
|
||||
namespace: storage
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "0"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: minio-api.riotpiao.homelab.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: minio
|
||||
port:
|
||||
number: 9000
|
||||
|
||||
---
|
||||
# ── Prometheus ────────────────────────────────────────────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: prometheus
|
||||
namespace: monitoring
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: prometheus.riotpiao.homelab.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: prometheus-kube-prometheus-prometheus
|
||||
port:
|
||||
number: 9090
|
||||
|
||||
---
|
||||
# ── Portainer ─────────────────────────────────────────────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: portainer
|
||||
namespace: dashboard
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: portainer.riotpiao.homelab.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: portainer
|
||||
port:
|
||||
number: 9000
|
||||
|
||||
---
|
||||
# ── Forgejo (git forge + OCI registry UI) ────────────────────────────────────
|
||||
# nginx terminates TLS using the wildcard cert, then proxies plain HTTP to
|
||||
# Forgejo on port 3000. ROOT_URL stays https:// so Forgejo generates correct URLs.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: forgejo
|
||||
namespace: cicd
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "0"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: forgejo.riotpiao.homelab.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: forgejo-gitea-http
|
||||
port:
|
||||
number: 3000
|
||||
|
||||
---
|
||||
# ── Argo CD (cicd namespace) ──────────────────────────────────────────────────
|
||||
# argocd-server runs HTTPS internally — nginx proxies via backend-protocol: HTTPS.
|
||||
# proxy-ssl-verify: off because argocd-server's pod cert is self-signed (not homelab-ca).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: argocd
|
||||
namespace: cicd
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
|
||||
nginx.ingress.kubernetes.io/proxy-ssl-verify: "off"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: argocd.riotpiao.homelab.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: argocd-server
|
||||
port:
|
||||
number: 443
|
||||
|
||||
---
|
||||
# ── Longhorn UI ───────────────────────────────────────────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: longhorn
|
||||
namespace: longhorn-system
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: longhorn.riotpiao.homelab.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: longhorn-frontend
|
||||
port:
|
||||
number: 80
|
||||
|
||||
---
|
||||
# ── Temporal Web UI ────────────────────────────────────────────────────────────
|
||||
# Temporal workflow orchestration Web UI with OIDC authentication
|
||||
# TLS: wildcard cert managed by cert-manager, served by nginx default-ssl-certificate
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: temporal
|
||||
namespace: temporal
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: temporal.riotpiao.homelab.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: temporal-web
|
||||
port:
|
||||
number: 8080
|
||||
@@ -0,0 +1,78 @@
|
||||
# k8s/ingress/nginx-values.yaml
|
||||
# Nginx Ingress Controller — bare-metal homelab config.
|
||||
# LoadBalancer service with Cilium LB-IPAM assigns fixed IP (192.168.1.160).
|
||||
# Access services at https://grafana.riotpiao.homelab.com (80/443 via LoadBalancer).
|
||||
|
||||
controller:
|
||||
kind: DaemonSet
|
||||
|
||||
# Single wildcard cert served for every *.riotpiao.homelab.com host.
|
||||
# Applied by the ingress-nginx presync hook (wildcard-cert.yaml) before nginx starts.
|
||||
# nginx hot-reloads when cert-manager renews homelab-tls — no restart needed.
|
||||
extraArgs:
|
||||
default-ssl-certificate: "ingress-nginx/homelab-tls"
|
||||
|
||||
hostPort:
|
||||
enabled: true
|
||||
ports:
|
||||
http: 80
|
||||
https: 443
|
||||
|
||||
# TCP proxy: forward port 2222 on every node → Forgejo SSH service.
|
||||
# This lets `git clone [email protected]:repo` work via the
|
||||
# same hostname as HTTPS without a separate LoadBalancer IP for SSH.
|
||||
tcp:
|
||||
2222: "cicd/forgejo-gitea-ssh:2222"
|
||||
|
||||
# Service as LoadBalancer — Cilium LB-IPAM assigns fixed IP.
|
||||
service:
|
||||
type: LoadBalancer
|
||||
annotations:
|
||||
io.cilium/lb-ipam-ips: "192.168.1.160"
|
||||
|
||||
# Allow the controller to land on the control-plane node.
|
||||
tolerations:
|
||||
- key: node-role.kubernetes.io/control-plane
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
|
||||
# Use the ingress-nginx IngressClass by default.
|
||||
ingressClassResource:
|
||||
default: true
|
||||
|
||||
# Required when using hostPort so DNS resolves correctly inside the pod.
|
||||
dnsPolicy: ClusterFirstWithHostNet
|
||||
|
||||
# Reduce noise in a single-admin homelab.
|
||||
admissionWebhooks:
|
||||
enabled: false
|
||||
|
||||
# ── Connection timeouts to upstreams ──────────────────────────────────────────
|
||||
# Increased to tolerate 5+ second pod-to-pod network latency spikes.
|
||||
# Default: 60s for all — acceptable but explicitly set for clarity.
|
||||
config:
|
||||
upstream-connect-timeout: "60"
|
||||
upstream-send-timeout: "60"
|
||||
upstream-read-timeout: "60"
|
||||
keepalive-timeout: "65"
|
||||
keepalive-requests: "100"
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 128Mi
|
||||
|
||||
# RED metrics (rate/errors/duration) for every host fronted by this controller —
|
||||
# every exposed service in the cluster goes through here, so this single block
|
||||
# is the cluster-wide "latency and availability" signal. Prometheus auto-discovers
|
||||
# the ServiceMonitor (serviceMonitorSelectorNilUsesHelmValues: false in prometheus-values.yaml).
|
||||
metrics:
|
||||
enabled: true
|
||||
serviceMonitor:
|
||||
enabled: true
|
||||
namespace: ingress-nginx
|
||||
interval: 30s
|
||||
scrapeTimeout: 60s
|
||||
@@ -0,0 +1,22 @@
|
||||
# k8s/ingress/wildcard-cert.yaml
|
||||
# Single wildcard TLS certificate for all *.riotpiao.homelab.com services.
|
||||
# Lives in the ingress-nginx namespace and is set as nginx's default-ssl-certificate,
|
||||
# so every ingress host gets it automatically — no per-service TLS blocks needed.
|
||||
#
|
||||
# Renewal: cert-manager auto-renews 30 days before expiry (renewBefore: 720h).
|
||||
# nginx detects the secret update via its K8s watch and hot-reloads — no pod restart.
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: homelab-tls
|
||||
namespace: ingress-nginx
|
||||
spec:
|
||||
secretName: homelab-tls
|
||||
dnsNames:
|
||||
- "*.riotpiao.homelab.com"
|
||||
- "riotpiao.homelab.com"
|
||||
issuerRef:
|
||||
name: homelab-ca
|
||||
kind: ClusterIssuer
|
||||
duration: 8760h # 1 year
|
||||
renewBefore: 720h # renew 30 days before expiry
|
||||
@@ -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
|
||||
@@ -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!"
|
||||
Executable
+54
@@ -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,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,183 @@
|
||||
# Authentik Federated OIDC & SSO
|
||||
|
||||
**Provider:** `https://authentik.riotpiao.homelab.com`
|
||||
**OIDC Issuer:** `https://authentik.riotpiao.homelab.com/application/o/talos-federation/`
|
||||
**Namespace:** `iam`
|
||||
|
||||
## When to Use
|
||||
|
||||
- **Federated login** — Single sign-on for Grafana, MinIO, Forgejo, Argo CD
|
||||
- **User groups** — RBAC via group membership (admins, devops, read-only)
|
||||
- **JWT tokens** — Authenticate CLI tools, API clients
|
||||
- **SSO for custom apps** — OAuth2/OIDC redirect flow
|
||||
|
||||
## Quick Start
|
||||
|
||||
**1. Login to Authentik console:**
|
||||
```bash
|
||||
# Browser: https://authentik.riotpiao.homelab.com
|
||||
# Default user: akadmin
|
||||
# Password: AUTHENTIK_BOOTSTRAP_PASSWORD (from .env)
|
||||
|
||||
# Or via OIDC (after initial setup)
|
||||
# Click "Sign in with talos-federation"
|
||||
```
|
||||
|
||||
**2. Create user:**
|
||||
```
|
||||
Authentik console → Users → Create
|
||||
- Username: alice
|
||||
- Email: alice@example.com
|
||||
- Group: homelab-devs (or homelab-admins)
|
||||
```
|
||||
|
||||
**3. User logs into Grafana:**
|
||||
```
|
||||
https://grafana.riotpiao.homelab.com
|
||||
→ Sign in with Authentik (auto-redirects to OIDC provider)
|
||||
→ Approve access
|
||||
→ Logged in as alice (group determines role: Admin or Viewer)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| OIDC provider | `talos-federation` (federated) |
|
||||
| OIDC issuer | `https://authentik.riotpiao.homelab.com/application/o/talos-federation/` |
|
||||
| JWKS endpoint | `https://authentik.riotpiao.homelab.com/application/o/talos-federation/.well-known/openid-configuration` |
|
||||
| Database | PostgreSQL (ddb namespace, authentik user) |
|
||||
| Backups | WAL archived to MinIO |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Grafana OIDC login:**
|
||||
```yaml
|
||||
# k8s/logging/grafana-values.yaml
|
||||
grafana:
|
||||
auth.generic_oauth:
|
||||
enabled: true
|
||||
name: Authentik
|
||||
client_id: grafana
|
||||
client_secret: $GRAFANA_OIDC_CLIENT_SECRET # from Vault
|
||||
auth_url: https://authentik.riotpiao.homelab.com/application/o/authorize/
|
||||
token_url: https://authentik.riotpiao.homelab.com/application/o/token/
|
||||
api_url: https://authentik.riotpiao.homelab.com/application/o/userinfo/
|
||||
scopes: openid profile email groups
|
||||
use_pkce: true
|
||||
```
|
||||
|
||||
**MinIO OIDC login:**
|
||||
```yaml
|
||||
# k8s/storage/minio-values.yaml
|
||||
minio:
|
||||
identity_oauth:
|
||||
provider: authentik
|
||||
client_id: minio
|
||||
client_secret: $MINIO_OIDC_CLIENT_SECRET
|
||||
redirect_uri: https://minio.riotpiao.homelab.com/oauth_callback
|
||||
config_url: https://authentik.riotpiao.homelab.com/application/o/talos-federation/.well-known/openid-configuration
|
||||
policy_mappings:
|
||||
- group: homelab-admins → consoleAdmin
|
||||
- group: homelab-devops → readwrite
|
||||
```
|
||||
|
||||
**CLI device code flow (talos-cli):**
|
||||
```bash
|
||||
# Get JWT token (no kubeconfig needed)
|
||||
talos secrets login
|
||||
# → Opens browser, approve device code
|
||||
# → Token cached in ~/.talos/token
|
||||
|
||||
# Use token to access Vault
|
||||
talos get cluster/ANTHROPIC_API_KEY --key ANTHROPIC_API_KEY
|
||||
# → Vault validates JWT from Authentik
|
||||
# → Returns secret
|
||||
```
|
||||
|
||||
**Custom app OIDC redirect:**
|
||||
```go
|
||||
import "github.com/coreos/go-oidc/v3/oidc"
|
||||
|
||||
provider, _ := oidc.NewProvider(ctx, "https://authentik.riotpiao.homelab.com/application/o/talos-federation/")
|
||||
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: "my-app"})
|
||||
|
||||
// After OAuth2 redirect & token exchange:
|
||||
idToken, _ := verifier.Verify(ctx, rawIDToken)
|
||||
|
||||
// Extract claims
|
||||
var claims struct {
|
||||
Email string `json:"email"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
idToken.Claims(&claims)
|
||||
```
|
||||
|
||||
## Group-Based RBAC
|
||||
|
||||
**Default groups:**
|
||||
- `homelab-admins` — Full cluster access (Grafana Admin, MinIO admin, Argo CD admin, Vault admin)
|
||||
- `homelab-devops` — Deploy & monitor (Grafana Editor, MinIO readwrite, Argo CD user)
|
||||
- `homelab-viewers` — Read-only (Grafana Viewer, MinIO readonly)
|
||||
|
||||
**Assign user to group:**
|
||||
```
|
||||
Authentik console → Users → alice → Edit
|
||||
→ Groups → Add "homelab-devops"
|
||||
→ Save
|
||||
```
|
||||
|
||||
**Custom group-to-role mapping:**
|
||||
```yaml
|
||||
# Per-service (see cicd-workflow.md, monitoring-metrics.md for examples)
|
||||
# Grafana: auth.generic_oauth.role_attribute_path = contains(groups[*], 'homelab-admins') && 'Admin' || 'Viewer'
|
||||
# MinIO: policy_mappings (see above)
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
**Authentik dashboard:** https://authentik.riotpiao.homelab.com/api/v3/admin/dashboards
|
||||
|
||||
**Key metrics:**
|
||||
- Login attempts (success/failure)
|
||||
- Active sessions
|
||||
- Token issuance rate
|
||||
- Provider sync status
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Users can't login (redirect loop):**
|
||||
```bash
|
||||
# Check redirect URI matches
|
||||
# Authentik console → Applications → grafana → Edit
|
||||
# Verify Redirect URI = https://grafana.riotpiao.homelab.com/login/generic_oauth
|
||||
|
||||
# Check OIDC provider is running
|
||||
k get pods -n iam -l app=authentik
|
||||
```
|
||||
|
||||
**JWT token expired:**
|
||||
```bash
|
||||
# CLI tokens have 24h expiry
|
||||
# Re-authenticate
|
||||
talos secrets login
|
||||
```
|
||||
|
||||
**Groups not syncing:**
|
||||
```bash
|
||||
# Check group attribute in OIDC config
|
||||
# Authentik console → Applications → <app> → OIDC Configuration
|
||||
# groups_attribute = "groups" (or custom claim name)
|
||||
```
|
||||
|
||||
**Vault can't validate JWT:**
|
||||
```bash
|
||||
# Verify JWKS endpoint is accessible
|
||||
curl https://authentik.riotpiao.homelab.com/application/o/talos-federation/.well-known/openid-configuration
|
||||
|
||||
# Restart Vault to refresh JWKS cache
|
||||
k rollout restart -n iam deployment/vault
|
||||
```
|
||||
|
||||
See `/TROUBLESHOOTING.md` for full incident guide.
|
||||
@@ -0,0 +1,222 @@
|
||||
# CI/CD Pipeline (Forgejo + Argo CD)
|
||||
|
||||
**Git Forge:** `https://forgejo.riotpiao.homelab.com`
|
||||
**Deployments:** `https://argocd.riotpiao.homelab.com` (or `kubectl port-forward`)
|
||||
**Namespaces:** `cicd`, `forge`
|
||||
|
||||
## When to Use
|
||||
|
||||
- **Build & test** — Forgejo Actions CI (GitHub Actions syntax)
|
||||
- **Image push** — Build OCI images, push to Forgejo registry
|
||||
- **GitOps deployment** — Argo CD syncs deploy repo to cluster
|
||||
- **Secrets in CI** — ci-bot JWT tokens, never kubeconfig
|
||||
|
||||
## Quick Start
|
||||
|
||||
**1. Clone a repo from Forgejo:**
|
||||
```bash
|
||||
git clone https://forgejo.riotpiao.homelab.com/rock/source.git
|
||||
cd source
|
||||
```
|
||||
|
||||
**2. Create workflow:**
|
||||
```bash
|
||||
mkdir -p .forgejo/workflows
|
||||
cat > .forgejo/workflows/ci.yml <<EOF
|
||||
name: CI
|
||||
on: [push]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: npm test
|
||||
- run: docker build -t myapp:latest .
|
||||
- run: |
|
||||
docker login forgejo.riotpiao.homelab.com \
|
||||
-u ci-bot \
|
||||
-p ${{ secrets.CI_BOT_TOKEN }}
|
||||
docker push forgejo.riotpiao.homelab.com/rock/myapp:latest
|
||||
EOF
|
||||
|
||||
git add .forgejo/workflows/ci.yml
|
||||
git commit -m "ci: add build workflow"
|
||||
git push
|
||||
```
|
||||
|
||||
**3. Trigger deployment:**
|
||||
```bash
|
||||
# Update deployment repo (rock/deploy)
|
||||
git clone https://forgejo.riotpiao.homelab.com/rock/deploy.git
|
||||
cd deploy
|
||||
|
||||
# Update image tag
|
||||
sed -i 's|forgejo.riotpiao.homelab.com/rock/myapp:.*|forgejo.riotpiao.homelab.com/rock/myapp:abc123|' k8s/deployment.yaml
|
||||
|
||||
git add k8s/deployment.yaml
|
||||
git commit -m "deploy: bump myapp to abc123"
|
||||
git push
|
||||
```
|
||||
|
||||
**4. Argo CD auto-syncs:**
|
||||
```bash
|
||||
# Watch deployment
|
||||
k rollout status -n <app-namespace> deployment/<app-name>
|
||||
|
||||
# Or check Argo CD UI
|
||||
kubectl port-forward -n argocd svc/argocd-server 8443:443
|
||||
# https://localhost:8443 (login via Authentik)
|
||||
```
|
||||
|
||||
## Workflow Syntax (GitHub Actions)
|
||||
|
||||
**Basic structure:**
|
||||
```yaml
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
|
||||
build:
|
||||
needs: test # wait for test job
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: docker build -t myapp:${{ github.sha }} .
|
||||
- run: docker push forgejo.riotpiao.homelab.com/rock/myapp:${{ github.sha }}
|
||||
```
|
||||
|
||||
**Available variables:**
|
||||
```bash
|
||||
${{ github.sha }} # commit hash
|
||||
${{ github.ref_name }} # branch name
|
||||
${{ github.run_number }} # build #
|
||||
${{ secrets.CI_BOT_TOKEN }} # injected from Forgejo
|
||||
```
|
||||
|
||||
## Secrets & Authentication
|
||||
|
||||
**ci-bot JWT token (auto-injected):**
|
||||
```yaml
|
||||
- run: |
|
||||
echo "${{ secrets.CI_BOT_TOKEN }}" | docker login \
|
||||
forgejo.riotpiao.homelab.com \
|
||||
-u ci-bot \
|
||||
--password-stdin
|
||||
docker push forgejo.riotpiao.homelab.com/rock/myapp:latest
|
||||
```
|
||||
|
||||
**API token (for pushing commits):**
|
||||
```yaml
|
||||
- run: |
|
||||
git config user.name "ci-bot"
|
||||
git config user.email "ci-bot@homelab"
|
||||
git commit --allow-empty -m "bump: version"
|
||||
git push https://ci-bot:${{ secrets.CI_BOT_TOKEN }}@forgejo.riotpiao.homelab.com/rock/deploy.git main
|
||||
```
|
||||
|
||||
**Vault secrets (via talos CLI):**
|
||||
```bash
|
||||
# Not available in CI runner — use Argo CD post-sync hooks instead
|
||||
# Or inject via init container before workflow runs
|
||||
```
|
||||
|
||||
## Argo CD GitOps
|
||||
|
||||
**Create app (one-time):**
|
||||
```bash
|
||||
argocd app create story-crater \
|
||||
--repo https://forgejo.riotpiao.homelab.com/rock/deploy.git \
|
||||
--path k8s/ \
|
||||
--dest-server https://kubernetes.default.svc \
|
||||
--dest-namespace story-crater-backend \
|
||||
--sync-policy automated
|
||||
```
|
||||
|
||||
**Monitor sync:**
|
||||
```bash
|
||||
# CLI
|
||||
argocd app get story-crater
|
||||
argocd app logs story-crater
|
||||
|
||||
# UI: https://argocd.riotpiao.homelab.com
|
||||
# Login: Authentik SSO (homelab-admins group only)
|
||||
```
|
||||
|
||||
**Manual sync:**
|
||||
```bash
|
||||
argocd app sync story-crater
|
||||
argocd app wait story-crater
|
||||
```
|
||||
|
||||
## Security Rules
|
||||
|
||||
✅ **DO:**
|
||||
- Store credentials in Forgejo Secrets (auto-injected)
|
||||
- Use ci-bot JWT for image push only
|
||||
- Commit to deploy repo (triggers Argo CD)
|
||||
- Enable branch protection (require CI pass)
|
||||
|
||||
❌ **DON'T:**
|
||||
- Put kubeconfig in CI (Argo CD bridges gap)
|
||||
- Commit secrets to source repo
|
||||
- Use admin-bot in CI workflows (over-privileged)
|
||||
- Push images directly to cluster (use Argo CD)
|
||||
|
||||
## Monitoring
|
||||
|
||||
**Grafana dashboard:** `svc-forgejo`, `svc-argocd`
|
||||
|
||||
**Key metrics:**
|
||||
- `forgejo_workflows_running` — active workflows
|
||||
- `argocd_app_sync_duration_seconds` — deployment time
|
||||
- `argocd_app_info{sync_status="OutOfSync"}` — drift detection
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Workflow fails silently:**
|
||||
```bash
|
||||
# Check runner logs
|
||||
k logs -n cicd -f deploy/forgejo-runner
|
||||
|
||||
# Check if runner pod is healthy
|
||||
k get pods -n cicd -l app=forgejo-runner
|
||||
```
|
||||
|
||||
**Image push fails (401 Unauthorized):**
|
||||
```bash
|
||||
# Verify ci-bot token in Forgejo
|
||||
# Settings → Applications → ci-bot → check scopes (package:write)
|
||||
|
||||
# Or re-create token
|
||||
talos put cluster/iam/agents/ci-bot-token TOKEN="$(openssl rand -hex 32)"
|
||||
```
|
||||
|
||||
**Argo CD out-of-sync:**
|
||||
```bash
|
||||
# Check deploy repo changes
|
||||
argocd app diff story-crater
|
||||
|
||||
# Manual sync
|
||||
argocd app sync story-crater --prune
|
||||
```
|
||||
|
||||
**Webhook not triggering:**
|
||||
```bash
|
||||
# Verify Forgejo webhook config
|
||||
# Repo Settings → Webhooks → Check delivery logs
|
||||
|
||||
# Or manually trigger
|
||||
argocd app sync story-crater
|
||||
```
|
||||
|
||||
See `/TROUBLESHOOTING.md` for full incident guide.
|
||||
@@ -0,0 +1,188 @@
|
||||
# CloudNativePG PostgreSQL Database
|
||||
|
||||
**Host:** `ddb-cluster-rw.ddb.svc.cluster.local` (read-write)
|
||||
**Read replica:** `ddb-cluster-ro.ddb.svc.cluster.local` (read-only)
|
||||
**Port:** `5432`
|
||||
**Namespace:** `ddb`
|
||||
|
||||
## When to Use
|
||||
|
||||
- **Multi-replica HA** — 3 replicas, automatic failover
|
||||
- **pgvector extension** — Vector similarity search (LLM embeddings)
|
||||
- **Transactional data** — Authentik, Story Crater backend, custom apps
|
||||
- **Declarative backups** — Automated WAL archiving to MinIO
|
||||
|
||||
## Quick Start
|
||||
|
||||
**1. Connect from pod:**
|
||||
```bash
|
||||
# Inside a pod (inject secret mount)
|
||||
psql -h ddb-cluster-rw.ddb.svc.cluster.local \
|
||||
-U story_crater \
|
||||
-d story_crater \
|
||||
-W # prompt for password (from Secret)
|
||||
```
|
||||
|
||||
**2. Create database & user (one-time):**
|
||||
```bash
|
||||
# Already done by helmfile postsync hook
|
||||
# But if needed manually:
|
||||
|
||||
psql -h ddb-cluster-rw.ddb.svc.cluster.local \
|
||||
-U postgres \
|
||||
-c "CREATE DATABASE myapp OWNER postgres;"
|
||||
|
||||
psql -h ddb-cluster-rw.ddb.svc.cluster.local \
|
||||
-U postgres \
|
||||
-d myapp \
|
||||
-c "CREATE USER myapp_user WITH PASSWORD 'secret';"
|
||||
|
||||
psql -h ddb-cluster-rw.ddb.svc.cluster.local \
|
||||
-U postgres \
|
||||
-d myapp \
|
||||
-c "GRANT ALL PRIVILEGES ON DATABASE myapp TO myapp_user;"
|
||||
```
|
||||
|
||||
**3. Enable pgvector:**
|
||||
```bash
|
||||
psql -h ddb-cluster-rw.ddb.svc.cluster.local \
|
||||
-U postgres \
|
||||
-d myapp \
|
||||
-c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||||
```
|
||||
|
||||
**4. Create table with embeddings:**
|
||||
```sql
|
||||
CREATE TABLE documents (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
content TEXT,
|
||||
embedding vector(1536), -- OpenAI embeddings
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX ON documents USING IVFFLAT (embedding vector_cosine_ops);
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| Host (RW) | `ddb-cluster-rw.ddb.svc.cluster.local` |
|
||||
| Host (RO) | `ddb-cluster-ro.ddb.svc.cluster.local` |
|
||||
| Port | 5432 |
|
||||
| Replicas | 3 (automatic failover) |
|
||||
| Extensions | pgvector (LLM embeddings), uuid-ossp |
|
||||
| Backups | WAL archiving to MinIO (continuous) |
|
||||
| Retention | 30 days |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Connection pooling (from app):**
|
||||
```go
|
||||
import "github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
config, _ := pgxpool.ParseConfig("postgres://user:pass@ddb-cluster-rw.ddb.svc.cluster.local:5432/myapp")
|
||||
config.MaxConns = 25
|
||||
config.MinConns = 5
|
||||
pool, _ := pgxpool.NewWithConfig(ctx, config)
|
||||
|
||||
// Use pool
|
||||
row := pool.QueryRow(ctx, "SELECT COUNT(*) FROM users")
|
||||
```
|
||||
|
||||
**Read from replica (analytics):**
|
||||
```go
|
||||
// Offload SELECT queries to read replica
|
||||
pool.QueryRow(ctx, "SELECT * FROM documents LIMIT 1") // auto-routes to RO if available
|
||||
|
||||
// Writes always go to RW
|
||||
pool.Exec(ctx, "INSERT INTO documents ...")
|
||||
```
|
||||
|
||||
**Vector similarity search:**
|
||||
```sql
|
||||
SELECT id, content, embedding <-> $1 AS distance
|
||||
FROM documents
|
||||
ORDER BY embedding <-> $1
|
||||
LIMIT 10;
|
||||
-- $1 = query embedding (e.g., from OpenAI API)
|
||||
```
|
||||
|
||||
**Backup & restore:**
|
||||
```bash
|
||||
# Backups are automatic (WAL to MinIO)
|
||||
# To restore from backup:
|
||||
# 1. Check MinIO s3://postgresql-backups/
|
||||
# 2. Use PostgreSQL PITR (point-in-time recovery)
|
||||
# 3. Contact SRE for restore procedure
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
**Grafana dashboard:** `svc-postgresql` (auto-configured)
|
||||
|
||||
**Key metrics:**
|
||||
- `pg_stat_activity_connections` — active connections
|
||||
- `pg_stat_database_blks_read` — disk I/O
|
||||
- `pg_replication_lag_seconds` — replica lag (goal: < 1s)
|
||||
|
||||
**CLI health check:**
|
||||
```bash
|
||||
# Check replication status
|
||||
kubectl exec -n ddb pod/ddb-cluster-1 -- \
|
||||
psql -U postgres -c "SELECT slot_name, restart_lsn FROM pg_replication_slots;"
|
||||
|
||||
# Check replica lag
|
||||
kubectl exec -n ddb pod/ddb-cluster-2 -- \
|
||||
psql -U postgres -c "SELECT now() - pg_last_xact_replay_timestamp() AS lag;"
|
||||
```
|
||||
|
||||
## Secrets & Credentials
|
||||
|
||||
**All user passwords stored in Vault:**
|
||||
```bash
|
||||
# Read password
|
||||
talos get cluster/STORY_CRATER_PG_PASSWORD --key STORY_CRATER_PG_PASSWORD
|
||||
|
||||
# Inject into pod (auto via Secret volume)
|
||||
# Mount: /run/secrets/db-password
|
||||
```
|
||||
|
||||
**Connection string from env:**
|
||||
```bash
|
||||
POSTGRES_CONNECTION="postgres://story_crater:${STORY_CRATER_PG_PASSWORD}@ddb-cluster-rw.ddb.svc.cluster.local:5432/story_crater"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Cannot connect (connection refused):**
|
||||
```bash
|
||||
# Verify cluster is running
|
||||
k get pods -n ddb
|
||||
|
||||
# Check Service DNS
|
||||
k exec -it pod/debug-pod -- nslookup ddb-cluster-rw.ddb.svc.cluster.local
|
||||
|
||||
# Verify Secret has password
|
||||
k get secret -n ddb ddb-cluster-superuser -o jsonpath='{.data.password}' | base64 -d
|
||||
```
|
||||
|
||||
**Replica lag is high (> 10s):**
|
||||
```bash
|
||||
# Check replica pod CPU/memory
|
||||
k top pod -n ddb
|
||||
|
||||
# Scale down other workloads if cluster is overloaded
|
||||
# Or scale up database resources (helmfile.yaml.gotmpl)
|
||||
```
|
||||
|
||||
**pgvector queries slow:**
|
||||
```sql
|
||||
-- Ensure index exists
|
||||
SELECT * FROM pg_indexes WHERE tablename = 'documents' AND indexname LIKE '%embedding%';
|
||||
|
||||
-- Re-index if missing
|
||||
CREATE INDEX ON documents USING IVFFLAT (embedding vector_cosine_ops);
|
||||
```
|
||||
|
||||
See `/TROUBLESHOOTING.md` for full incident guide.
|
||||
@@ -0,0 +1,162 @@
|
||||
# MinIO S3-Compatible Object Storage
|
||||
|
||||
**Endpoint:** `https://minio.riotpiao.homelab.com` (console)
|
||||
**API:** `minio.storage.svc.cluster.local:9000` (cluster-internal)
|
||||
**Namespace:** `storage`
|
||||
|
||||
## When to Use
|
||||
|
||||
- **File uploads** — Images, documents, backups
|
||||
- **Log backend** — Loki chunks storage
|
||||
- **Vault unsealing** — Store unseal keys
|
||||
- **CI/CD artifacts** — Build outputs, Docker layers cache
|
||||
|
||||
## Quick Start
|
||||
|
||||
**1. Access MinIO console:**
|
||||
```bash
|
||||
# Via browser: https://minio.riotpiao.homelab.com
|
||||
# Credentials: MINIO_ROOT_USER / MINIO_ROOT_PASSWORD (from .env)
|
||||
|
||||
# Or port-forward
|
||||
make pf-minio # localhost:9001
|
||||
```
|
||||
|
||||
**2. Create bucket:**
|
||||
```bash
|
||||
# Via AWS CLI
|
||||
export AWS_ACCESS_KEY_ID=$MINIO_ROOT_USER
|
||||
export AWS_SECRET_ACCESS_KEY=$MINIO_ROOT_PASSWORD
|
||||
|
||||
aws s3 mb s3://my-bucket \
|
||||
--endpoint-url https://minio.riotpiao.homelab.com \
|
||||
--region homelab
|
||||
|
||||
# Or via console UI: Click "Create Bucket"
|
||||
```
|
||||
|
||||
**3. Upload file:**
|
||||
```bash
|
||||
aws s3 cp /path/to/file.txt s3://my-bucket/ \
|
||||
--endpoint-url https://minio.storage.svc.cluster.local:9000 \
|
||||
--use-path-style
|
||||
```
|
||||
|
||||
**4. List buckets:**
|
||||
```bash
|
||||
aws s3 ls --endpoint-url https://minio.storage.svc.cluster.local:9000
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| Access key | `MINIO_ROOT_USER` (from .env) |
|
||||
| Secret key | `MINIO_ROOT_PASSWORD` (from .env) |
|
||||
| Cluster API | `minio.storage.svc.cluster.local:9000` |
|
||||
| Console port | `9001` |
|
||||
| Replication | 3-node site-replication (az-a ↔ az-b ↔ az-c) |
|
||||
| Buckets (system) | `loki-chunks`, `loki-ruler`, `vault-backups` |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Loki log backend (auto-configured):**
|
||||
```yaml
|
||||
# k8s/logging/loki-values.yaml
|
||||
loki:
|
||||
storage:
|
||||
s3:
|
||||
endpoint: minio.storage.svc.cluster.local:9000
|
||||
buckets: loki-chunks
|
||||
secretAccessKey: $MINIO_ROOT_PASSWORD
|
||||
accessKeyId: $MINIO_ROOT_USER
|
||||
```
|
||||
|
||||
**Application usage (Go/Python/Node):**
|
||||
```go
|
||||
import "github.com/minio/minio-go/v7"
|
||||
|
||||
client, _ := minio.New("minio.storage.svc.cluster.local:9000", &minio.Options{
|
||||
Creds: credentials.NewStaticV4(os.Getenv("MINIO_ROOT_USER"), os.Getenv("MINIO_ROOT_PASSWORD"), ""),
|
||||
Secure: false, // cluster-internal (no TLS)
|
||||
})
|
||||
|
||||
// Upload
|
||||
client.FPutObject(ctx, "my-bucket", "file.txt", "/path/to/file.txt", minio.PutObjectOptions{})
|
||||
|
||||
// Download
|
||||
client.FGetObject(ctx, "my-bucket", "file.txt", "/tmp/file.txt", minio.GetObjectOptions{})
|
||||
```
|
||||
|
||||
**Vault backup bucket:**
|
||||
```bash
|
||||
# Vault stores unseal keys in s3://vault-backups
|
||||
# Auto-managed by helmfile; no manual action needed
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
**Grafana dashboard:** `svc-minio`
|
||||
|
||||
**Key metrics:**
|
||||
- `minio_disk_drive_free_bytes` — available space
|
||||
- `minio_bucket_usage_object_count` — objects per bucket
|
||||
- `minio_bucket_usage_total_bytes` — total size per bucket
|
||||
|
||||
**Site replication status:**
|
||||
```bash
|
||||
# Port-forward to MinIO pod
|
||||
k port-forward -n storage pod/minio-0 9000:9000 &
|
||||
|
||||
# Check replication
|
||||
mc alias set local http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD
|
||||
mc admin replicate status local
|
||||
```
|
||||
|
||||
## Authentication (Cluster-Internal)
|
||||
|
||||
**From pods (cluster-internal):**
|
||||
```bash
|
||||
# Use credentials from Secret or env var
|
||||
export MINIO_ENDPOINT=minio.storage.svc.cluster.local:9000
|
||||
export MINIO_ACCESS_KEY=$MINIO_ROOT_USER
|
||||
export MINIO_SECRET_KEY=$MINIO_ROOT_PASSWORD
|
||||
aws s3 ls --endpoint-url http://$MINIO_ENDPOINT --use-path-style
|
||||
```
|
||||
|
||||
**External access (HTTPS via Ingress):**
|
||||
```bash
|
||||
# Console: https://minio.riotpiao.homelab.com (port 9001)
|
||||
# API: Use AWS CLI with --endpoint-url https://minio.riotpiao.homelab.com:9000
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Bucket creation fails:**
|
||||
```bash
|
||||
# Check MinIO pod logs
|
||||
k logs -n storage pod/minio-0 | grep -i error
|
||||
|
||||
# Verify storage space
|
||||
k get pvc -n storage
|
||||
```
|
||||
|
||||
**Site replication lag:**
|
||||
```bash
|
||||
# Check if all 3 nodes are healthy
|
||||
k get pods -n storage -l app=minio
|
||||
|
||||
# If one node is down, site replication queues changes (eventually consistent)
|
||||
```
|
||||
|
||||
**Access denied:**
|
||||
```bash
|
||||
# Verify credentials in .env
|
||||
echo $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD
|
||||
|
||||
# If credentials rotated, update Secret
|
||||
k patch secret -n storage minio-root-credentials \
|
||||
--type merge -p '{"stringData":{"MINIO_ROOT_PASSWORD":"newpass"}}'
|
||||
```
|
||||
|
||||
See `/TROUBLESHOOTING.md` for full incident guide.
|
||||
@@ -0,0 +1,265 @@
|
||||
# Monitoring: Prometheus, Grafana & Loki
|
||||
|
||||
**Prometheus:** `prometheus-kube-prom-prometheus.monitoring.svc.cluster.local:9090`
|
||||
**Grafana:** `https://grafana.riotpiao.homelab.com`
|
||||
**Loki:** `loki.logging.svc.cluster.local:3100`
|
||||
**Namespaces:** `monitoring`, `logging`
|
||||
|
||||
## When to Use
|
||||
|
||||
- **Metrics** — CPU, memory, latency, request rate, custom business metrics (Prometheus)
|
||||
- **Logs** — Pod logs, kernel logs, trace events (Loki)
|
||||
- **Dashboards** — Real-time visualization (Grafana)
|
||||
- **Alerts** — Page on high error rate, latency spike, or resource exhaustion
|
||||
|
||||
## Quick Start
|
||||
|
||||
**1. Access Grafana:**
|
||||
```bash
|
||||
# Browser: https://grafana.riotpiao.homelab.com
|
||||
# Login: admin / GRAFANA_ADMIN_PASSWORD (from .env)
|
||||
# Or via Authentik SSO
|
||||
|
||||
# Port-forward (if no external access)
|
||||
make pf-grafana # localhost:3000
|
||||
```
|
||||
|
||||
**2. View dashboards:**
|
||||
```
|
||||
Grafana → Dashboards → Homelab folder
|
||||
- Service Availability (uptime probes + cert expiry)
|
||||
- Latency & Golden Signals (request rate/error %/duration)
|
||||
- Hardware Statistics (per-node CPU/mem/disk)
|
||||
- Kube-Controller Health (API server metrics)
|
||||
- Service Internals (per-service deep-dive)
|
||||
```
|
||||
|
||||
**3. Add Prometheus data source:**
|
||||
```
|
||||
Grafana → Settings → Data Sources → Add
|
||||
- Type: Prometheus
|
||||
- URL: http://prometheus-kube-prom-prometheus.monitoring.svc.cluster.local:9090
|
||||
- Save & test
|
||||
```
|
||||
|
||||
**4. Create dashboard:**
|
||||
```json
|
||||
{
|
||||
"dashboard": {
|
||||
"title": "My Service",
|
||||
"panels": [
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(http_requests_total{service='myapp'}[5m])"
|
||||
}
|
||||
],
|
||||
"title": "Request Rate"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Instrumentation Patterns
|
||||
|
||||
**Go (Prometheus):**
|
||||
```go
|
||||
import "github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
// Counter (monotonic increase)
|
||||
requestsTotal := prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "http_requests_total",
|
||||
Help: "Total requests",
|
||||
},
|
||||
[]string{"method", "status"},
|
||||
)
|
||||
|
||||
// Gauge (can go up or down)
|
||||
activeConnections := prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "active_connections",
|
||||
})
|
||||
|
||||
// Histogram (latency buckets)
|
||||
requestDuration := prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "http_request_duration_seconds",
|
||||
Buckets: []float64{.001, .01, .1, 1},
|
||||
},
|
||||
[]string{"method", "path"},
|
||||
)
|
||||
|
||||
// Record metric
|
||||
requestsTotal.WithLabelValues("POST", "200").Inc()
|
||||
requestDuration.WithLabelValues("POST", "/api/users").Observe(0.042)
|
||||
```
|
||||
|
||||
**Node.js (prom-client):**
|
||||
```javascript
|
||||
const prometheus = require('prom-client');
|
||||
|
||||
const httpRequestDuration = new prometheus.Histogram({
|
||||
name: 'http_request_duration_seconds',
|
||||
help: 'Duration of HTTP requests in seconds',
|
||||
labelNames: ['method', 'path', 'status'],
|
||||
buckets: [0.001, 0.01, 0.1, 1],
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
const duration = (Date.now() - start) / 1000;
|
||||
httpRequestDuration.labels(req.method, req.path, res.statusCode).observe(duration);
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Expose metrics
|
||||
app.get('/metrics', (req, res) => {
|
||||
res.set('Content-Type', prometheus.register.contentType);
|
||||
res.end(prometheus.register.metrics());
|
||||
});
|
||||
```
|
||||
|
||||
## ServiceMonitor (Auto-Scrape)
|
||||
|
||||
**Prometheus automatically discovers ServiceMonitor resources:**
|
||||
```yaml
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: myapp
|
||||
namespace: myapp-ns
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: myapp
|
||||
endpoints:
|
||||
- port: metrics
|
||||
interval: 30s
|
||||
path: /metrics
|
||||
```
|
||||
|
||||
**Deploy & verify:**
|
||||
```bash
|
||||
kubectl apply -f myapp-servicemonitor.yaml
|
||||
|
||||
# Check Prometheus targets
|
||||
kubectl port-forward -n monitoring svc/prometheus-kube-prom-prometheus 9090:9090
|
||||
# http://localhost:9090/targets → should show myapp
|
||||
```
|
||||
|
||||
## PrometheusRule (Alerting)
|
||||
|
||||
**Define alert rules:**
|
||||
```yaml
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: myapp-alerts
|
||||
namespace: myapp-ns
|
||||
spec:
|
||||
groups:
|
||||
- name: myapp
|
||||
rules:
|
||||
- alert: HighErrorRate
|
||||
expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.05 # > 5% error rate
|
||||
for: 5m
|
||||
annotations:
|
||||
summary: "High error rate for {{ $labels.service }}"
|
||||
|
||||
- alert: HighLatency
|
||||
expr: histogram_quantile(0.99, http_request_duration_seconds_bucket) > 1 # p99 > 1s
|
||||
for: 10m
|
||||
annotations:
|
||||
summary: "High latency for {{ $labels.path }}"
|
||||
```
|
||||
|
||||
## Logging (Loki)
|
||||
|
||||
**View pod logs in Grafana:**
|
||||
```
|
||||
Grafana → Explore → Loki
|
||||
Query: {namespace="story-crater-backend"} | json | level="error"
|
||||
|
||||
Label filters:
|
||||
- namespace
|
||||
- pod_name
|
||||
- container
|
||||
- level (error, warn, info)
|
||||
```
|
||||
|
||||
**Log retention:**
|
||||
- 10-day default (configurable)
|
||||
- Older logs deleted automatically
|
||||
- Chunks stored in MinIO (s3://loki-chunks)
|
||||
|
||||
## Monitoring Checklist
|
||||
|
||||
**For every service:**
|
||||
- [ ] Export `/metrics` (Prometheus format)
|
||||
- [ ] Add ServiceMonitor resource
|
||||
- [ ] Add PrometheusRule (errors, latency)
|
||||
- [ ] Create Grafana dashboard (6 rows: Availability, Resources, Domain, Logs, SLO, Related)
|
||||
- [ ] Set up Slack/PagerDuty integration (if on-call)
|
||||
|
||||
## Key Queries
|
||||
|
||||
**Request rate by status:**
|
||||
```promql
|
||||
sum(rate(http_requests_total[5m])) by (status)
|
||||
```
|
||||
|
||||
**Error rate %:**
|
||||
```promql
|
||||
sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) * 100
|
||||
```
|
||||
|
||||
**P99 latency:**
|
||||
```promql
|
||||
histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))
|
||||
```
|
||||
|
||||
**Memory usage %:**
|
||||
```promql
|
||||
(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Prometheus can't scrape ServiceMonitor:**
|
||||
```bash
|
||||
# Check ServiceMonitor exists
|
||||
k get servicemonitor -A | grep myapp
|
||||
|
||||
# Verify Service has correct labels
|
||||
k get svc -A -o json | jq '.items[] | select(.metadata.name=="myapp") | .metadata.labels'
|
||||
|
||||
# Check Prometheus targets
|
||||
kubectl port-forward -n monitoring svc/prometheus-kube-prom-prometheus 9090:9090
|
||||
# http://localhost:9090/targets → look for "Down" targets with error
|
||||
```
|
||||
|
||||
**Metrics missing from Grafana:**
|
||||
```bash
|
||||
# Verify pod is exporting metrics
|
||||
k port-forward -n myapp-ns pod/myapp-0 8080:8080
|
||||
curl http://localhost:8080/metrics | grep my_metric_name
|
||||
|
||||
# Check query syntax in Grafana
|
||||
# Hover over query → "Query Inspector" to see response
|
||||
```
|
||||
|
||||
**Alert not firing:**
|
||||
```bash
|
||||
# Check PrometheusRule is loaded
|
||||
k get prometheusrule -n myapp-ns
|
||||
|
||||
# Verify query in Prometheus
|
||||
kubectl port-forward -n monitoring svc/prometheus-kube-prom-prometheus 9090:9090
|
||||
# http://localhost:9090 → Graph tab → paste query
|
||||
```
|
||||
|
||||
See `/TROUBLESHOOTING.md` for full incident guide.
|
||||
@@ -0,0 +1,314 @@
|
||||
# Networking: Ingress, TLS & Service Discovery
|
||||
|
||||
**Ingress Controller:** `nginx-ingress` (Nginx)
|
||||
**Load Balancer:** Cilium LB-IPAM (eBPF-based)
|
||||
**TLS CA:** homelab-ca (self-signed, 10-year)
|
||||
**Namespace:** `ingress-nginx`
|
||||
|
||||
## When to Use
|
||||
|
||||
- **Public HTTPS endpoints** — External access via TLS
|
||||
- **Hostname-based routing** — Multiple services on same IP
|
||||
- **TLS termination** — Offload encryption/decryption
|
||||
- **Service discovery** — Internal DNS (CoreDNS)
|
||||
|
||||
## Quick Start
|
||||
|
||||
**1. Create Ingress rule:**
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: myapp
|
||||
namespace: myapp-ns
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod" # or homelab-ca
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- myapp.riotpiao.homelab.com
|
||||
secretName: myapp-tls
|
||||
rules:
|
||||
- host: myapp.riotpiao.homelab.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: myapp-svc
|
||||
port:
|
||||
number: 8080
|
||||
```
|
||||
|
||||
**2. Deploy:**
|
||||
```bash
|
||||
kubectl apply -f ingress.yaml
|
||||
|
||||
# Wait for cert issuance
|
||||
kubectl get certificate -n myapp-ns
|
||||
# Should show "Ready" after ~30s
|
||||
```
|
||||
|
||||
**3. Test from client:**
|
||||
```bash
|
||||
# Add to /etc/hosts (or use WireGuard)
|
||||
192.168.1.160 myapp.riotpiao.homelab.com
|
||||
|
||||
# Access
|
||||
curl https://myapp.riotpiao.homelab.com
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| Ingress class | `nginx` |
|
||||
| Load balancer type | `LoadBalancer` (Cilium LB-IPAM) |
|
||||
| TLS issuer | `homelab-ca` (ClusterIssuer) |
|
||||
| TLS cert lifetime | 90 days (auto-renewed by cert-manager) |
|
||||
| DNS | CoreDNS (in-cluster), external via `/etc/hosts` or DuckDNS |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Ingress with path-based routing:**
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: api
|
||||
namespace: default
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- api.riotpiao.homelab.com
|
||||
secretName: api-tls
|
||||
rules:
|
||||
- host: api.riotpiao.homelab.com
|
||||
http:
|
||||
paths:
|
||||
- path: /users
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: users-svc
|
||||
port:
|
||||
number: 3000
|
||||
- path: /orders
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: orders-svc
|
||||
port:
|
||||
number: 3001
|
||||
```
|
||||
|
||||
**Ingress with basic auth:**
|
||||
```bash
|
||||
# Generate htpasswd
|
||||
htpasswd -c auth admin
|
||||
# → prompted for password
|
||||
|
||||
# Create Secret
|
||||
kubectl create secret generic basic-auth --from-file=auth -n default
|
||||
|
||||
# Create Ingress
|
||||
```
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: protected
|
||||
namespace: default
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-type: basic
|
||||
nginx.ingress.kubernetes.io/auth-secret: basic-auth
|
||||
nginx.ingress.kubernetes.io/auth-realm: 'Authentication Required'
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: protected.riotpiao.homelab.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: app-svc
|
||||
port:
|
||||
number: 8080
|
||||
```
|
||||
|
||||
**Internal DNS (CoreDNS rewrite):**
|
||||
```yaml
|
||||
# k8s/coredns/coredns-configmap.yaml
|
||||
# Rewrite:
|
||||
# - grafana.riotpiao.homelab.com → grafana.logging (cluster-internal)
|
||||
# - prometheus.riotpiao.homelab.com → prometheus-kube-prom-prometheus.monitoring
|
||||
#
|
||||
# Allows pods to use external URLs but resolve to internal Services
|
||||
```
|
||||
|
||||
**Fixed LoadBalancer IP (Cilium LB-IPAM):**
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ingress-nginx
|
||||
namespace: ingress-nginx
|
||||
annotations:
|
||||
io.cilium/lb-ipam-ips: "192.168.1.160" # fixed IP
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
selector:
|
||||
app: nginx-ingress
|
||||
ports:
|
||||
- port: 443
|
||||
targetPort: 443
|
||||
protocol: TCP
|
||||
```
|
||||
|
||||
## TLS Certificate Management
|
||||
|
||||
**Automatic renewal (cert-manager):**
|
||||
```yaml
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: myapp-cert
|
||||
namespace: myapp-ns
|
||||
spec:
|
||||
secretName: myapp-tls
|
||||
duration: 2160h # 90 days
|
||||
renewBefore: 360h # renew 15 days before expiry
|
||||
commonName: myapp.riotpiao.homelab.com
|
||||
dnsNames:
|
||||
- myapp.riotpiao.homelab.com
|
||||
issuerRef:
|
||||
name: homelab-ca
|
||||
kind: ClusterIssuer
|
||||
```
|
||||
|
||||
**Check certificate status:**
|
||||
```bash
|
||||
# List certs
|
||||
k get certificate -A
|
||||
|
||||
# View cert details
|
||||
k describe certificate -n myapp-ns myapp-cert
|
||||
|
||||
# View TLS Secret
|
||||
k get secret -n myapp-ns myapp-tls -o json | jq '.data."tls.crt"' | base64 -d | openssl x509 -text
|
||||
|
||||
# Check expiry date
|
||||
k get secret -n myapp-ns myapp-tls -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -enddate
|
||||
```
|
||||
|
||||
## Service Discovery
|
||||
|
||||
**Cluster-internal DNS:**
|
||||
```bash
|
||||
# From any pod, resolve via CoreDNS
|
||||
nslookup grafana.logging.svc.cluster.local # full FQDN
|
||||
nslookup grafana.logging # short form (same namespace)
|
||||
nslookup grafana # if in logging namespace
|
||||
|
||||
# Resolved to ClusterIP (internal only)
|
||||
```
|
||||
|
||||
**External DNS (WireGuard VPN or port-forward):**
|
||||
```bash
|
||||
# Option 1: WireGuard tunnel
|
||||
# Client connects to 10.6.0.1 (WireGuard server on talos-cp-1)
|
||||
# All traffic tunneled to cluster
|
||||
|
||||
# Option 2: Port-forward from jump box
|
||||
make pf-grafana # localhost:3000 → grafana.logging:3000
|
||||
|
||||
# Option 3: Add to /etc/hosts (on home network)
|
||||
192.168.1.160 grafana.riotpiao.homelab.com
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
**Grafana dashboard:** `svc-nginx-ingress`
|
||||
|
||||
**Key metrics:**
|
||||
- `nginx_requests_total` — total requests
|
||||
- `nginx_request_duration_seconds` — latency histogram
|
||||
- `nginx_ingress_upstream_requests_total{status=~"5.."}` — backend errors
|
||||
- `nginx_ssl_expire_time_seconds` — cert expiry countdown
|
||||
|
||||
**Alert on cert expiry:**
|
||||
```yaml
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: cert-expiry
|
||||
namespace: ingress-nginx
|
||||
spec:
|
||||
groups:
|
||||
- name: cert-expiry
|
||||
rules:
|
||||
- alert: CertificateExpiringSoon
|
||||
expr: nginx_ssl_expire_time_seconds < 86400 * 14 # < 14 days
|
||||
annotations:
|
||||
summary: "Certificate {{ $labels.host }} expires in {{ $value | humanizeDuration }}"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Certificate stuck in "Pending":**
|
||||
```bash
|
||||
# Check cert-manager logs
|
||||
k logs -n cert-manager -f deploy/cert-manager
|
||||
|
||||
# Verify ClusterIssuer exists
|
||||
k get clusterissuer
|
||||
|
||||
# Check ACME order (if using LetsEncrypt)
|
||||
k describe certificate -n myapp-ns myapp-cert
|
||||
```
|
||||
|
||||
**Ingress not exposing service (503 error):**
|
||||
```bash
|
||||
# Verify Service exists and has endpoints
|
||||
k get svc -n myapp-ns
|
||||
k get endpoints -n myapp-ns myapp-svc
|
||||
|
||||
# Check if pods are ready
|
||||
k get pods -n myapp-ns
|
||||
|
||||
# Test pod directly (port-forward)
|
||||
k port-forward -n myapp-ns pod/myapp-0 8080:8080
|
||||
curl http://localhost:8080
|
||||
```
|
||||
|
||||
**DNS resolution fails from pod:**
|
||||
```bash
|
||||
# Test from pod
|
||||
k run -it --rm debug --image=busybox:1.28 --restart=Never -- \
|
||||
nslookup grafana.logging.svc.cluster.local
|
||||
|
||||
# If fails, CoreDNS may be unhealthy
|
||||
k get pods -n kube-system -l k8s-app=kube-dns
|
||||
k logs -n kube-system -l k8s-app=kube-dns
|
||||
```
|
||||
|
||||
**TLS handshake error (cert not trusted):**
|
||||
```bash
|
||||
# Verify TLS cert Secret exists
|
||||
k get secret -n myapp-ns myapp-tls
|
||||
|
||||
# Verify cert is correctly signed by homelab-ca
|
||||
k get secret -n myapp-ns myapp-tls -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -text | grep -A 5 "Issuer:"
|
||||
|
||||
# If cert is self-signed (homelab-ca), add to client's trusted roots
|
||||
# Or bypass cert verification (dev only):
|
||||
curl -k https://myapp.riotpiao.homelab.com
|
||||
```
|
||||
|
||||
See `/TROUBLESHOOTING.md` for full incident guide.
|
||||
@@ -0,0 +1,193 @@
|
||||
# SQS-like Message Queue Service (kmsvc)
|
||||
|
||||
**Endpoint:** `https://kmsvc.riotpiao.homelab.com` (REST + gRPC-Gateway)
|
||||
**Internal:** `kmsvc-management-service.sqs.svc.cluster.local:8080`
|
||||
**Namespace:** `sqs`
|
||||
|
||||
## When to Use
|
||||
|
||||
- **Decouple services** — Producer doesn't wait for consumer
|
||||
- **Async jobs** — Fire-and-forget processing (batch, email, webhooks)
|
||||
- **FIFO ordering** — Guarantee message order within `MessageGroupId`
|
||||
- **Durable delivery** — At-least-once (messages in Kafka, replicated 3×)
|
||||
|
||||
## Quick Start
|
||||
|
||||
**1. Create a queue:**
|
||||
```bash
|
||||
kubectl apply -f - <<EOF
|
||||
apiVersion: kmsvc.io/v1
|
||||
kind: Queue
|
||||
metadata:
|
||||
name: orders
|
||||
spec:
|
||||
fifoQueue: false # standard queue
|
||||
visibilityTimeoutSeconds: 30 # re-deliver if not acked
|
||||
messageRetentionPeriodSeconds: 345600 # 4 days
|
||||
partitionsPerShard: 6
|
||||
maxReceiveCount: 5 # move to DLQ after 5 fails
|
||||
EOF
|
||||
```
|
||||
|
||||
**2. Send message:**
|
||||
```bash
|
||||
curl -X POST https://kmsvc.riotpiao.homelab.com/v1/queues/orders/messages \
|
||||
-H "Authorization: Bearer $JWT_TOKEN" \
|
||||
-d '{
|
||||
"body": "{\"order_id\":123,\"total\":99.99}",
|
||||
"attributes": {"source":"web","priority":"high"}
|
||||
}'
|
||||
```
|
||||
|
||||
**3. Receive message:**
|
||||
```bash
|
||||
curl "https://kmsvc.riotpiao.homelab.com/v1/queues/orders/messages?max_number_of_messages=10&wait_time_seconds=20" \
|
||||
-H "Authorization: Bearer $JWT_TOKEN"
|
||||
|
||||
# Response:
|
||||
# {
|
||||
# "messages": [
|
||||
# {
|
||||
# "message_id": "abc-123",
|
||||
# "receipt_handle": "...",
|
||||
# "body": "{...}",
|
||||
# "attributes": {...},
|
||||
# "receive_count": 1
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
```
|
||||
|
||||
**4. Acknowledge (delete) message:**
|
||||
```bash
|
||||
curl -X DELETE "https://kmsvc.riotpiao.homelab.com/v1/queues/orders/messages/$receipt_handle" \
|
||||
-H "Authorization: Bearer $JWT_TOKEN"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| Kafka bootstrap | `kmsvc-kafka-bootstrap.sqs.svc.cluster.local:9092` |
|
||||
| Redis | `kmsvc-redis-master.sqs.svc.cluster.local:6379` |
|
||||
| Topic naming | `kmsvc.{queueName}.shard-{id}` |
|
||||
| Replication | 3 replicas, min.insync.replicas=2 |
|
||||
| Retention | 4 days (configurable per queue) |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Batch processing:**
|
||||
```bash
|
||||
for i in {1..100}; do
|
||||
curl -X POST https://kmsvc.riotpiao.homelab.com/v1/queues/jobs/messages \
|
||||
-H "Authorization: Bearer $JWT_TOKEN" \
|
||||
-d "{\"body\":\"task-$i\"}" &
|
||||
done
|
||||
wait
|
||||
```
|
||||
|
||||
**FIFO queue (order guaranteed per group):**
|
||||
```yaml
|
||||
apiVersion: kmsvc.io/v1
|
||||
kind: Queue
|
||||
metadata:
|
||||
name: checkout-fifo
|
||||
spec:
|
||||
fifoQueue: true
|
||||
visibilityTimeoutSeconds: 60
|
||||
partitionsPerShard: 1
|
||||
```
|
||||
|
||||
**Dead-letter queue (failed messages):**
|
||||
```yaml
|
||||
apiVersion: kmsvc.io/v1
|
||||
kind: Queue
|
||||
metadata:
|
||||
name: orders-dlq
|
||||
spec:
|
||||
fifoQueue: false
|
||||
|
||||
---
|
||||
apiVersion: kmsvc.io/v1
|
||||
kind: Queue
|
||||
metadata:
|
||||
name: orders
|
||||
spec:
|
||||
fifoQueue: false
|
||||
maxReceiveCount: 3
|
||||
deadLetterTargetQueue: orders-dlq # auto-route failures here
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
**Grafana dashboard:** `svc-kmsvc` (automatically loaded)
|
||||
|
||||
**Key metrics:**
|
||||
- `kmsvc_messages_sent_total` — total sent
|
||||
- `kmsvc_messages_received_total` — total received
|
||||
- `kmsvc_queue_depth` — pending messages per queue
|
||||
- `kmsvc_message_visibility_timeout_seconds` — visibility window
|
||||
|
||||
**Redis in-flight tracking:**
|
||||
```bash
|
||||
# Connect to Redis
|
||||
k port-forward -n sqs svc/redis 6379:6379 &
|
||||
redis-cli
|
||||
|
||||
# Check pending messages
|
||||
KEYS "kmsvc:pending:orders:*"
|
||||
KEYS "kmsvc:inflight:*" | wc -l
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
**Requires JWT from Authentik:**
|
||||
```bash
|
||||
# Get token (device code flow)
|
||||
talos secrets login
|
||||
|
||||
# Use token
|
||||
export JWT_TOKEN=$(talos get cluster/kmsvc/jwt-token --key jwt-token)
|
||||
curl -H "Authorization: Bearer $JWT_TOKEN" https://kmsvc.riotpiao.homelab.com/v1/queues
|
||||
```
|
||||
|
||||
## Integration Example
|
||||
|
||||
**Story Crater backend consumer:**
|
||||
```go
|
||||
// Receive messages
|
||||
messages, err := kmsvc.ReceiveMessage(ctx, &kmsvc.ReceiveMessageRequest{
|
||||
QueueName: "story-crater",
|
||||
MaxNumberOfMessages: 10,
|
||||
WaitTimeSeconds: 20,
|
||||
})
|
||||
|
||||
// Process
|
||||
for _, msg := range messages.Messages {
|
||||
processMessage(msg.Body)
|
||||
|
||||
// Acknowledge on success
|
||||
kmsvc.DeleteMessage(ctx, &kmsvc.DeleteMessageRequest{
|
||||
QueueName: "story-crater",
|
||||
ReceiptHandle: msg.ReceiptHandle,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Queue stuck / high lag:**
|
||||
```bash
|
||||
# Check Kafka broker status
|
||||
k exec -n sqs pod/kmsvc-kafka-0 -- kafka-broker-api-versions.sh --bootstrap-server localhost:9092
|
||||
|
||||
# Inspect queue topics
|
||||
k exec -n sqs pod/kmsvc-kafka-0 -- kafka-topics.sh --bootstrap-server localhost:9092 --list | grep orders
|
||||
```
|
||||
|
||||
**Messages not being consumed:**
|
||||
- Check `maxReceiveCount` (may be routing to DLQ)
|
||||
- Verify consumer has `ReceiveMessage` permission (JWT scope)
|
||||
- Check Redis: `KEYS "kmsvc:fifo_lock:orders:*"` (may be blocked by visibility timeout)
|
||||
|
||||
See `/TROUBLESHOOTING.md` for full incident guide.
|
||||
@@ -0,0 +1,227 @@
|
||||
# Temporal Workflow Orchestration
|
||||
|
||||
**Server:** `temporal.temporal.svc.cluster.local:7233` (cluster-internal)
|
||||
**Web UI:** `kubectl port-forward -n temporal svc/temporal-web 8088:8088`
|
||||
**Namespace:** `temporal`
|
||||
|
||||
## When to Use
|
||||
|
||||
- **Long-running operations** — Tasks that take minutes/hours (send email, process batch, retry with backoff)
|
||||
- **State machines** — Multi-step workflows with decision logic
|
||||
- **Retries & timeouts** — Built-in exponential backoff, automatic retry
|
||||
- **Audit trail** — Full history of workflow executions (why it happened, when, by whom)
|
||||
|
||||
## Quick Start
|
||||
|
||||
**1. Access Temporal Web UI:**
|
||||
```bash
|
||||
k port-forward -n temporal svc/temporal-web 8088:8088
|
||||
# http://localhost:8088
|
||||
```
|
||||
|
||||
**2. Define workflow (Go example):**
|
||||
```go
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"time"
|
||||
"go.temporal.io/sdk/workflow"
|
||||
"go.temporal.io/sdk/activity"
|
||||
)
|
||||
|
||||
type Inputs struct {
|
||||
OrderID string
|
||||
Amount float64
|
||||
}
|
||||
|
||||
// Workflow definition
|
||||
func OrderProcessing(ctx workflow.Context, input Inputs) (string, error) {
|
||||
// Step 1: Charge payment
|
||||
chargeResult := ""
|
||||
err := workflow.ExecuteActivity(
|
||||
ctx,
|
||||
ChargePayment,
|
||||
input.OrderID,
|
||||
input.Amount,
|
||||
).Get(ctx, &chargeResult)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Step 2: Send confirmation email (retry 3x on failure)
|
||||
emailResult := ""
|
||||
opts := workflow.ActivityOptions{
|
||||
StartToCloseTimeout: time.Minute,
|
||||
RetryPolicy: &temporal.RetryPolicy{
|
||||
InitialInterval: time.Second,
|
||||
BackoffCoefficient: 2,
|
||||
MaximumAttempts: 3,
|
||||
},
|
||||
}
|
||||
ctx = workflow.WithActivityOptions(ctx, opts)
|
||||
|
||||
err = workflow.ExecuteActivity(ctx, SendConfirmationEmail, input.OrderID).Get(ctx, &emailResult)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "order_processed", nil
|
||||
}
|
||||
|
||||
// Activity: payment processing
|
||||
func ChargePayment(ctx context.Context, orderID string, amount float64) (string, error) {
|
||||
// Call payment gateway
|
||||
return "payment_successful", nil
|
||||
}
|
||||
|
||||
// Activity: email notification
|
||||
func SendConfirmationEmail(ctx context.Context, orderID string) (string, error) {
|
||||
// Send email
|
||||
return "email_sent", nil
|
||||
}
|
||||
```
|
||||
|
||||
**3. Register & start workflow:**
|
||||
```go
|
||||
import "go.temporal.io/sdk/client"
|
||||
|
||||
client, _ := client.Dial(client.Options{
|
||||
HostPort: "temporal.temporal.svc.cluster.local:7233",
|
||||
})
|
||||
|
||||
// Start workflow execution
|
||||
run, _ := client.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
|
||||
ID: "order-123",
|
||||
TaskQueue: "orders",
|
||||
}, OrderProcessing, Inputs{OrderID: "123", Amount: 99.99})
|
||||
|
||||
// Wait for result
|
||||
var result string
|
||||
run.Get(ctx, &result)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| Server | `temporal.temporal.svc.cluster.local:7233` |
|
||||
| Web UI | `localhost:8088` (via port-forward) |
|
||||
| Database | PostgreSQL (managed by helmfile) |
|
||||
| Task queue | `default`, `orders`, `emails` (custom per app) |
|
||||
| Retention | 30 days (configurable) |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Retry with exponential backoff:**
|
||||
```go
|
||||
opts := workflow.ActivityOptions{
|
||||
StartToCloseTimeout: 5 * time.Minute,
|
||||
RetryPolicy: &temporal.RetryPolicy{
|
||||
InitialInterval: time.Second,
|
||||
BackoffCoefficient: 2.0, // double wait time each retry
|
||||
MaximumInterval: time.Minute, // cap at 1 min between retries
|
||||
MaximumAttempts: 5, // give up after 5 tries
|
||||
},
|
||||
}
|
||||
ctx = workflow.WithActivityOptions(ctx, opts)
|
||||
```
|
||||
|
||||
**Wait for signal (user approval):**
|
||||
```go
|
||||
// Workflow waits for approval signal
|
||||
approval := ""
|
||||
workflow.GetSignalChannel(ctx, "approval").Receive(ctx, &approval)
|
||||
|
||||
if approval == "approved" {
|
||||
// Continue workflow
|
||||
} else {
|
||||
return "", errors.New("request denied")
|
||||
}
|
||||
```
|
||||
|
||||
**Parallel activities:**
|
||||
```go
|
||||
// Execute email & SMS in parallel
|
||||
emailFuture := workflow.ExecuteActivity(ctx, SendEmail, userID)
|
||||
smsFuture := workflow.ExecuteActivity(ctx, SendSMS, userID)
|
||||
|
||||
// Wait for both to complete
|
||||
emailFuture.Get(ctx, nil)
|
||||
smsFuture.Get(ctx, nil)
|
||||
```
|
||||
|
||||
**Scheduled workflow (cron):**
|
||||
```go
|
||||
run, _ := client.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
|
||||
ID: "daily-report",
|
||||
CronSchedule: "0 9 * * MON-FRI", // 9 AM weekdays
|
||||
WorkflowTaskTimeout: time.Hour,
|
||||
}, GenerateDailyReport, nil)
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
**Web UI:**
|
||||
- List workflows: http://localhost:8088/namespaces/default/workflows
|
||||
- View execution history: Click workflow ID
|
||||
- See activity logs, errors, retry attempts
|
||||
|
||||
**Grafana dashboard:** `svc-temporal` (auto-configured)
|
||||
|
||||
**Key metrics:**
|
||||
- `temporal_workflow_execution_duration_seconds` — workflow time
|
||||
- `temporal_activity_execution_duration_seconds` — activity time
|
||||
- `temporal_activity_execution_failed_total` — failed activities
|
||||
|
||||
## Integration with Story Crater
|
||||
|
||||
**Example: Process message via Temporal:**
|
||||
```go
|
||||
// In message handler
|
||||
client, _ := temporal.Dial(/* ... */)
|
||||
|
||||
run, _ := client.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
|
||||
ID: fmt.Sprintf("msg-%s", messageID),
|
||||
TaskQueue: "story-crater",
|
||||
}, ProcessMessageWorkflow, Message{
|
||||
ID: messageID,
|
||||
Body: body,
|
||||
Source: "kafka-queue",
|
||||
})
|
||||
|
||||
// Non-blocking: workflow runs independently
|
||||
// Check status later
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Workflow stuck:**
|
||||
```bash
|
||||
# Check Temporal server health
|
||||
k get pods -n temporal
|
||||
|
||||
# View workflow execution history (via Web UI or CLI)
|
||||
tctl workflow show --workflow-id order-123
|
||||
|
||||
# Terminate stuck workflow
|
||||
tctl workflow terminate --workflow-id order-123
|
||||
```
|
||||
|
||||
**Activity retrying endlessly:**
|
||||
```go
|
||||
// Add max attempts or timeout
|
||||
RetryPolicy: &temporal.RetryPolicy{
|
||||
MaximumAttempts: 5, // must have this!
|
||||
}
|
||||
```
|
||||
|
||||
**PostgreSQL connection fails:**
|
||||
```bash
|
||||
# Check temporal pod logs
|
||||
k logs -n temporal pod/temporal-0
|
||||
|
||||
# Verify database is running
|
||||
k get pods -n ddb
|
||||
```
|
||||
|
||||
See `/TROUBLESHOOTING.md` for full incident guide.
|
||||
@@ -0,0 +1,211 @@
|
||||
# Vault: Secret Management & JWT Auth
|
||||
|
||||
**Vault:** `https://vault.riotpiao.homelab.com`
|
||||
**Internal:** `vault.iam.svc.cluster.local:8200`
|
||||
**Namespace:** `iam`
|
||||
|
||||
## When to Use
|
||||
|
||||
- **Store secrets** — Database passwords, API keys, TLS certs
|
||||
- **Rotate credentials** — Auto-rotate, track rotation history
|
||||
- **JWT validation** — Verify tokens from Authentik, no external call needed
|
||||
- **Audit trail** — Who accessed what secret, when
|
||||
|
||||
## Quick Start
|
||||
|
||||
**1. Login to Vault:**
|
||||
```bash
|
||||
# Browser: https://vault.riotpiao.homelab.com
|
||||
# Auth method: OIDC → "Sign in with Authentik" (federated)
|
||||
# Or: Device code → talos secrets login (CLI)
|
||||
|
||||
# Via CLI (device code flow)
|
||||
talos secrets login
|
||||
# → Opens browser, approve device code
|
||||
# → Token cached in ~/.talos/vault
|
||||
```
|
||||
|
||||
**2. Store a secret:**
|
||||
```bash
|
||||
# Field name = variable name (SCREAMING_SNAKE_CASE)
|
||||
talos put cluster/ANTHROPIC_API_KEY ANTHROPIC_API_KEY="sk-..."
|
||||
talos put cluster/STORY_CRATER_DB_PASS STORY_CRATER_DB_PASS="dbpass123"
|
||||
```
|
||||
|
||||
**3. Retrieve a secret:**
|
||||
```bash
|
||||
# Always use --key flag
|
||||
talos get cluster/ANTHROPIC_API_KEY --key ANTHROPIC_API_KEY
|
||||
# → sk-...
|
||||
|
||||
# Full secret as JSON
|
||||
talos get cluster/ANTHROPIC_API_KEY --json
|
||||
```
|
||||
|
||||
**4. Load into shell (helmfile, scripts):**
|
||||
```bash
|
||||
vsource .env
|
||||
# → Expands empty vars from Vault (ANTHROPIC_API_KEY=)
|
||||
# → Passes hardcoded vars as-is (DEBUG=true)
|
||||
|
||||
helmfile diff
|
||||
helmfile apply
|
||||
```
|
||||
|
||||
## Vault Paths (KV v2)
|
||||
|
||||
**Naming convention:** `cluster/<VARIABLE_NAME>`
|
||||
|
||||
```
|
||||
cluster/
|
||||
├── ANTHROPIC_API_KEY # third-party API
|
||||
├── STORY_CRATER_DB_PASS # database password
|
||||
├── MINIO_ROOT_PASSWORD # MinIO credentials
|
||||
├── AUTHENTIK_BOOTSTRAP_PASSWORD # initial admin pass
|
||||
├── iam/
|
||||
│ ├── federation # Authentik OIDC app config
|
||||
│ ├── roles/admin # RBAC role definitions
|
||||
│ ├── services/grafana # OAuth2 client info
|
||||
│ ├── agents/ci-bot # machine credentials
|
||||
│ └── bindings/alice # user→role mappings
|
||||
└── kubernetes/
|
||||
├── ingress-tls # TLS cert private keys
|
||||
└── pull-secrets # Docker registry creds
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Store generated secret immediately (keeps it out of shell history):**
|
||||
```bash
|
||||
# Generate & store in one command
|
||||
talos put cluster/GRAFANA_OIDC_CLIENT_SECRET \
|
||||
GRAFANA_OIDC_CLIENT_SECRET="$(openssl rand -hex 32)"
|
||||
```
|
||||
|
||||
**Use in Kubernetes Secret:**
|
||||
```bash
|
||||
# Create Secret using Vault secret
|
||||
kubectl create secret generic grafana-oidc \
|
||||
--from-literal=client-secret="$(talos get cluster/GRAFANA_OIDC_CLIENT_SECRET --key GRAFANA_OIDC_CLIENT_SECRET)" \
|
||||
-n logging
|
||||
```
|
||||
|
||||
**Rotate credentials safely:**
|
||||
```bash
|
||||
# 1. Generate new secret
|
||||
NEW_PASS=$(openssl rand -base64 24)
|
||||
|
||||
# 2. Store in Vault
|
||||
talos put cluster/OLD_DB_PASS OLD_DB_PASS="$NEW_PASS"
|
||||
|
||||
# 3. Update database user
|
||||
psql -h ddb-cluster-rw.ddb.svc.cluster.local -U postgres \
|
||||
-c "ALTER USER story_crater PASSWORD '$NEW_PASS';"
|
||||
|
||||
# 4. Update Kubernetes Secret
|
||||
kubectl patch secret db-credentials -n story-crater-backend \
|
||||
--type merge -p "{\"stringData\":{\"password\":\"$NEW_PASS\"}}"
|
||||
|
||||
# 5. Restart pods to pick up new Secret
|
||||
k rollout restart -n story-crater-backend deployment/app
|
||||
```
|
||||
|
||||
**JWT token from CLI:**
|
||||
```bash
|
||||
# After device code login
|
||||
talos secrets login
|
||||
|
||||
# Token is cached and auto-renewed
|
||||
# Use for API calls
|
||||
VAULT_TOKEN=$(cat ~/.talos/vault)
|
||||
curl -H "X-Vault-Token: $VAULT_TOKEN" \
|
||||
https://vault.iam.svc.cluster.local:8200/v1/secret/data/cluster/ANTHROPIC_API_KEY
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
**Vault status:**
|
||||
```bash
|
||||
# Check if sealed
|
||||
talos status vault
|
||||
|
||||
# If sealed (disaster recovery):
|
||||
# See /TROUBLESHOOTING.md § Vault Sealed
|
||||
```
|
||||
|
||||
**Audit log (who accessed what):**
|
||||
```bash
|
||||
# Enable audit logging (already enabled by helmfile)
|
||||
# Logs stored in Loki under vault namespace
|
||||
|
||||
# View recent access
|
||||
talos audit log --limit 50
|
||||
|
||||
# Export for compliance
|
||||
talos audit export --format json > vault-audit.json
|
||||
```
|
||||
|
||||
## Security Rules
|
||||
|
||||
✅ **DO:**
|
||||
- Store ALL secrets in Vault (never .env in git)
|
||||
- Use field name = variable name
|
||||
- Always use `--key` flag when retrieving
|
||||
- Rotate credentials on schedule (quarterly)
|
||||
- Review audit log for anomalies
|
||||
|
||||
❌ **DON'T:**
|
||||
- Commit `.env` with real secrets (only `.env.example`)
|
||||
- Use positional arguments (must use `--key`)
|
||||
- Share Vault root token
|
||||
- Store secrets in pod env (use Secret volumes)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Cannot login (Authentik OIDC fails):**
|
||||
```bash
|
||||
# Check Authentik is running
|
||||
k get pods -n iam -l app=authentik
|
||||
|
||||
# Verify OIDC app in Authentik console
|
||||
# Applications → Vault OIDC → Check client ID/secret
|
||||
|
||||
# Restart Vault to re-sync OIDC config
|
||||
k rollout restart -n iam statefulset/vault
|
||||
```
|
||||
|
||||
**Vault is sealed:**
|
||||
```bash
|
||||
# Check status
|
||||
talos status vault
|
||||
|
||||
# If sealed, use unseal keys (stored in MinIO backup)
|
||||
# See /TROUBLESHOOTING.md § Vault Sealed for recovery steps
|
||||
```
|
||||
|
||||
**Secret not found:**
|
||||
```bash
|
||||
# Verify path exists
|
||||
talos list cluster
|
||||
|
||||
# Check secret name (case-sensitive)
|
||||
talos get cluster/anthropic_api_key --key anthropic_api_key # won't work
|
||||
talos get cluster/ANTHROPIC_API_KEY --key ANTHROPIC_API_KEY # correct
|
||||
```
|
||||
|
||||
**vsource not expanding secrets:**
|
||||
```bash
|
||||
# Verify .env has empty value
|
||||
grep ANTHROPIC_API_KEY .env
|
||||
# → Should be: ANTHROPIC_API_KEY= (empty, not a value)
|
||||
|
||||
# Verify Vault is accessible
|
||||
talos get cluster/ANTHROPIC_API_KEY --key ANTHROPIC_API_KEY
|
||||
# → Should return secret
|
||||
|
||||
# Run vsource explicitly
|
||||
vsource .env
|
||||
echo $ANTHROPIC_API_KEY # should be populated
|
||||
```
|
||||
|
||||
See `/TROUBLESHOOTING.md` for full incident guide.
|
||||
Reference in New Issue
Block a user