Compare commits

...

10 Commits

Author SHA1 Message Date
juvdiaz 39d500866c Moving gitea to debian host and updating opi5 storage to ssd instead of nvme 2026-06-26 22:04:07 -06:00
juvdiaz 207f7274fe Update storage and add recovery tools for debian tools 2026-06-26 19:42:59 -06:00
juvdiaz 3429069b9e Adding arr stack 2026-06-22 08:47:35 -06:00
juvdiaz a69966c457 Adding recovery for debian desktop 2026-06-11 11:02:23 -06:00
juvdiaz f121910cba Version website stylesheet asset 2026-06-10 15:53:50 -06:00
juvdiaz a270c6eca4 Update website retro theme styles 2026-06-10 15:14:00 -06:00
juvdiaz b45cd2d508 Document translation workflow architecture 2026-06-10 11:37:57 -06:00
juvdiaz d5abeca4b1 Fix website Ollama connectivity setup 2026-06-10 11:11:23 -06:00
juvdiaz ab68569dbe Add translation observability logs 2026-06-10 10:41:46 -06:00
juvdiaz 35c6c6f654 Add n8n workflow app 2026-06-10 10:38:50 -06:00
34 changed files with 1974 additions and 106 deletions

2
.gitignore vendored
View File

@ -3,9 +3,11 @@
*.tfstate.backup
.terraform/
.lab/
bootstrap/host/inventory/
# Ignore local archive dumps and backups
*.tar
*.tar.gz
*.zip
infra/gitea/data/

117
README.md
View File

@ -47,7 +47,7 @@ accidentally modify the cluster.
- joins worker nodes such as Raspberry Pi and Pimox Debian arm64 nodes
- configures Calico-compatible pod CIDR
- configures containerd to pull from the in-cluster NodePort registry
- creates retained host directories under `/var/openebs/local`
- creates retained host directories under `/data/openebs/local`
3. `bootstrap/platform`
- installs a minimal Calico deployment through the Tigera operator
@ -70,7 +70,7 @@ accidentally modify the cluster.
listens on loopback inside the container, OPcache is enabled, and
pre-rendered HTML handles the read-heavy pages
- default apps are `container-registry`, `website-production`,
`demos-static`, `heimdall`, and `supply-chain-policy`
`demos-static`, `heimdall`, `n8n`, and `supply-chain-policy`
5. `bootstrap/edge`
- connects to the OCI jump box
@ -90,7 +90,22 @@ On the Debian host:
minor, currently `v1.36`
- SSH access to worker nodes
- SSH access to the OCI edge host
- enough persistent storage for `/var/openebs/local` and `/var/lib/docker`
- enough persistent storage for `/data/openebs/local` and `/data/docker`
After a clean Debian reinstall, bootstrap the host package set and desktop
configuration first:
```bash
sudo apt-get update
sudo apt-get install -y git ansible sudo
git clone <repo-url> /home/jv/my-homelab-configs
cd /home/jv/my-homelab-configs
ansible-playbook -K -i bootstrap/host/inventory.ini bootstrap/host/playbook.yml
```
If you kept the private app-config archive generated from the old Debian host,
pass its generated `ansible-vars.yml` with `-e @/path/to/ansible-vars.yml`.
Details are in `bootstrap/host/README.md`.
The default kubeconfig path is `/home/jv/.kube/config`. Override it with
`KUBECONFIG_PATH` or `TF_VAR_kubeconfig_path` when needed.
@ -128,7 +143,8 @@ cd ~/my-homelab-configs
```
The script first deploys external Gitea to the Raspberry Pi with Docker Compose
so Git stays outside the Kubernetes rebuild blast radius. It then detects the
under `/data/homelab-gitea`, backed by the Raspberry Pi SSD, so Git stays
outside the Kubernetes rebuild blast radius. It then detects the
Pimox host at `192.168.100.80` in auto mode. When SSH, `qm`, and `vmbr0` are
available, it applies `bootstrap/provisioning`, creates or reuses the Debian 13
arm64 template, creates or reuses one worker VM clone, discovers the guest IP
@ -141,9 +157,11 @@ applies the edge stack.
Set `LAB_PIMOX_PIPELINE=false` to skip Pimox automation. Set
`LAB_PIMOX_WORKER_COUNT=0` to create or refresh only the template. The pipeline
keeps the template on its configured `local` storage, creates new worker VM
clones on `nvme_thin_pool` by default, checks that the Pimox bridge already
exists, refuses `local` as worker clone storage, and refuses to edit Orange Pi
host networking.
clones on `opi5_ssd` by default when that Pimox storage is available,
checks that the Pimox bridge already exists, refuses `local` as worker clone
storage, and refuses to edit Orange Pi host networking. Keep durable data on
the HP Debian laptop's `data-vg` or external backups; treat Orange Pi VM disks
as rebuildable worker capacity.
`LAB_PIMOX_SKIP_WORKER_INDEXES` defaults to an empty value, so the pipeline owns
worker index `1` and VMID `9010` when `LAB_PIMOX_WORKER_COUNT=1`. Set
@ -156,9 +174,9 @@ attaches to both WAN and LAN bridges. Run `./lab.sh openwrt` after `vmbr1`
already exists on the Orange Pi. The pipeline downloads the OpenWrt ARM
SystemReady EFI image, writes basic WAN/LAN/firewall config into the image,
imports it as VM `9100`, attaches `vmbr0` as WAN and `vmbr1` as LAN, and stores
the VM disk on `nvme_thin_pool`. It leaves the VM stopped and not enabled for
host boot by default. It does not use the Debian Kubernetes golden-node template
for OpenWrt.
the VM disk on the selected non-local Pimox storage. It leaves the VM stopped
and not enabled for host boot by default. It does not use the Debian Kubernetes
golden-node template for OpenWrt.
The website and demos images default to `linux/arm64` because both deployments
are pinned to the Raspberry Pi worker. Override with `WEBSITE_IMAGE_PLATFORMS`
@ -180,7 +198,7 @@ translation endpoints such as `save_idea.php`, `visitor_ideas.php`,
Set `LAB_GITEA_DEPLOY=false` to skip the external Gitea deployment step when the
Raspberry Pi service is already managed manually. The default Gitea target is
`jv@192.168.100.89`, install directory `/opt/homelab-gitea`, HTTP port `3000`,
`jv@192.168.100.89`, install directory `/data/homelab-gitea`, HTTP port `3000`,
and SSH port `32222`.
## Validation
@ -197,10 +215,10 @@ kubectl -n website-production get pods -o wide
kubectl -n demos-static get pods -o wide
kubectl -n heimdall get pods -o wide
ssh jv@192.168.100.89 'cd /opt/homelab-gitea && sudo docker compose ps'
ssh jv@192.168.100.89 'cd /data/homelab-gitea && sudo docker compose ps'
docker info --format '{{.DockerRootDir}}'
df -h / /var/openebs/local /var/lib/docker
df -h / /data /data/openebs/local /data/docker
```
The website should be reached through the configured public hostname, not the raw
@ -210,11 +228,15 @@ hostname.
## Adding Nodes
For Pimox on Orange Pi 5 Plus, `./lab.sh up` can create the Debian 13 arm64
template and worker VM clones automatically. Defaults are intentionally tied to
the observed host: Pimox SSH host `192.168.100.80`, bridge `vmbr0`, template VMID
`9000` on `local` storage, two 4 GiB worker VMs starting at VMID `9010`, worker
clone storage `nvme_thin_pool`, and no CPU affinity because this Pimox is pinned
to Debian Bullseye. Details and override variables are in
template and worker VM clones automatically when the Orange Pi storage path is
healthy. Defaults are intentionally tied to the observed host: Pimox SSH host
`192.168.100.80`, bridge `vmbr0`, template VMID `9000` on `local` storage, two
4 GiB worker VMs starting at VMID `9010`, worker clone storage `opi5_ssd`,
and no CPU affinity because this Pimox is pinned to Debian Bullseye. The NVMe
that was previously attached to the Orange Pi now belongs to the HP Debian
laptop as LVM volume group `data-vg`; the current Orange Pi SSD-backed Pimox
datastore is `opi5_ssd` and is used for rebuildable VM root disks. Details and
override variables are in
`bootstrap/provisioning/README.md`.
Worker indexes are stable. Index `1` maps to VMID `9010`, node name
@ -266,7 +288,8 @@ duplicate those PV manifests when you want storage on another node.
- `homelab.dev/node-role=edge-app`, `homelab.dev/storage=local`, and
`homelab.dev/workload-class=edge` on the Raspberry Pi worker
- `homelab.dev/node-role=app`, `homelab.dev/storage=nvme`, and
`homelab.dev/workload-class=platform` on automated Pimox worker clones
`homelab.dev/workload-class=platform` on automated Pimox worker clones when
those workers are enabled
Override `control_plane_node_labels`, `worker_node_labels`,
`LAB_RASPBERRY_NODE_LABELS_JSON`, or `LAB_PIMOX_WORKER_NODE_LABELS_JSON` when
@ -332,6 +355,14 @@ Traefik's address:
192.168.100.240 lab2025.duckdns.org
192.168.100.240 demos.lab2025.duckdns.org
192.168.100.240 heimdall.lab2025.duckdns.org
192.168.100.240 n8n.lab2025.duckdns.org
192.168.100.240 prowlarr.lab2025.duckdns.org
192.168.100.240 sonarr.lab2025.duckdns.org
192.168.100.240 radarr.lab2025.duckdns.org
192.168.100.240 qbittorrent.lab2025.duckdns.org
192.168.100.240 kapowarr.lab2025.duckdns.org
192.168.100.240 suwayomi.lab2025.duckdns.org
192.168.100.240 maintainerr.lab2025.duckdns.org
192.168.100.240 grafana.lab2025.duckdns.org
192.168.100.240 argocd.lab2025.duckdns.org
```
@ -480,18 +511,31 @@ Because Heimdall does not support reverse-proxy subfolder hosting cleanly, it
is exposed through the dedicated hostname `heimdall.lab2025.duckdns.org` rather
than a `/heimdall/` path.
The `n8n` app runs in the `n8n` namespace with retained OpenEBS storage and is
exposed at `https://n8n.lab2025.duckdns.org`. It receives
`OLLAMA_BASE_URL=http://192.168.100.68:11434` so workflows can call the existing
Debian-host Ollama API for translation prewarming and batch jobs.
The `arr-stack` app runs Prowlarr, Sonarr, Radarr, qBittorrent, Kapowarr,
Suwayomi, Maintainerr, and FlareSolverr as native Kubernetes Deployments in the
`arr-stack` namespace. It uses retained OpenEBS PVCs for shared `/data` media
paths and per-service config directories, and exposes the user interfaces
through dedicated Traefik hostnames instead of a Tailscale sidecar.
## Storage
OpenEBS provides the platform storage provisioner. Stateful Kubernetes apps use
retained local PV paths such as `/var/openebs/local/registry`; these paths are
retained local PV paths such as `/data/openebs/local/registry`; these paths are
intentionally outside kubeadm reset paths so data can survive cluster
destroy/create cycles. Those critical volumes are declared explicitly as
retained local PVs so a rebuilt cluster binds back to the same host paths
instead of creating fresh directories.
For the current lab, `/var/openebs/local` and `/var/lib/docker` are expected to
live on larger storage than the root filesystem. This keeps retained PVs,
container layers, Buildx state, and image caches from filling `/`.
For the current lab, the HP Debian laptop's NVMe is managed by LVM volume group
`data-vg` and mounted under `/data`. OpenEBS retained hostpath data lives under
`/data/openebs/local`, and Docker data should live under `/data/docker` where
the host is configured that way. This keeps retained PVs, container layers,
Buildx state, and image caches from filling `/`.
## Gitea
@ -500,9 +544,9 @@ always-on Docker Compose service from `infra/gitea/docker-compose.yml`, not as a
Kubernetes workload. This keeps Git available when the Kubernetes cluster is
destroyed and rebuilt.
The default data path is `/opt/homelab-gitea/data` on the Raspberry Pi SD card.
That is acceptable for the current temporary setup; move
`LAB_GITEA_INSTALL_DIR` to an SSD mount when the SSD is added.
The default data path is `/data/homelab-gitea/data` on the Raspberry Pi SSD.
The Raspberry Pi Docker root is also expected to live on the SSD-backed `/data`
mount rather than the SD card.
Public source browsing stays available through
`https://lab2025.duckdns.org/git/`. Registration is disabled and anonymous users
@ -637,7 +681,7 @@ for platform or provisioning updates.
`./lab.sh nuke` resets kubeadm, containerd runtime state, CNI files, Calico
links, iptables rules, and local OpenTofu state. It does not delete retained data
under `/var/openebs/local`.
under `/data/openebs/local`.
For multi-node labs, set `WORKER_SSH_TARGETS` to a space-separated list of SSH
targets. It defaults to an empty string so the Raspberry Pi Gitea host is not
@ -646,16 +690,20 @@ cleaned unless you explicitly include it.
## Website App
The website is a PHP app under `apps/website`. It includes a home page, CV page,
blog page, and demos page, plus a lightweight translation flow backed by Ollama.
blog page, and demos page, plus a lightweight translation flow backed by Redis,
n8n, and Ollama.
Static language files live in `apps/website/lang`; `en.php` and `nah.php` are
curated source files, with the Nahuatl home page intentionally biased toward as
many Nahuatl words as possible while keeping technical terms understandable.
Unsupported browser languages use the same-origin `/translate.php` endpoint,
which calls Ollama server-side through `OLLAMA_HOST` and `OLLAMA_MODEL`; the
browser never calls the private Ollama IP directly. The default model is the
custom `website-translator` Ollama model defined in
browser never calls the private Ollama IP directly. Redis caches per-string
translation results in the `website-production` namespace, while n8n is
available for translation prewarming and batch workflow jobs. The default model
is the custom `website-translator` Ollama model defined in
`apps/website/ollama/Modelfile`. Generated runtime language JSON is saved
through `save_lang.php` on the website PVC.
through `save_lang.php` on the website PVC, and `translate.php` emits structured
logs for cache hits, misses, Ollama latency, JSON parse failures, and timeouts.
Create or refresh the Ollama model on the Debian server before deploying a
website image that points at it:
@ -664,6 +712,13 @@ website image that points at it:
./lab.sh website-translation-model
```
Ollama must also listen on the Debian host LAN address so Kubernetes pods on
other nodes can reach `OLLAMA_HOST=http://192.168.100.68:11434`:
```bash
./lab.sh website-ollama-listen
```
The CV page has two client-side presentation modes:
- `Elegant`: dark, minimal, terminal-inspired styling with a square profile

132
apps/arr-stack/ingress.yaml Normal file
View File

@ -0,0 +1,132 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: prowlarr
namespace: arr-stack
spec:
ingressClassName: traefik
rules:
- host: prowlarr.lab2025.duckdns.org
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: prowlarr
port:
number: 9696
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sonarr
namespace: arr-stack
spec:
ingressClassName: traefik
rules:
- host: sonarr.lab2025.duckdns.org
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: sonarr
port:
number: 8989
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: radarr
namespace: arr-stack
spec:
ingressClassName: traefik
rules:
- host: radarr.lab2025.duckdns.org
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: radarr
port:
number: 7878
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: qbittorrent
namespace: arr-stack
spec:
ingressClassName: traefik
rules:
- host: qbittorrent.lab2025.duckdns.org
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: qbittorrent
port:
number: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: kapowarr
namespace: arr-stack
spec:
ingressClassName: traefik
rules:
- host: kapowarr.lab2025.duckdns.org
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: kapowarr
port:
number: 5656
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: suwayomi
namespace: arr-stack
spec:
ingressClassName: traefik
rules:
- host: suwayomi.lab2025.duckdns.org
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: suwayomi
port:
number: 4567
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: maintainerr
namespace: arr-stack
spec:
ingressClassName: traefik
rules:
- host: maintainerr.lab2025.duckdns.org
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: maintainerr
port:
number: 6246

View File

@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- storage.yaml
- web-app.yaml
- ingress.yaml

View File

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

View File

@ -0,0 +1,25 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: arr-config
namespace: arr-stack
spec:
accessModes:
- ReadWriteOnce
storageClassName: openebs-hostpath-retain
resources:
requests:
storage: 20Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: arr-data
namespace: arr-stack
spec:
accessModes:
- ReadWriteOnce
storageClassName: openebs-hostpath-retain
resources:
requests:
storage: 500Gi

538
apps/arr-stack/web-app.yaml Normal file
View File

@ -0,0 +1,538 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: flaresolverr
namespace: arr-stack
labels:
app: flaresolverr
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: flaresolverr
template:
metadata:
labels:
app: flaresolverr
spec:
nodeSelector:
kubernetes.io/os: linux
containers:
- name: flaresolverr
image: ghcr.io/flaresolverr/flaresolverr:latest
imagePullPolicy: IfNotPresent
env:
- name: LOG_LEVEL
value: info
- name: TZ
value: America/Mexico_City
ports:
- containerPort: 8191
name: http
resources:
requests:
cpu: 50m
memory: 256Mi
limits:
memory: 1Gi
---
apiVersion: v1
kind: Service
metadata:
name: flaresolverr
namespace: arr-stack
spec:
selector:
app: flaresolverr
ports:
- port: 8191
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: prowlarr
namespace: arr-stack
labels:
app: prowlarr
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: prowlarr
template:
metadata:
labels:
app: prowlarr
spec:
nodeSelector:
kubernetes.io/os: linux
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
containers:
- name: prowlarr
image: lscr.io/linuxserver/prowlarr:latest
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: America/Mexico_City
ports:
- containerPort: 9696
name: http
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
volumeMounts:
- name: arr-config
mountPath: /config
subPath: prowlarr
volumes:
- name: arr-config
persistentVolumeClaim:
claimName: arr-config
---
apiVersion: v1
kind: Service
metadata:
name: prowlarr
namespace: arr-stack
spec:
selector:
app: prowlarr
ports:
- port: 9696
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: radarr
namespace: arr-stack
labels:
app: radarr
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: radarr
template:
metadata:
labels:
app: radarr
spec:
nodeSelector:
kubernetes.io/os: linux
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
containers:
- name: radarr
image: lscr.io/linuxserver/radarr:latest
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: America/Mexico_City
ports:
- containerPort: 7878
name: http
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
memory: 2Gi
volumeMounts:
- name: arr-config
mountPath: /config
subPath: radarr
- name: arr-data
mountPath: /data
volumes:
- name: arr-config
persistentVolumeClaim:
claimName: arr-config
- name: arr-data
persistentVolumeClaim:
claimName: arr-data
---
apiVersion: v1
kind: Service
metadata:
name: radarr
namespace: arr-stack
spec:
selector:
app: radarr
ports:
- port: 7878
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sonarr
namespace: arr-stack
labels:
app: sonarr
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: sonarr
template:
metadata:
labels:
app: sonarr
spec:
nodeSelector:
kubernetes.io/os: linux
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
containers:
- name: sonarr
image: lscr.io/linuxserver/sonarr:latest
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: America/Mexico_City
ports:
- containerPort: 8989
name: http
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
memory: 2Gi
volumeMounts:
- name: arr-config
mountPath: /config
subPath: sonarr
- name: arr-data
mountPath: /data
volumes:
- name: arr-config
persistentVolumeClaim:
claimName: arr-config
- name: arr-data
persistentVolumeClaim:
claimName: arr-data
---
apiVersion: v1
kind: Service
metadata:
name: sonarr
namespace: arr-stack
spec:
selector:
app: sonarr
ports:
- port: 8989
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: qbittorrent
namespace: arr-stack
labels:
app: qbittorrent
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: qbittorrent
template:
metadata:
labels:
app: qbittorrent
spec:
nodeSelector:
kubernetes.io/os: linux
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
containers:
- name: qbittorrent
image: lscr.io/linuxserver/qbittorrent:latest
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: America/Mexico_City
- name: WEBUI_PORT
value: "8080"
ports:
- containerPort: 8080
name: http
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
memory: 2Gi
volumeMounts:
- name: arr-config
mountPath: /config
subPath: qbittorrent
- name: arr-data
mountPath: /data
volumes:
- name: arr-config
persistentVolumeClaim:
claimName: arr-config
- name: arr-data
persistentVolumeClaim:
claimName: arr-data
---
apiVersion: v1
kind: Service
metadata:
name: qbittorrent
namespace: arr-stack
spec:
selector:
app: qbittorrent
ports:
- port: 8080
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kapowarr
namespace: arr-stack
labels:
app: kapowarr
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: kapowarr
template:
metadata:
labels:
app: kapowarr
spec:
nodeSelector:
kubernetes.io/os: linux
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
containers:
- name: kapowarr
image: mrcas/kapowarr:latest
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: America/Mexico_City
ports:
- containerPort: 5656
name: http
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
volumeMounts:
- name: arr-config
mountPath: /app/db
subPath: kapowarr
- name: arr-data
mountPath: /app/temp_downloads
subPath: torrents
- name: arr-data
mountPath: /comics
subPath: media/comics
volumes:
- name: arr-config
persistentVolumeClaim:
claimName: arr-config
- name: arr-data
persistentVolumeClaim:
claimName: arr-data
---
apiVersion: v1
kind: Service
metadata:
name: kapowarr
namespace: arr-stack
spec:
selector:
app: kapowarr
ports:
- port: 5656
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: suwayomi
namespace: arr-stack
labels:
app: suwayomi
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: suwayomi
template:
metadata:
labels:
app: suwayomi
spec:
nodeSelector:
kubernetes.io/os: linux
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
containers:
- name: suwayomi
image: ghcr.io/suwayomi/suwayomi-server:preview
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: America/Mexico_City
ports:
- containerPort: 4567
name: http
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
memory: 2Gi
volumeMounts:
- name: arr-config
mountPath: /home/suwayomi/.local/share/Suwayomi-Server
subPath: suwayomi
- name: arr-data
mountPath: /data/manga
subPath: media/manga
volumes:
- name: arr-config
persistentVolumeClaim:
claimName: arr-config
- name: arr-data
persistentVolumeClaim:
claimName: arr-data
---
apiVersion: v1
kind: Service
metadata:
name: suwayomi
namespace: arr-stack
spec:
selector:
app: suwayomi
ports:
- port: 4567
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: maintainerr
namespace: arr-stack
labels:
app: maintainerr
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: maintainerr
template:
metadata:
labels:
app: maintainerr
spec:
nodeSelector:
kubernetes.io/os: linux
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
containers:
- name: maintainerr
image: ghcr.io/jorenn92/maintainerr:latest
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: America/Mexico_City
ports:
- containerPort: 6246
name: http
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
volumeMounts:
- name: arr-config
mountPath: /opt/data
subPath: maintainerr
volumes:
- name: arr-config
persistentVolumeClaim:
claimName: arr-config
---
apiVersion: v1
kind: Service
metadata:
name: maintainerr
namespace: arr-stack
spec:
selector:
app: maintainerr
ports:
- port: 6246
targetPort: http

View File

@ -11,7 +11,7 @@ spec:
persistentVolumeReclaimPolicy: Retain
storageClassName: openebs-hostpath-retain
local:
path: /var/openebs/local/registry
path: /data/openebs/local/registry
nodeAffinity:
required:
nodeSelectorTerms:

View File

@ -81,6 +81,62 @@ data:
"description": "Homelab service dashboard",
"colour": "#7c3aed",
"class": "Heimdall"
},
{
"title": "n8n",
"url": "https://n8n.lab2025.duckdns.org/",
"description": "Workflow automation for translation jobs and homelab tasks",
"colour": "#ea4b71",
"class": "N8N"
},
{
"title": "Prowlarr",
"url": "https://prowlarr.lab2025.duckdns.org/",
"description": "Indexer management for the media automation stack",
"colour": "#2563eb",
"class": "Prowlarr"
},
{
"title": "Sonarr",
"url": "https://sonarr.lab2025.duckdns.org/",
"description": "TV library automation",
"colour": "#22c55e",
"class": "Sonarr"
},
{
"title": "Radarr",
"url": "https://radarr.lab2025.duckdns.org/",
"description": "Movie library automation",
"colour": "#f59e0b",
"class": "Radarr"
},
{
"title": "qBittorrent",
"url": "https://qbittorrent.lab2025.duckdns.org/",
"description": "Torrent download client",
"colour": "#3b82f6",
"class": "Qbittorrent"
},
{
"title": "Kapowarr",
"url": "https://kapowarr.lab2025.duckdns.org/",
"description": "Comics library automation",
"colour": "#ef4444",
"class": "Kapowarr"
},
{
"title": "Suwayomi",
"url": "https://suwayomi.lab2025.duckdns.org/",
"description": "Manga server and scraper engine",
"colour": "#8b5cf6",
"class": "Suwayomi"
},
{
"title": "Maintainerr",
"url": "https://maintainerr.lab2025.duckdns.org/",
"description": "Media list maintenance automation",
"colour": "#14b8a6",
"class": "Maintainerr"
}
]
seed.py: |

18
apps/n8n/ingress.yaml Normal file
View File

@ -0,0 +1,18 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: n8n
namespace: n8n
spec:
ingressClassName: traefik
rules:
- host: n8n.lab2025.duckdns.org
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: n8n
port:
number: 80

View File

@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- web-app.yaml
- ingress.yaml

4
apps/n8n/namespace.yaml Normal file
View File

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

112
apps/n8n/web-app.yaml Normal file
View File

@ -0,0 +1,112 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: n8n-data
namespace: n8n
spec:
accessModes:
- ReadWriteOnce
storageClassName: openebs-hostpath-retain
resources:
requests:
storage: 2Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: n8n
namespace: n8n
labels:
app: n8n
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: n8n
template:
metadata:
labels:
app: n8n
spec:
nodeSelector:
kubernetes.io/os: linux
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
containers:
- name: n8n
image: n8nio/n8n:2.5.2
imagePullPolicy: IfNotPresent
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
env:
- name: N8N_HOST
value: n8n.lab2025.duckdns.org
- name: N8N_PORT
value: "5678"
- name: N8N_PROTOCOL
value: https
- name: WEBHOOK_URL
value: https://n8n.lab2025.duckdns.org/
- name: GENERIC_TIMEZONE
value: America/Mexico_City
- name: TZ
value: America/Mexico_City
- name: N8N_DIAGNOSTICS_ENABLED
value: "false"
- name: N8N_VERSION_NOTIFICATIONS_ENABLED
value: "false"
- name: N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS
value: "true"
- name: N8N_RUNNERS_ENABLED
value: "true"
- name: OLLAMA_BASE_URL
value: http://192.168.100.68:11434
ports:
- containerPort: 5678
name: http
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 30
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 90
periodSeconds: 30
resources:
requests:
cpu: 100m
memory: 384Mi
limits:
memory: 1Gi
volumeMounts:
- name: n8n-data
mountPath: /home/node/.n8n
volumes:
- name: n8n-data
persistentVolumeClaim:
claimName: n8n-data
---
apiVersion: v1
kind: Service
metadata:
name: n8n
namespace: n8n
spec:
ports:
- port: 80
targetPort: http
selector:
app: n8n

View File

@ -55,6 +55,37 @@ The OCI edge nginx layer handles gzip, cache HITs, HTTP/2, TLS session reuse,
client keepalive, and upstream keepalive to the Traefik backend. CSS and
JavaScript keep the edge's longer immutable asset policy.
## Translation Flow
Unsupported browser languages call the same-origin `translate.php` endpoint.
The endpoint sanitizes and batches text, checks Redis per string, calls the
custom `website-translator` Ollama model only for cache misses, writes fresh
translations back to Redis with a TTL, then returns results in the original
order. The browser never calls the private Ollama address directly.
n8n is deployed separately at `https://n8n.lab2025.duckdns.org` and receives
`OLLAMA_BASE_URL=http://192.168.100.68:11434` so workflows can prewarm
translations or run batch jobs against the Debian-host Ollama API.
## Translation Observability
`translate.php` emits one structured JSON log event for each valid translation
request. The event name is `website_translation` and the payload does not include
source text or translated text.
Useful fields:
- `cache_hits`
- `cache_misses`
- `ollama_latency_ms`
- `failed_json_parse_count`
- `timeout_count`
- `status`
- `reason`
In Loki, filter website logs for `"event":"website_translation"` to inspect
cache behavior, Ollama latency, JSON parse failures, and timeout failures.
## Validation
Do not build images locally. The image is built and deployed by the Debian

View File

@ -57,6 +57,7 @@ $stackKeys = [
'blog_stack_16',
'blog_stack_17',
'blog_stack_18',
'blog_stack_19',
];
$caseStudyCards = [
@ -174,6 +175,12 @@ $stackSourceLinks = [
['label' => 'Cosign build flow', 'path' => 'lab.sh'],
['label' => 'apps variables', 'path' => 'bootstrap/apps/variables.tf'],
],
'blog_stack_19' => [
['label' => 'translate.php', 'path' => 'apps/website/translate.php'],
['label' => 'website Redis manifest', 'path' => 'apps/website/web-app.yaml'],
['label' => 'n8n app manifest', 'path' => 'apps/n8n/web-app.yaml'],
['label' => 'Ollama Modelfile', 'path' => 'apps/website/ollama/Modelfile'],
],
];
function renderStackSourceLinks(string $stackKey, array $sourceLinks, string $sourceBase): void {
@ -193,7 +200,7 @@ function renderStackSourceLinks(string $stackKey, array $sourceLinks, string $so
<head>
<meta charset="UTF-8">
<title><?php echo $text['blog_title']; ?> - <?php echo $text['name']; ?></title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="<?php echo asset_url('styles.css'); ?>">
</head>
<body class="theme-dark">
@ -310,7 +317,7 @@ function renderStackSourceLinks(string $stackKey, array $sourceLinks, string $so
<div class="diagram-shell" aria-label="Professional homelab architecture diagram">
<svg class="homelab-map" viewBox="0 0 1120 960" role="img" aria-labelledby="homelab-map-title homelab-map-desc">
<title id="homelab-map-title">Homelab architecture map</title>
<desc id="homelab-map-desc">Git push enters Gitea, Gitea Actions validates and builds app images, OpenTofu manages cluster and provisioning layers, Debian keeps the control plane and PXE services, Pimox app workers run Argo CD, Kyverno, and app workloads on NVMe-backed VMs, OpenWrt can run as an opt-in firewall VM, and the OCI edge routes traffic into Kubernetes services.</desc>
<desc id="homelab-map-desc">Git push enters Gitea, Gitea Actions validates and builds app images, OpenTofu manages cluster and provisioning layers, Debian keeps the control plane, PXE services, and Ollama translation model, Pimox app workers run Argo CD, Kyverno, Redis-backed website translation, n8n workflow automation, and app workloads on NVMe-backed VMs, OpenWrt can run as an opt-in firewall VM, and the OCI edge routes traffic into Kubernetes services.</desc>
<defs>
<marker id="map-arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M2,2 L10,6 L2,10 Z" fill="#2b6cb0"></path>
@ -405,8 +412,8 @@ function renderStackSourceLinks(string $stackKey, array $sourceLinks, string $so
<g class="diagram-node node-accent-orange" transform="translate(412 774)">
<rect width="280" height="82" rx="8"></rect>
<text x="18" y="27">Storage and backups</text>
<text class="diagram-small" x="18" y="50">OpenEBS retained PVs</text>
<text class="diagram-small" x="18" y="68">Gitea dumps and monitoring data</text>
<text class="diagram-small" x="18" y="50">OpenEBS retained PVs, n8n data</text>
<text class="diagram-small" x="18" y="68">Gitea dumps, logs, Redis cache</text>
</g>
<g class="diagram-node node-accent-blue" transform="translate(800 92)">
@ -436,9 +443,9 @@ function renderStackSourceLinks(string $stackKey, array $sourceLinks, string $so
<rect width="258" height="112" rx="8"></rect>
<text x="18" y="28">Orange Pi 5 Plus Pimox</text>
<text class="diagram-small" x="18" y="52">pimox-worker app nodes</text>
<text class="diagram-small" x="18" y="70">workers on nvme_thin_pool</text>
<text class="diagram-small" x="18" y="88">Argo CD, Kyverno, apps</text>
<text class="diagram-small" x="18" y="106">idempotent qm automation</text>
<text class="diagram-small" x="18" y="70">template VMs; storage optional</text>
<text class="diagram-small" x="18" y="88">Argo CD, Kyverno, website, n8n</text>
<text class="diagram-small" x="18" y="106">Redis cache and app workloads</text>
</g>
<g class="diagram-node node-accent-orange" transform="translate(800 616)">
@ -457,6 +464,12 @@ function renderStackSourceLinks(string $stackKey, array $sourceLinks, string $so
<text class="diagram-small" x="18" y="68">pulled by app workers</text>
</g>
<g class="diagram-node node-accent-purple" transform="translate(800 866)">
<rect width="258" height="54" rx="8"></rect>
<text x="18" y="24">Ollama translation API</text>
<text class="diagram-small" x="18" y="44">website-translator on Debian</text>
</g>
<path class="diagram-link" d="M174 174 L174 208"></path>
<path class="diagram-link" d="M174 290 L174 318"></path>
<path class="diagram-link" d="M174 400 L174 430"></path>
@ -476,6 +489,7 @@ function renderStackSourceLinks(string $stackKey, array $sourceLinks, string $so
<path class="diagram-link" d="M929 188 L929 218"></path>
<path class="diagram-link" d="M929 300 L929 330"></path>
<path class="diagram-link" d="M929 766 L929 426"></path>
<path class="diagram-link" d="M800 893 C724 893 720 367 692 367"></path>
<text class="diagram-link-label" x="184" y="198">push</text>
<text class="diagram-link-label" x="196" y="312">workflow</text>
@ -491,6 +505,7 @@ function renderStackSourceLinks(string $stackKey, array $sourceLinks, string $so
<text class="diagram-link-label" x="934" y="212">secure tunnel</text>
<text class="diagram-link-label" x="934" y="322">service traffic</text>
<text class="diagram-link-label" x="946" y="754">image pulls</text>
<text class="diagram-link-label" x="710" y="860">translation calls</text>
</svg>
</div>

View File

@ -38,7 +38,7 @@ $recruiterKeys = [
<head>
<meta charset="UTF-8">
<title>CV - <?php echo $text['name']; ?></title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="<?php echo asset_url('styles.css'); ?>">
</head>
<body class="cv-page theme-dark">

View File

@ -72,7 +72,7 @@ $demoCards = [
<head>
<meta charset="UTF-8">
<title><?php echo $text['demos_title']; ?> - <?php echo $text['name']; ?></title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="<?php echo asset_url('styles.css'); ?>">
</head>
<body class="theme-dark">

View File

@ -8,7 +8,7 @@ $blogHref = 'blog.php?lang=' . urlencode($lang);
<head>
<meta charset="UTF-8">
<title><?php echo $text['tree_title']; ?> - <?php echo $text['name']; ?></title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="<?php echo asset_url('styles.css'); ?>">
</head>
<body>

View File

@ -24,7 +24,7 @@ $homeProofCards = [
<head>
<meta charset="UTF-8">
<title><?php echo $text['name']; ?></title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="<?php echo asset_url('styles.css'); ?>">
</head>
<body class="home-page theme-dark">

View File

@ -126,14 +126,14 @@ return [
'blog_q7' => 'Can the current cluster actually handle all that, or are we about to smoke the Pi?',
'blog_a7' => 'The Pi survives because the demos are intentionally local-first and now ship as a separate static artifact. The website pod stays a portfolio shell, the demos-static pod serves static bundles, and the user browser does the expensive work. If I later ship real ONNX object detection, Transformers.js, or full video transcoding models, those must lazy-load in the browser or move to a beefier node. The Raspberry Pi is brave, but it is not a GPU wearing a tiny hat.',
'blog_q8' => 'So the lab can now build its own worker nodes?',
'blog_a8' => 'Yes, and now with fewer crossed fingers. Debian runs a provisioning layer with dnsmasq, nginx, PXE boot files, GRUB, and a Debian 13 arm64 preseed. OpenTofu talks to Pimox through qm, creates VM 9000 on local storage, boots it from the network, installs the OS, runs golden-node prep, disables swap, verifies cgroups, installs containerd and kubeadm tooling, then seals it as a template. Worker clones are idempotent by VMID and now land on nvme_thin_pool, so local storage stays reserved for the template.',
'blog_a8' => 'Yes, and now with fewer crossed fingers. Debian runs a provisioning layer with dnsmasq, nginx, PXE boot files, GRUB, and a Debian 13 arm64 preseed. OpenTofu talks to Pimox through qm, creates VM 9000 on local storage, boots it from the network, installs the OS, runs golden-node prep, disables swap, verifies cgroups, installs containerd and kubeadm tooling, then seals it as a template. Worker clones are idempotent by VMID when a stable non-local Pimox datastore is available.',
'blog_q9' => 'And OpenWrt is joining the story too?',
'blog_a9' => 'Only as a simple firewall, not as a networking science project. The pipeline can create an opt-in OpenWrt ARM SystemReady VM, attach vmbr0 as WAN and vmbr1 as LAN, and configure the LAN side without rewriting Orange Pi host networking. DHCP stays optional, and VLANs wait until there is a managed switch and a local test window.',
'blog_q10' => 'What changed on the observability and scheduling side?',
'blog_a10' => 'Monitoring moved from "someday" to "running," and scheduling moved from "whatever fits" to explicit worker placement. Prometheus Stack, Grafana, Loki, Promtail, node-exporter, and kube-state-metrics give the lab useful signals; the next useful step is choosing the few alerts that would actually wake me up for the right reasons.',
'blog_stack_title' => 'Technologies and why they are here',
'blog_stack_1' => 'Debian Linux is the steady adult in the room: control plane host, deployment workstation, PXE/preseed server, and the place where OpenTofu, Docker, kubeadm, and the scripts do their thing.',
'blog_stack_2' => 'Raspberry Pi keeps the external Gitea service close to the lab, while Pimox on the Orange Pi 5 Plus provides the app-worker pool. The Debian template stays on local storage and Kubernetes worker clones go to nvme_thin_pool.',
'blog_stack_2' => 'Raspberry Pi keeps the external Gitea service close to the lab on SSD-backed /data, while Pimox on the Orange Pi 5 Plus can provide an app-worker pool when its VM storage is stable. The HP Debian laptop now owns the NVMe-backed data-vg storage for Kubernetes data paths.',
'blog_stack_3' => 'OpenTofu makes the cluster, platform, apps, edge, and provisioning layers repeatable, because "I swear I remember the command" is not a disaster recovery strategy.',
'blog_stack_4' => 'Calico handles pod networking, and OpenEBS hostpath storage keeps the important data around after rebuilds, because deleting everything by accident is only funny once.',
'blog_stack_5' => 'Argo CD is the GitOps referee and now runs on app workers: manifests live in Git, the cluster follows along, and manual drift gets side-eyed back into place.',
@ -146,14 +146,15 @@ return [
'blog_stack_12' => 'The Pimox worker pipeline uses qm over SSH to create an OVMF/virtio-scsi Debian 13 arm64 VM, wait for qemu-guest-agent, seal it, and convert VM 9000 into a reusable template on local storage.',
'blog_stack_13' => 'The golden image bakes in Kubernetes prerequisites: swap disabled, cgroup boot options checked, kernel modules loaded, containerd configured for systemd cgroups, kubeadm/kubelet/kubectl installed, and qemu-guest-agent enabled.',
'blog_stack_14' => 'Worker clone automation is count-based and idempotent: LAB_PIMOX_WORKER_COUNT=1 means ensure VM 9010 exists, not create a fresh worker on every run. New workers are refused if the target storage is local.',
'blog_stack_15' => 'OpenWrt is handled separately from the Debian golden-node template. The lab downloads the upstream ARM SystemReady EFI image, imports it as VM 9050 on nvme_thin_pool, and keeps it disabled unless LAB_OPENWRT_VM=true is set.',
'blog_stack_15' => 'OpenWrt is handled separately from the Debian golden-node template. The lab downloads the upstream ARM SystemReady EFI image, imports it onto selected non-local Pimox storage, and keeps it disabled unless LAB_OPENWRT_VM=true is set.',
'blog_stack_16' => 'The monitoring layer now includes Prometheus Stack, Grafana, Loki, Promtail, node-exporter, and kube-state-metrics, with placement guardrails so platform add-ons stop defaulting to the control plane.',
'blog_stack_17' => 'The portfolio website keeps Apache at the core, runs PHP through PHP-FPM with OPcache, pre-renders read-heavy pages into static HTML during the image build, and leaves PHP for write and translation endpoints.',
'blog_stack_18' => 'The supply-chain path now uses BuildKit SBOM attestations, Cosign signatures, a cluster-published public key, and an audit-first Kyverno ImageValidatingPolicy that can be promoted to deny unsigned local-registry pods.',
'blog_stack_19' => 'The translation path now uses Redis for per-string cache hits, a custom Ollama website-translator model on the Debian server for cache misses, n8n for prewarming and batch workflow automation, and structured logs for cache, latency, JSON parse, and timeout signals.',
'blog_arch_kicker' => 'Architecture map',
'blog_arch_title' => 'The homelab, end to end',
'blog_arch_intro' => 'The current delivery path starts with a push to Gitea, runs local validation, builds arm64 images, syncs the validated commit into the GitOps mirror, and lets Argo CD reconcile from the app workers. The infrastructure path stays manual through lab.sh, including the PXE/Pimox template builder, NVMe-backed worker clones, Kyverno policy placement, and the opt-in OpenWrt firewall VM, while the OCI edge routes public traffic back through the private path.',
'blog_arch_caption' => 'The diagram is intentionally operational: it shows the app delivery loop, image flow, provisioning path, worker-placement boundary, monitoring layer, OpenWrt firewall option, and public traffic path without hiding the practical bits that make a small lab behave like a platform.',
'blog_arch_intro' => 'The current delivery path starts with a push to Gitea, runs local validation, builds arm64 images, syncs the validated commit into the GitOps mirror, and lets Argo CD reconcile from the app workers. The infrastructure path stays manual through lab.sh, including the PXE/Pimox template builder, NVMe-backed worker clones, Kyverno policy placement, Redis-backed website translation, n8n workflow automation, and the opt-in OpenWrt firewall VM, while the OCI edge routes public traffic back through the private path.',
'blog_arch_caption' => 'The diagram is intentionally operational: it shows the app delivery loop, image flow, provisioning path, worker-placement boundary, monitoring layer, Redis/n8n/Ollama translation path, OpenWrt firewall option, and public traffic path without hiding the practical bits that make a small lab behave like a platform.',
'blog_arch_fun_link' => 'Open the Christmas-tree version',
'blog_activity_kicker' => 'Recent activity log',
'blog_activity_title' => 'What changed since the first build',
@ -168,7 +169,7 @@ return [
'blog_activity_8' => 'Changed Gitea backups to dump from the Raspberry Pi Docker container and store archives on the Debian host.',
'blog_activity_9' => 'Validated the full main-branch deployment path: fetch main, apply OpenTofu layers, build and push arm64 images, refresh Argo CD, and confirm the runner completes successfully.',
'blog_activity_10' => 'Built the Debian 13 arm64 Pimox template end to end with PXE, preseed, qemu-guest-agent discovery, cgroup validation, swap disabled, and a final seal step.',
'blog_activity_11' => 'Added NVMe-backed Pimox worker clone automation so VM 9000 stays on local storage while worker nodes are created on nvme_thin_pool.',
'blog_activity_11' => 'Added Pimox worker clone automation so VM 9000 stays on local storage while worker nodes use a selected non-local datastore.',
'blog_activity_12' => 'Added an opt-in OpenWrt VM path for a simple firewall between vmbr0 and vmbr1, with guardrails that avoid Orange Pi host networking changes.',
'blog_activity_13' => 'Installed the monitoring stack and moved platform add-ons such as Argo CD, Kyverno, and prometheus-stack work toward app-worker placement instead of treating the control plane as spare capacity.',
'blog_activity_14' => 'Reworked the website image to keep Apache as the web server while moving PHP execution to PHP-FPM, enabling OPcache, and validating the runtime through Gitea Actions instead of local image builds.',
@ -187,7 +188,7 @@ return [
'blog_todo_8' => 'Turn the installed observability stack into useful operations views: a few high-signal dashboards, alerts for node health, storage pressure, certificate expiry, and failed app syncs.',
'blog_todo_9' => 'Schedule backup restore drills for external Gitea and OpenEBS volumes, then write the exact restore runbook.',
'blog_todo_10' => 'Tighten TLS, SSH, and token rotation around the OCI edge, Gitea, registry, and runner credentials.',
'blog_todo_11' => 'Document the new storage split: local for the Pimox template, nvme_thin_pool for VM workers, OpenEBS for Kubernetes app data, and backup targets for anything that must survive a rebuild.',
'blog_todo_11' => 'Document the new storage split: local for the Pimox template, data-vg on the HP Debian laptop for Kubernetes app data, Raspberry Pi SSD /data for Gitea and Docker, and backup targets for anything that must survive a rebuild.',
'blog_todo_12' => 'Move sensitive app configuration into Sealed Secrets, External Secrets, or another explicit secret-management path.',
'blog_todo_13' => 'After the new disk has breathing room, clone the first Pimox worker from VM 9000, join it with kubeadm, and verify labels, taints, CNI, storage, and workload scheduling.',
'blog_todo_14' => 'Test the OpenWrt VM in a maintenance window before making it a gateway: confirm WAN, LAN, rollback access, DHCP settings, and that Pimox remains reachable.',

View File

@ -126,14 +126,14 @@ return [
'blog_q7' => 'Current cluster huel quipia nochi inin, o tiquchichinos Pi?',
'blog_a7' => 'Pi survives pampa demos cateh intentionally local-first ihuan now ship as separate static artifact. Website pod mocahua portfolio shell, demos-static pod serves static bundles, ihuan browser quichihua expensive work. Tla later onicship real ONNX object detection, Transformers.js, o full video transcoding models, monequi lazy-load ipan browser o yasque beefier node.',
'blog_q8' => 'Axcan lab huel quichihua own worker nodes?',
'blog_a8' => 'Quena, ihuan axcan ika fewer crossed fingers. Debian quipia provisioning layer ika dnsmasq, nginx, PXE boot files, GRUB, ihuan Debian 13 arm64 preseed. OpenTofu notza Pimox through qm, quichihua VM 9000 ipan local storage, quiboota from network, quinstala OS, quichihua golden-node prep, disables swap, verifies cgroups, installs containerd ihuan kubeadm tooling, then seals it as template. Worker clones cateh idempotent by VMID ihuan now land on nvme_thin_pool.',
'blog_a8' => 'Quena, ihuan axcan ika fewer crossed fingers. Debian quipia provisioning layer ika dnsmasq, nginx, PXE boot files, GRUB, ihuan Debian 13 arm64 preseed. OpenTofu notza Pimox through qm, quichihua VM 9000 ipan local storage, quiboota from network, quinstala OS, quichihua golden-node prep, disables swap, verifies cgroups, installs containerd ihuan kubeadm tooling, then seals it as template. Worker clones cateh idempotent by VMID tla onca stable non-local Pimox datastore.',
'blog_q9' => 'Ihuan OpenWrt no quisa nican?',
'blog_a9' => 'Zan simple firewall, ahmo networking science project. Pipeline huel quichihua opt-in OpenWrt ARM SystemReady VM, attach vmbr0 quen WAN ihuan vmbr1 quen LAN, ihuan configure LAN side ahmo rewriting Orange Pi host networking. DHCP stays optional, VLANs wait until onca managed switch ihuan local test window.',
'blog_q10' => 'Tlein omopatla ipan observability ihuan scheduling side?',
'blog_a10' => 'Monitoring moved de "someday" to "running," ihuan scheduling moved de "whatever fits" to explicit worker placement. Prometheus Stack, Grafana, Loki, Promtail, node-exporter, ihuan kube-state-metrics quimaca useful signals; next step quitosnequi elegir few alerts in melahuac nechixitisque para right reasons.',
'blog_stack_title' => 'Tlamantli ihuan tleica nemi nican',
'blog_stack_1' => 'Debian Linux quimaca steady adult in room: control plane host, deployment workstation, PXE/preseed server, ihuan canin OpenTofu, Docker, kubeadm, ihuan scripts quichihua tequitl.',
'blog_stack_2' => 'Raspberry Pi keeps external Gitea service close to lab, while Pimox ipan Orange Pi 5 Plus provides app-worker pool. Debian template mocahua ipan local storage ihuan Kubernetes worker clones yahui ipan nvme_thin_pool.',
'blog_stack_2' => 'Raspberry Pi keeps external Gitea service close to lab ipan SSD-backed /data, while Pimox ipan Orange Pi 5 Plus huel quimaca app-worker pool tla VM storage stable. HP Debian laptop axcan quipia NVMe-backed data-vg storage para Kubernetes data paths.',
'blog_stack_3' => 'OpenTofu quichihua cluster, platform, apps, edge, ihuan provisioning layers repeatable, pampa "I swear I remember the command" amo disaster recovery strategy.',
'blog_stack_4' => 'Calico handles pod networking, ihuan OpenEBS hostpath storage keeps important data after rebuilds, pampa deleting everything by accident zan funny once.',
'blog_stack_5' => 'Argo CD quimaca GitOps referee ihuan now runs on app workers: manifests cateh ipan Git, cluster follows along, ihuan manual drift gets side-eyed back into place.',
@ -146,14 +146,15 @@ return [
'blog_stack_12' => 'Pimox worker pipeline uses qm over SSH para create OVMF/virtio-scsi Debian 13 arm64 VM, wait for qemu-guest-agent, seal it, ihuan convert VM 9000 into reusable template on local storage.',
'blog_stack_13' => 'Golden image bakes in Kubernetes prerequisites: swap disabled, cgroup boot options checked, kernel modules loaded, containerd configured for systemd cgroups, kubeadm/kubelet/kubectl installed, ihuan qemu-guest-agent enabled.',
'blog_stack_14' => 'Worker clone automation is count-based ihuan idempotent: LAB_PIMOX_WORKER_COUNT=1 quitosnequi ensure VM 9010 exists, amo create fresh worker every run. New workers refused tla target storage is local.',
'blog_stack_15' => 'OpenWrt handled separately from Debian golden-node template. Lab downloads upstream ARM SystemReady EFI image, imports it as VM 9050 on nvme_thin_pool, ihuan keeps disabled unless LAB_OPENWRT_VM=true.',
'blog_stack_15' => 'OpenWrt handled separately from Debian golden-node template. Lab downloads upstream ARM SystemReady EFI image, imports it onto selected non-local Pimox storage, ihuan keeps disabled unless LAB_OPENWRT_VM=true.',
'blog_stack_16' => 'Monitoring layer axcan includes Prometheus Stack, Grafana, Loki, Promtail, node-exporter, ihuan kube-state-metrics, ika placement guardrails para platform add-ons ma amo default to control plane.',
'blog_stack_17' => 'Portfolio website keeps Apache at core, runs PHP through PHP-FPM ika OPcache, pre-renders read-heavy pages into static HTML during image build, ihuan leaves PHP para write ihuan translation endpoints.',
'blog_stack_18' => 'Supply-chain path axcan uses BuildKit SBOM attestations, Cosign signatures, public key ipan cluster, ihuan audit-first Kyverno ImageValidatingPolicy in huel mopromote para deny unsigned local-registry pods.',
'blog_stack_19' => 'Translation path axcan uses Redis para per-string cache hits, custom Ollama website-translator model ipan Debian server para cache misses, n8n para prewarming ihuan batch workflows, ihuan structured logs para cache, latency, JSON parse, ihuan timeout signals.',
'blog_arch_kicker' => 'Architecture mapa',
'blog_arch_title' => 'Homelab, end to end',
'blog_arch_intro' => 'Current delivery path starts ika push to Gitea, runs local validation, builds arm64 images, syncs validated commit into GitOps mirror, ihuan lets Argo CD reconcile from app workers. Infrastructure path stays manual through lab.sh, including PXE/Pimox template builder, NVMe-backed worker clones, Kyverno policy placement, ihuan opt-in OpenWrt firewall VM, while OCI edge routes public traffic back through private path.',
'blog_arch_caption' => 'Diagram intentionally operational: quinextia app delivery loop, image flow, provisioning path, worker-placement boundary, monitoring layer, OpenWrt firewall option, ihuan public traffic path ahmo hiding practical bits in quichihua small lab behave like platform.',
'blog_arch_intro' => 'Current delivery path starts ika push to Gitea, runs local validation, builds arm64 images, syncs validated commit into GitOps mirror, ihuan lets Argo CD reconcile from app workers. Infrastructure path stays manual through lab.sh, including PXE/Pimox template builder, NVMe-backed worker clones, Kyverno policy placement, Redis-backed website translation, n8n workflow automation, ihuan opt-in OpenWrt firewall VM, while OCI edge routes public traffic back through private path.',
'blog_arch_caption' => 'Diagram intentionally operational: quinextia app delivery loop, image flow, provisioning path, worker-placement boundary, monitoring layer, Redis/n8n/Ollama translation path, OpenWrt firewall option, ihuan public traffic path ahmo hiding practical bits in quichihua small lab behave like platform.',
'blog_arch_fun_link' => 'Xiquitta Christmas-tree version',
'blog_activity_kicker' => 'Yancuic activity log',
'blog_activity_title' => 'Tlein omopatla since first build',
@ -168,7 +169,7 @@ return [
'blog_activity_8' => 'Changed Gitea backups to dump from Raspberry Pi Docker container ihuan store archives on Debian host.',
'blog_activity_9' => 'Validated full main-branch deployment path: fetch main, apply OpenTofu layers, build ihuan push arm64 images, refresh Argo CD, ihuan confirm runner completes successfully.',
'blog_activity_10' => 'Built Debian 13 arm64 Pimox template end to end ika PXE, preseed, qemu-guest-agent discovery, cgroup validation, swap disabled, ihuan final seal step.',
'blog_activity_11' => 'Added NVMe-backed Pimox worker clone automation para VM 9000 ma mocahua ipan local storage mientras worker nodes mochihuah ipan nvme_thin_pool.',
'blog_activity_11' => 'Added Pimox worker clone automation para VM 9000 ma mocahua ipan local storage mientras worker nodes use selected non-local datastore.',
'blog_activity_12' => 'Added opt-in OpenWrt VM path para simple firewall between vmbr0 ihuan vmbr1, ika guardrails in avoid Orange Pi host networking changes.',
'blog_activity_13' => 'Installed monitoring stack ihuan moved platform add-ons such as Argo CD, Kyverno, ihuan prometheus-stack toward app-worker placement instead of treating control plane as spare capacity.',
'blog_activity_14' => 'Reworked website image para Apache ma mocahua web server while PHP runs through PHP-FPM, OPcache enabled, ihuan runtime validated through Gitea Actions instead of local image builds.',
@ -187,7 +188,7 @@ return [
'blog_todo_8' => 'Turn installed observability stack into useful operations views: few high-signal dashboards, alerts para node health, storage pressure, certificate expiry, ihuan failed app syncs.',
'blog_todo_9' => 'Schedule backup restore drills para external Gitea ihuan OpenEBS volumes, then write exact restore runbook.',
'blog_todo_10' => 'Tighten TLS, SSH, ihuan token rotation around OCI edge, Gitea, registry, ihuan runner credentials.',
'blog_todo_11' => 'Document new storage split: local para Pimox template, nvme_thin_pool para VM workers, OpenEBS para Kubernetes app data, ihuan backup targets para tlein must survive rebuild.',
'blog_todo_11' => 'Document new storage split: local para Pimox template, data-vg ipan HP Debian laptop para Kubernetes app data, Raspberry Pi SSD /data para Gitea ihuan Docker, ihuan backup targets para tlein must survive rebuild.',
'blog_todo_12' => 'Move sensitive app configuration into Sealed Secrets, External Secrets, o occe explicit secret-management path.',
'blog_todo_13' => 'After new disk has breathing room, clone first Pimox worker from VM 9000, join it ika kubeadm, ihuan verify labels, taints, CNI, storage, ihuan workload scheduling.',
'blog_todo_14' => 'Test OpenWrt VM ipan maintenance window before making it gateway: confirm WAN, LAN, rollback access, DHCP settings, ihuan Pimox remains reachable.',

View File

@ -56,6 +56,12 @@ function loadRuntimeTranslations(string $file, array $base): array {
return $translations;
}
function asset_url(string $path): string {
$fullPath = __DIR__ . '/' . ltrim($path, '/');
$version = is_file($fullPath) ? (string) filemtime($fullPath) : '1';
return htmlspecialchars($path . '?v=' . $version);
}
$lang = getLang($availableLangs);
$en = include "$staticLangDir/en.php";

View File

@ -1314,7 +1314,7 @@ body.home-page.theme-dark {
.theme-dark.home-page .hero-image img {
border-radius: 0;
border: 2px solid #a8ffb0;
filter: saturate(0.8) contrast(1.08);
filter: grayscale(1) contrast(1.12);
}
.theme-dark .cv-header-text {
@ -1331,7 +1331,7 @@ body.home-page.theme-dark {
.theme-dark .cv-portrait-img {
border-radius: 0;
border: 2px solid #a8ffb0;
filter: saturate(0.8) contrast(1.08);
filter: grayscale(1) contrast(1.12);
}
body.theme-dark {
@ -2029,11 +2029,15 @@ body.theme-dark {
}
body.theme-light {
background: #fffff0;
color: #1b1610;
font-family: "Brush Script MT", "Segoe Script", "Snell Roundhand", "Apple Chancery", cursive;
font-size: 1.12rem;
line-height: 1.68;
background:
linear-gradient(rgba(36, 92, 52, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(36, 92, 52, 0.04) 1px, transparent 1px),
#f4f0df;
background-size: 22px 22px;
color: #151a12;
font-family: "Courier New", "Lucida Console", Monaco, monospace;
font-size: 1rem;
line-height: 1.58;
}
body.theme-dark .top-nav,
@ -2144,6 +2148,12 @@ body.theme-dark .diagram-link {
stroke: #39ff14;
}
body.theme-dark .profile-img,
body.theme-dark.home-page .hero-image img,
body.theme-dark .cv-portrait-img {
filter: grayscale(1) contrast(1.12);
}
body.theme-light .top-nav,
body.theme-light .site-theme-toolbar,
body.theme-light .nav-left,
@ -2157,7 +2167,7 @@ body.theme-light h2,
body.theme-light h3,
body.theme-light h4,
body.theme-light strong {
color: #1b1610;
color: #162316;
font-family: inherit;
}
@ -2173,7 +2183,7 @@ body.theme-light .cta,
body.theme-light .diagram-caption,
body.theme-light .source-links span,
body.theme-light .visitor-idea-card footer {
color: #1b1610;
color: #263421;
}
body.theme-light .container,
@ -2199,10 +2209,63 @@ body.theme-light .cv-skill-grid article,
body.theme-light .cv-recruiter-panel,
body.theme-light .cv-proof-section,
body.theme-light .cv-skill-section {
background: #fffff0;
border-color: #1b1610;
color: #1b1610;
box-shadow: none;
background:
linear-gradient(180deg, #245c34 0 14px, transparent 14px),
#fbf7e6;
border: 2px solid #151a12;
border-radius: 3px;
color: #263421;
box-shadow: 5px 5px 0 #245c34;
}
body.theme-light .blog-hero,
body.theme-light .demos-hero {
padding: 22px;
}
body.theme-light .home-proof-card span,
body.theme-light .case-card span,
body.theme-light .blog-kicker,
body.theme-light .section-kicker,
body.theme-light .demo-label,
body.theme-light .activity-number {
color: #0f6b2d;
}
body.theme-light .message.question {
border-left-color: #586e2f;
}
body.theme-light .message.answer {
border-left-color: #0f6b2d;
}
body.theme-light .top-nav,
body.theme-light .site-theme-toolbar {
background: #fbf7e6;
border: 2px solid #151a12;
border-radius: 3px;
padding: 10px 12px;
box-shadow: 4px 4px 0 #245c34;
}
body.theme-light .nav-left::before {
content: "tux@homelab:~$ ";
color: #0f6b2d;
}
body.theme-light.home-page .hero-text h1,
body.theme-light .cv-header-text h1 {
color: #162316;
font-family: inherit;
font-size: clamp(2.2rem, 6vw, 3.1rem);
font-weight: 900;
letter-spacing: 0;
}
body.theme-light.home-page .hero-role {
color: #0f6b2d;
font-weight: 900;
}
body.theme-light .hero-actions a,
@ -2210,10 +2273,12 @@ body.theme-light .cv-recruiter-actions a,
body.theme-light .case-card a,
body.theme-light .idea-form button,
body.theme-light .site-theme-option {
background: #fffff0;
border-color: #1b1610;
color: #1b1610;
background: #fbf7e6;
border: 2px solid #151a12;
border-radius: 3px;
color: #151a12;
font-family: inherit;
box-shadow: 3px 3px 0 #245c34;
}
body.theme-light .site-theme-option.is-active,
@ -2221,23 +2286,26 @@ body.theme-light .hero-actions a:hover,
body.theme-light .cv-recruiter-actions a:hover,
body.theme-light .case-card a:hover,
body.theme-light .idea-form button:hover {
background: #1b1610;
color: #fffff0;
background: #151a12;
color: #d6ff7a;
transform: translate(1px, 1px);
box-shadow: 2px 2px 0 #245c34;
}
body.theme-light .idea-form input,
body.theme-light .idea-form textarea,
body.theme-light .diagram-shell {
background: #fffff0;
border-color: #1b1610;
color: #1b1610;
background: #fbf7e6;
border: 2px solid #151a12;
border-radius: 3px;
color: #151a12;
font-family: inherit;
}
body.theme-light .diagram-zone,
body.theme-light .diagram-node rect {
fill: #fffff0;
stroke: #1b1610;
fill: #fbf7e6;
stroke: #151a12;
}
body.theme-light .diagram-zone-title,
@ -2245,9 +2313,50 @@ body.theme-light .diagram-node text,
body.theme-light .diagram-link-label,
body.theme-light .diagram-node .diagram-small,
body.theme-light .diagram-small {
fill: #1b1610;
fill: #151a12;
}
body.theme-light .diagram-link {
stroke: #1b1610;
stroke: #0f6b2d;
}
body.theme-light .profile-img,
body.theme-light.home-page .hero-image img,
body.theme-light .cv-portrait-img {
border: 3px solid #151a12;
border-radius: 3px;
box-shadow: 5px 5px 0 #245c34;
filter: sepia(0.14) contrast(1.06) saturate(0.88);
}
body.theme-light .cv-portrait-orbit text {
fill: #0f6b2d;
font-family: inherit;
letter-spacing: 0;
}
body.theme-light .source-links a,
body.theme-light .idea-status-thanks {
background: #eef6cf;
}
body.theme-light .idea-status-invalid,
body.theme-light .idea-status-slow,
body.theme-light .idea-status-error {
background: #ffe9d2;
}
body.theme-light .cruncher-icon,
body.theme-light .demo-icon {
background: #e8efcf;
border: 2px solid #151a12;
border-radius: 3px;
color: #151a12;
box-shadow: 3px 3px 0 #245c34;
}
body.theme-light .network-icon span,
body.theme-light .architecture-icon::before,
body.theme-light .architecture-icon::after {
background: #0f6b2d;
}

View File

@ -2,6 +2,8 @@
header('Content-Type: application/json');
$requestStartedAt = microtime(true);
function translate_response(int $status, array $body): never {
http_response_code($status);
echo json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
@ -18,6 +20,22 @@ function clean_translate_text(string $value, int $maxLength = 4000): string {
return $value;
}
function elapsed_ms(float $startedAt): int {
return (int) round((microtime(true) - $startedAt) * 1000);
}
function translation_log(array $fields): void {
$payload = array_merge([
'event' => 'website_translation',
'timestamp' => gmdate('c'),
], $fields);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json !== false) {
error_log($json);
}
}
function redis_encode_command(array $parts): string {
$command = '*' . count($parts) . "\r\n";
foreach ($parts as $part) {
@ -215,14 +233,32 @@ foreach ($texts as $index => $text) {
$missTexts[] = $text;
}
$cacheHits = count($texts) - count($missTexts);
$cacheMisses = count($missTexts);
$failedJsonParseCount = 0;
$ollamaTimeoutCount = 0;
$ollamaLatencyMs = null;
if (!$missTexts) {
ksort($cleanTranslations);
translation_log([
'status' => 'success',
'target_lang' => $targetLang,
'model' => $ollamaModel,
'text_count' => count($texts),
'cache_hits' => $cacheHits,
'cache_misses' => $cacheMisses,
'ollama_latency_ms' => $ollamaLatencyMs,
'failed_json_parse_count' => $failedJsonParseCount,
'timeout_count' => $ollamaTimeoutCount,
'duration_ms' => elapsed_ms($requestStartedAt),
]);
echo json_encode([
'translations' => array_values($cleanTranslations),
'model' => $ollamaModel,
'cache' => [
'hits' => count($texts),
'misses' => 0,
'hits' => $cacheHits,
'misses' => $cacheMisses,
],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
@ -254,28 +290,94 @@ curl_setopt_array($curl, [
CURLOPT_TIMEOUT => 130,
]);
$ollamaStartedAt = microtime(true);
$rawResponse = curl_exec($curl);
$curlError = curl_error($curl);
$curlErrno = curl_errno($curl);
$statusCode = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$ollamaLatencyMs = elapsed_ms($ollamaStartedAt);
if ($rawResponse === false || $statusCode < 200 || $statusCode >= 300) {
$ollamaTimeoutCount = $curlErrno === CURLE_OPERATION_TIMEDOUT ? 1 : 0;
translation_log([
'status' => 'error',
'reason' => 'ollama_request_failed',
'target_lang' => $targetLang,
'model' => $ollamaModel,
'text_count' => count($texts),
'cache_hits' => $cacheHits,
'cache_misses' => $cacheMisses,
'ollama_latency_ms' => $ollamaLatencyMs,
'failed_json_parse_count' => $failedJsonParseCount,
'timeout_count' => $ollamaTimeoutCount,
'ollama_status_code' => $statusCode,
'curl_errno' => $curlErrno,
'duration_ms' => elapsed_ms($requestStartedAt),
]);
translate_response(502, ['error' => 'Ollama request failed', 'status' => $statusCode, 'detail' => $curlError]);
}
$ollamaResponse = json_decode($rawResponse, true);
if (!is_array($ollamaResponse) || !isset($ollamaResponse['response']) || !is_string($ollamaResponse['response'])) {
if (json_last_error() !== JSON_ERROR_NONE) {
$failedJsonParseCount++;
}
translation_log([
'status' => 'error',
'reason' => 'invalid_ollama_response',
'target_lang' => $targetLang,
'model' => $ollamaModel,
'text_count' => count($texts),
'cache_hits' => $cacheHits,
'cache_misses' => $cacheMisses,
'ollama_latency_ms' => $ollamaLatencyMs,
'failed_json_parse_count' => $failedJsonParseCount,
'timeout_count' => $ollamaTimeoutCount,
'ollama_status_code' => $statusCode,
'duration_ms' => elapsed_ms($requestStartedAt),
]);
translate_response(502, ['error' => 'Invalid Ollama response']);
}
$translationJson = trim(str_replace(['```json', '```'], '', $ollamaResponse['response']));
$translations = json_decode($translationJson, true);
if (!is_array($translations) || count($translations) !== count($missTexts)) {
if (json_last_error() !== JSON_ERROR_NONE) {
$failedJsonParseCount++;
}
translation_log([
'status' => 'error',
'reason' => 'unexpected_translation_response',
'target_lang' => $targetLang,
'model' => $ollamaModel,
'text_count' => count($texts),
'cache_hits' => $cacheHits,
'cache_misses' => $cacheMisses,
'ollama_latency_ms' => $ollamaLatencyMs,
'failed_json_parse_count' => $failedJsonParseCount,
'timeout_count' => $ollamaTimeoutCount,
'ollama_status_code' => $statusCode,
'duration_ms' => elapsed_ms($requestStartedAt),
]);
translate_response(502, ['error' => 'Unexpected translation response']);
}
foreach ($translations as $index => $translation) {
if (!is_string($translation) && !is_numeric($translation)) {
translation_log([
'status' => 'error',
'reason' => 'unexpected_translation_item',
'target_lang' => $targetLang,
'model' => $ollamaModel,
'text_count' => count($texts),
'cache_hits' => $cacheHits,
'cache_misses' => $cacheMisses,
'ollama_latency_ms' => $ollamaLatencyMs,
'failed_json_parse_count' => $failedJsonParseCount,
'timeout_count' => $ollamaTimeoutCount,
'ollama_status_code' => $statusCode,
'duration_ms' => elapsed_ms($requestStartedAt),
]);
translate_response(502, ['error' => 'Unexpected translation item']);
}
$originalPosition = $missPositions[$index];
@ -289,11 +391,24 @@ foreach ($missPositions as $position) {
translation_cache_write($cacheWrites);
ksort($cleanTranslations);
translation_log([
'status' => 'success',
'target_lang' => $targetLang,
'model' => $ollamaModel,
'text_count' => count($texts),
'cache_hits' => $cacheHits,
'cache_misses' => $cacheMisses,
'ollama_latency_ms' => $ollamaLatencyMs,
'failed_json_parse_count' => $failedJsonParseCount,
'timeout_count' => $ollamaTimeoutCount,
'ollama_status_code' => $statusCode,
'duration_ms' => elapsed_ms($requestStartedAt),
]);
echo json_encode([
'translations' => array_values($cleanTranslations),
'model' => $ollamaModel,
'cache' => [
'hits' => count($texts) - count($missTexts),
'misses' => count($missTexts),
'hits' => $cacheHits,
'misses' => $cacheMisses,
],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

View File

@ -66,6 +66,24 @@ variable "applications" {
self_heal = true
create_namespace = true
}
n8n = {
project = "default"
path = "apps/n8n"
namespace = "n8n"
target_revision = "main"
prune = true
self_heal = true
create_namespace = true
}
arr-stack = {
project = "default"
path = "apps/arr-stack"
namespace = "arr-stack"
target_revision = "main"
prune = true
self_heal = true
create_namespace = true
}
supply-chain-policy = {
project = "default"
path = "apps/supply-chain-policy"

View File

@ -7,7 +7,7 @@ variable "control_plane_node_labels" {
type = map(string)
default = {
"homelab.dev/node-role" = "control-plane"
"homelab.dev/storage" = "local"
"homelab.dev/storage" = "data-vg"
"homelab.dev/workload-class" = "control-plane"
}
}
@ -48,7 +48,7 @@ variable "node_dns_servers" {
variable "persistent_volume_dirs" {
type = list(string)
default = [
"/var/openebs/local/registry",
"/data/openebs/local/registry",
]
}

40
bootstrap/host/README.md Normal file
View File

@ -0,0 +1,40 @@
# Debian Host Bootstrap
This layer restores the Debian control-plane and personal desktop package set
before the OpenTofu/Kubernetes bootstrap runs.
Fresh Debian recovery path:
```bash
sudo apt-get update
sudo apt-get install -y git ansible sudo
sudo usermod -aG sudo jv
git clone <repo-url> /home/jv/my-homelab-configs
cd /home/jv/my-homelab-configs
ansible-playbook -K -i bootstrap/host/inventory.ini bootstrap/host/playbook.yml
./lab.sh up
```
To also restore captured application behavior/configuration, keep the generated
`home-configs.tar.gz` outside Git and pass the generated vars file:
```bash
ansible-playbook -K -i bootstrap/host/inventory.ini bootstrap/host/playbook.yml \
-e @/path/to/debian-pc-ansible-inventory/ansible-vars.yml
```
The playbook:
- configures apt repositories for Docker, Kubernetes, Helm, OpenTofu, and Brave
- installs the captured Debian package set, filtering packages unavailable on
the current Debian release
- installs Zoom from the upstream `.deb` when requested on amd64
- adds `jv` to expected local groups
- enables core services such as Docker, containerd, kubelet, ssh, cron, and
Bluetooth when installed
- creates homelab persistence directories used by `lab.sh up`
- optionally extracts the captured user config archive into `/home/jv`
Secrets and active sessions are intentionally not stored in this repo. Keep
SSH keys, kubeconfigs, SOPS keys, Docker auth, and browser session/password data
in a separate encrypted backup if you want them restored.

View File

@ -0,0 +1,252 @@
---
debian_pc_user: jv
debian_pc_user_home: "/home/{{ debian_pc_user }}"
debian_pc_kubernetes_minor_version: v1.36
debian_pc_enable_passwordless_sudo: true
debian_pc_persistent_directories:
- /data/openebs/local/registry
- /data/docker
- "/home/{{ debian_pc_user }}/git-server"
- "/home/{{ debian_pc_user }}/backups/gitea"
- "/home/{{ debian_pc_user }}/backups/gitea-restore-drills"
debian_pc_user_groups:
- sudo
- docker
- audio
- bluetooth
- video
- plugdev
debian_pc_enable_services:
- ssh
- cron
- docker
- containerd
- kubelet
- bluetooth
debian_pc_hold_packages:
- containerd.io
- kubeadm
- kubectl
- kubelet
debian_pc_base_packages:
- apt-transport-https
- bash-completion
- ca-certificates
- curl
- git
- gnupg
- gpg
- python3
- python3-apt
- rsync
- sudo
- tar
- unzip
- wget
- zip
# Captured from the Debian PC manual package inventory. The playbook filters
# packages that are unavailable for the current Debian release after adding the
# third-party repositories, so this list can remain faithful to the source host.
debian_pc_apt_manual_packages:
- adduser
- amd64-microcode
- ansible
- apt
- apt-listchanges
- apt-transport-https
- apt-utils
- aria2
- base-files
- base-passwd
- bash
- bash-completion
- bind9-dnsutils
- bind9-host
- blueman
- bluez
- bluez-tools
- brave-browser
- bsdutils
- busybox
- bzip2
- ca-certificates
- chafa
- chromium
- conky-all
- console-setup
- containerd.io
- containernetworking-plugins
- coreutils
- cpio
- cron
- cron-daemon-common
- curl
- dash
- dbus
- debconf
- debconf-i18n
- debian-archive-keyring
- debian-faq
- debianutils
- dhcpcd-base
- diffutils
- direnv
- dmidecode
- dnsmasq
- doc-debian
- docker-buildx-plugin
- docker-ce
- docker-ce-cli
- docker-ce-rootless-extras
- docker-compose-plugin
- docker-model-plugin
- dosfstools
- dpkg
- e2fsprogs
- fdisk
- file
- findutils
- firmware-amd-graphics
- firmware-realtek
- firmware-sof-signed
- gcc-14-base
- geany
- geany-plugins
- gettext-base
- git
- gnumeric
- gnupg
- gparted
- gpg
- grep
- groff-base
- grub-common
- grub-efi-amd64
- gzip
- haveged
- helm
- hostname
- i3
- ifupdown
- imagemagick
- inetutils-telnet
- init
- initramfs-tools
- init-system-helpers
- installation-report
- iproute2
- iputils-ping
- keyboard-configuration
- kitty
- kmod
- krb5-locales
- kubeadm
- kubectl
- kubelet
- laptop-detect
- less
- linux-image-amd64
- linux-sysctl-defaults
- locales
- login
- login.defs
- logrotate
- logsave
- lsof
- lvm2
- man-db
- manpages
- mawk
- mcomix
- media-types
- mount
- mplayer
- nano
- ncurses-base
- ncurses-bin
- ncurses-term
- netbase
- netcat-traditional
- nfs-common
- nftables
- nginx
- nodejs
- openbox
- open-iscsi
- openssh-client
- openssl-provider-legacy
- os-prober
- parted
- passwd
- pavucontrol
- pciutils
- perl
- perl-base
- pipx
- procps
- psmisc
- pulseaudio-module-bluetooth
- qcomicbook
- readline-common
- reportbug
- rocminfo
- rsync
- screen
- sed
- sensible-utils
- shellcheck
- shim-signed
- smplayer
- sqv
- sudo
- synapse
- systemd
- systemd-sysv
- systemd-timesyncd
- sysvinit-utils
- tar
- task-english
- task-laptop
- tasksel
- task-ssh-server
- tcpdump
- thefuck
- tilda
- tmux
- tofu
- traceroute
- transmission
- tree
- tzdata
- ucf
- udev
- unzip
- usbutils
- util-linux
- util-linux-extra
- vim
- vim-common
- vim-tiny
- virtualenvwrapper
- vlc
- wamerican
- wget
- whiptail
- wmctrl
- wtmpdb
- xlsx2csv
- xorg
- xz-utils
- zip
- zlib1g
- zoom
- zstd
debian_pc_config_archive: ""
debian_pc_restore_configs: true

View File

@ -0,0 +1,3 @@
[debian_pc]
localhost ansible_connection=local

250
bootstrap/host/playbook.yml Normal file
View File

@ -0,0 +1,250 @@
---
- name: Restore Debian homelab control-plane and desktop host
hosts: debian_pc
become: true
gather_facts: true
pre_tasks:
- name: Require Debian
ansible.builtin.assert:
that:
- ansible_facts.os_family == "Debian"
- ansible_facts.distribution == "Debian"
fail_msg: "This host bootstrap is intentionally scoped to Debian."
- name: Install apt prerequisites
ansible.builtin.apt:
name: "{{ debian_pc_base_packages }}"
state: present
update_cache: true
- name: Ensure apt keyring directory exists
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
tasks:
- name: Add Docker apt key
ansible.builtin.get_url:
url: https://download.docker.com/linux/debian/gpg
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
- name: Add Docker apt source
ansible.builtin.copy:
dest: /etc/apt/sources.list.d/docker.sources
mode: "0644"
content: |
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: {{ ansible_facts.distribution_release }}
Components: stable
Architectures: {{ ansible_facts.architecture | regex_replace('x86_64', 'amd64') | regex_replace('aarch64', 'arm64') }}
Signed-By: /etc/apt/keyrings/docker.asc
- name: Add Kubernetes apt key
ansible.builtin.shell: |
set -euo pipefail
curl -fsSL "https://pkgs.k8s.io/core:/stable:/{{ debian_pc_kubernetes_minor_version }}/deb/Release.key" |
gpg --dearmor >/etc/apt/keyrings/kubernetes-apt-keyring.gpg
chmod 0644 /etc/apt/keyrings/kubernetes-apt-keyring.gpg
args:
executable: /bin/bash
creates: /etc/apt/keyrings/kubernetes-apt-keyring.gpg
- name: Add Kubernetes apt source
ansible.builtin.copy:
dest: /etc/apt/sources.list.d/kubernetes.list
mode: "0644"
content: |
deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/{{ debian_pc_kubernetes_minor_version }}/deb/ /
- name: Add Helm apt key
ansible.builtin.shell: |
set -euo pipefail
helm_key_fingerprint="DDF78C3E6EBB2D2CC223C95C62BA89D07698DBC6"
tmp_key="$(mktemp)"
trap 'rm -f "${tmp_key}"' EXIT
curl -fsSL https://packages.buildkite.com/helm-linux/helm-debian/gpgkey >"${tmp_key}"
actual_fingerprint="$(gpg --show-keys --with-colons "${tmp_key}" | awk -F: '$1 == "fpr" { print $10; exit }')"
if [ "${actual_fingerprint}" != "${helm_key_fingerprint}" ]; then
echo "Unexpected Helm apt key fingerprint: ${actual_fingerprint}" >&2
exit 1
fi
gpg --dearmor <"${tmp_key}" >/etc/apt/keyrings/helm.gpg
chmod 0644 /etc/apt/keyrings/helm.gpg
args:
executable: /bin/bash
creates: /etc/apt/keyrings/helm.gpg
- name: Add Helm apt source
ansible.builtin.copy:
dest: /etc/apt/sources.list.d/helm-stable-debian.list
mode: "0644"
content: |
deb [arch={{ ansible_facts.architecture | regex_replace('x86_64', 'amd64') | regex_replace('aarch64', 'arm64') }} signed-by=/etc/apt/keyrings/helm.gpg] https://packages.buildkite.com/helm-linux/helm-debian/any/ any main
- name: Add OpenTofu apt keys
ansible.builtin.shell: |
set -euo pipefail
curl -fsSL https://get.opentofu.org/opentofu.gpg >/etc/apt/keyrings/opentofu.gpg
curl -fsSL https://packages.opentofu.org/opentofu/tofu/gpgkey |
gpg --dearmor >/etc/apt/keyrings/opentofu-repo.gpg
chmod 0644 /etc/apt/keyrings/opentofu.gpg /etc/apt/keyrings/opentofu-repo.gpg
args:
executable: /bin/bash
creates: /etc/apt/keyrings/opentofu-repo.gpg
- name: Add OpenTofu apt source
ansible.builtin.copy:
dest: /etc/apt/sources.list.d/opentofu.list
mode: "0644"
content: |
deb [signed-by=/etc/apt/keyrings/opentofu.gpg,/etc/apt/keyrings/opentofu-repo.gpg] https://packages.opentofu.org/opentofu/tofu/any/ any main
- name: Add Brave apt key
ansible.builtin.get_url:
url: https://brave-browser-apt-release.s3.brave.com/brave-browser-archive-keyring.gpg
dest: /etc/apt/keyrings/brave-browser-archive-keyring.gpg
mode: "0644"
- name: Add Brave apt source
ansible.builtin.copy:
dest: /etc/apt/sources.list.d/brave-browser-release.sources
mode: "0644"
content: |
Types: deb
URIs: https://brave-browser-apt-release.s3.brave.com/
Suites: stable
Components: main
Architectures: amd64 arm64
Signed-By: /etc/apt/keyrings/brave-browser-archive-keyring.gpg
- name: Refresh apt cache after repository setup
ansible.builtin.apt:
update_cache: true
- name: Write desired package list for availability filtering
ansible.builtin.copy:
dest: /tmp/homelab-debian-pc-desired-packages.txt
mode: "0644"
content: |
{% for package in (debian_pc_base_packages + debian_pc_apt_manual_packages) | unique | sort %}
{{ package }}
{% endfor %}
- name: Filter packages available to apt
ansible.builtin.shell: |
set -euo pipefail
while IFS= read -r package; do
[ -n "$package" ] || continue
if apt-cache policy "$package" | awk '/Candidate:/ && $2 != "(none)" { found = 1 } END { exit found ? 0 : 1 }'; then
printf '%s\n' "$package"
fi
done </tmp/homelab-debian-pc-desired-packages.txt
args:
executable: /bin/bash
register: debian_pc_installable_packages_result
changed_when: false
- name: Track installable package list
ansible.builtin.set_fact:
debian_pc_installable_packages: "{{ debian_pc_installable_packages_result.stdout_lines | default([]) }}"
- name: Install available Debian PC packages
ansible.builtin.apt:
name: "{{ debian_pc_installable_packages }}"
state: present
when: debian_pc_installable_packages | length > 0
- name: Install Zoom from upstream deb when requested on amd64
ansible.builtin.apt:
deb: https://zoom.us/client/latest/zoom_amd64.deb
state: present
when:
- "'zoom' in debian_pc_apt_manual_packages"
- ansible_facts.architecture == "x86_64"
- name: Hold Kubernetes and runtime packages
ansible.builtin.dpkg_selections:
name: "{{ item }}"
selection: hold
loop: "{{ debian_pc_hold_packages }}"
failed_when: false
- name: Ensure expected local groups exist
ansible.builtin.group:
name: "{{ item }}"
state: present
loop: "{{ debian_pc_user_groups }}"
- name: Ensure user supplementary groups
ansible.builtin.user:
name: "{{ debian_pc_user }}"
groups: "{{ debian_pc_user_groups }}"
append: true
- name: Allow passwordless sudo for homelab automation
ansible.builtin.copy:
dest: "/etc/sudoers.d/90-homelab-{{ debian_pc_user }}"
owner: root
group: root
mode: "0440"
validate: "visudo -cf %s"
content: |
{{ debian_pc_user }} ALL=(ALL) NOPASSWD:ALL
when: debian_pc_enable_passwordless_sudo | bool
- name: Ensure persistent homelab directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ debian_pc_user if item.startswith(debian_pc_user_home) else 'root' }}"
group: "{{ debian_pc_user if item.startswith(debian_pc_user_home) else 'root' }}"
mode: "{{ '0755' if item.startswith(debian_pc_user_home) else '0775' }}"
loop: "{{ debian_pc_persistent_directories }}"
- name: Enable expected services when installed
ansible.builtin.systemd:
name: "{{ item }}"
enabled: true
state: started
loop: "{{ debian_pc_enable_services }}"
failed_when: false
- name: Check whether config archive is present
ansible.builtin.stat:
path: "{{ debian_pc_config_archive }}"
register: debian_pc_config_archive_stat
when:
- debian_pc_restore_configs | bool
- debian_pc_config_archive | length > 0
- name: Restore captured home application configs
ansible.builtin.unarchive:
src: "{{ debian_pc_config_archive }}"
dest: "{{ debian_pc_user_home }}"
remote_src: true
owner: "{{ debian_pc_user }}"
group: "{{ debian_pc_user }}"
when:
- debian_pc_restore_configs | bool
- debian_pc_config_archive | length > 0
- debian_pc_config_archive_stat.stat.exists | default(false)
- name: Report packages skipped because apt has no candidate
ansible.builtin.shell: |
set -euo pipefail
comm -23 \
<(sort -u /tmp/homelab-debian-pc-desired-packages.txt) \
<(printf '%s\n' {{ debian_pc_installable_packages | map('quote') | join(' ') }} | sort -u)
args:
executable: /bin/bash
register: debian_pc_skipped_packages_result
changed_when: false
- name: Show skipped packages
ansible.builtin.debug:
msg: "{{ debian_pc_skipped_packages_result.stdout_lines }}"
when: debian_pc_skipped_packages_result.stdout_lines | length > 0

View File

@ -56,7 +56,7 @@ variable "openebs" {
version = "4.3.3"
namespace = "openebs"
retain_storage_class = "openebs-hostpath-retain"
base_path = "/var/openebs/local"
base_path = "/data/openebs/local"
}
}

View File

@ -112,10 +112,13 @@ networking, virtio-scsi disk, `vmbr0`, `local` template storage, 1 socket with
`./lab.sh up` also creates or reuses worker clones after the template exists. It
defaults to one worker, VMID `9010`, name `pimox-worker-01`, deterministic
locally administered MAC address, 1 socket
with 2 cores, 4 GiB RAM, `nvme_thin_pool` clone storage, and qemu-guest-agent IP
discovery. New workers are full clones created with
`qm clone --storage`, so the template can remain on `local` while worker disks
land on the NVMe thin pool. Set `LAB_PIMOX_WORKER_REPLACE_EXISTING=true` to
with 2 cores, 4 GiB RAM, `opi5_ssd` clone storage, and qemu-guest-agent IP
discovery when that Pimox storage is available. New workers are full clones
created with `qm clone --storage`, so the template can remain on `local` while
worker disks land on the Orange Pi SSD-backed non-local Pimox datastore. Keep
durable data on the HP Debian laptop's `data-vg` or external backups and treat
Orange Pi VM disks as rebuildable worker capacity. Set
`LAB_PIMOX_WORKER_REPLACE_EXISTING=true` to
destroy and recreate existing worker VMs from the current template. The pipeline
refuses `LAB_PIMOX_WORKER_STORAGE=local` so only the template VM lives on local
storage.
@ -136,7 +139,7 @@ LAB_PIMOX_WORKER_COUNT=0 ./lab.sh up
LAB_PIMOX_WORKER_COUNT=2 ./lab.sh up
LAB_PIMOX_SKIP_WORKER_INDEXES=1 LAB_PIMOX_WORKER_COUNT=2 ./lab.sh up
LAB_PIMOX_WORKER_BASE_VMID=9020 ./lab.sh up
LAB_PIMOX_WORKER_STORAGE=nvme_thin_pool ./lab.sh up
LAB_PIMOX_WORKER_STORAGE=<stable-pimox-storage> ./lab.sh up
LAB_PIMOX_WORKER_REPLACE_EXISTING=true ./lab.sh up
LAB_PIMOX_HOST=192.168.100.80 LAB_PIMOX_BRIDGE=vmbr0 ./lab.sh up
```
@ -159,7 +162,7 @@ Defaults:
- VMID `9100`
- VM name `openwrt-firewall`
- disk storage `nvme_thin_pool`
- disk storage `opi5_ssd` when that Pimox storage is available
- WAN bridge `vmbr0`
- LAN bridge `vmbr1`
- LAN address `192.168.50.1/24`
@ -172,7 +175,7 @@ Useful overrides:
```bash
LAB_OPENWRT_VMID=9100
LAB_OPENWRT_STORAGE=nvme_thin_pool
LAB_OPENWRT_STORAGE=<stable-pimox-storage>
LAB_OPENWRT_WAN_BRIDGE=vmbr0
LAB_OPENWRT_LAN_BRIDGE=vmbr1
LAB_OPENWRT_LAN_IP=192.168.50.1
@ -184,6 +187,6 @@ LAB_OPENWRT_VERSION=24.10.6
LAB_OPENWRT_IMAGE_URL=https://downloads.openwrt.org/releases/24.10.6/targets/armsr/armv8/openwrt-24.10.6-armsr-armv8-generic-ext4-combined-efi.img.gz
```
The pipeline validates `vmbr0`, `vmbr1`, and `nvme_thin_pool` on the Pimox host.
The pipeline validates `vmbr0`, `vmbr1`, and the selected storage on the Pimox host.
It refuses `local` as OpenWrt storage and refuses to create or modify host
network bridges.

View File

@ -4,14 +4,14 @@ Gitea is bootstrap infrastructure, not a Kubernetes workload.
`lab.sh deploy-gitea` copies `docker-compose.yml` to the Raspberry Pi and runs
Gitea as an always-on Docker Compose service. The current default stores data on
the Pi SD card under `/opt/homelab-gitea/data`; move
`LAB_GITEA_INSTALL_DIR` to an SSD mount when the SSD is added.
the Raspberry Pi SSD under `/data/homelab-gitea/data`; the Pi Docker root is
also expected to live on the SSD-backed `/data` mount.
Defaults:
- host: `192.168.100.89`
- user: `jv`
- install dir: `/opt/homelab-gitea`
- install dir: `/data/homelab-gitea`
- HTTP port: `3000`
- SSH port: `32222`
- public root URL: `https://lab2025.duckdns.org/git/`

75
lab.sh
View File

@ -191,6 +191,14 @@ adopt_apps_existing_resources() {
"argoproj.io/v1alpha1" \
"Application" \
"heimdall"
adopt_tofu_kubernetes_manifest \
"${stack}" \
'kubernetes_manifest.argocd_application["n8n"]' \
"${namespace}" \
"applications.argoproj.io" \
"argoproj.io/v1alpha1" \
"Application" \
"n8n"
adopt_tofu_kubernetes_manifest \
"${stack}" \
'kubernetes_manifest.argocd_application["supply-chain-policy"]' \
@ -886,7 +894,7 @@ run_pimox_pipeline() {
local worker_memory="${LAB_PIMOX_WORKER_MEMORY:-4096}"
local worker_cpu_affinities="${LAB_PIMOX_WORKER_CPU_AFFINITIES:-}"
local worker_replace_existing="${LAB_PIMOX_WORKER_REPLACE_EXISTING:-false}"
local worker_storage="${LAB_PIMOX_WORKER_STORAGE:-${TF_VAR_pimox_worker_storage:-nvme_thin_pool}}"
local worker_storage="${LAB_PIMOX_WORKER_STORAGE:-${TF_VAR_pimox_worker_storage:-opi5_ssd}}"
local worker_user="${LAB_PIMOX_WORKER_USER:-jv}"
local worker_key_path="${LAB_PIMOX_WORKER_SSH_KEY_PATH:-/home/jv/.ssh/id_ed25519}"
local ip_prefix="${LAB_PIMOX_GUEST_IP_PREFIX:-192.168.100.}"
@ -948,6 +956,18 @@ fi
if ! sudo -n true >/dev/null 2>&1; then
echo 'passwordless sudo is not available for ${pimox_user}'
exit 1
fi
pvesm_cmd=\"\$(command -v pvesm 2>/dev/null || true)\"
if [ -z \"\$pvesm_cmd\" ] && [ -x /usr/sbin/pvesm ]; then
pvesm_cmd=/usr/sbin/pvesm
fi
if [ -z \"\$pvesm_cmd\" ]; then
echo 'pvesm was not found; cannot validate Pimox worker storage ${worker_storage}'
exit 1
fi
if ! sudo \"\$pvesm_cmd\" status | awk -v storage='${worker_storage}' 'NR > 1 && \$1 == storage && \$3 == \"active\" { found = 1 } END { exit found ? 0 : 1 }'; then
echo 'Pimox worker storage ${worker_storage} was not found or is not active'
exit 1
fi" 2>&1)"
readiness_status=$?
set -e
@ -1046,7 +1066,7 @@ run_openwrt_pipeline() {
local qm_bin="${LAB_PIMOX_QM_BIN:-${TF_VAR_pimox_qm_bin:-/usr/sbin/qm}}"
local vmid="${LAB_OPENWRT_VMID:-9100}"
local vm_name="${LAB_OPENWRT_NAME:-openwrt-firewall}"
local storage="${LAB_OPENWRT_STORAGE:-nvme_thin_pool}"
local storage="${LAB_OPENWRT_STORAGE:-opi5_ssd}"
local wan_bridge="${LAB_OPENWRT_WAN_BRIDGE:-vmbr0}"
local lan_bridge="${LAB_OPENWRT_LAN_BRIDGE:-vmbr1}"
local cores="${LAB_OPENWRT_CORES:-2}"
@ -2029,7 +2049,7 @@ deploy_gitea() {
local gitea_host="${LAB_GITEA_HOST:-${LAB_RASPBERRY_HOST:-192.168.100.89}}"
local gitea_user="${LAB_GITEA_USER:-${LAB_RASPBERRY_USER:-jv}}"
local gitea_key="${LAB_GITEA_SSH_KEY_PATH:-${LAB_RASPBERRY_SSH_KEY_PATH:-/home/jv/.ssh/id_ed25519}}"
local install_dir="${LAB_GITEA_INSTALL_DIR:-/opt/homelab-gitea}"
local install_dir="${LAB_GITEA_INSTALL_DIR:-/data/homelab-gitea}"
local image="${LAB_GITEA_IMAGE:-gitea/gitea:1.21.7}"
local http_port="${LAB_GITEA_HTTP_PORT:-3000}"
local ssh_port="${LAB_GITEA_SSH_PORT:-32222}"
@ -2920,6 +2940,31 @@ website_translation_model() {
echo "Ollama model ${model_name} is ready."
}
website_ollama_listen() {
local bind_address="${WEBSITE_OLLAMA_BIND_ADDRESS:-0.0.0.0:11434}"
local dropin_dir="/etc/systemd/system/ollama.service.d"
local dropin_file="${dropin_dir}/homelab-listen.conf"
require_debian_server "website-ollama-listen"
if ! systemctl cat ollama.service >/dev/null 2>&1; then
echo "ollama.service was not found on this Debian server." >&2
exit 1
fi
echo "Configuring ollama.service to listen on ${bind_address}..."
sudo mkdir -p "${dropin_dir}"
printf '[Service]\nEnvironment="OLLAMA_HOST=%s"\n' "${bind_address}" |
sudo tee "${dropin_file}" >/dev/null
sudo systemctl daemon-reload
sudo systemctl restart ollama.service
sudo systemctl is-active --quiet ollama.service
echo "Ollama service is active. Verifying http://${bind_address%:*}:11434/api/tags..."
curl -fsS "http://127.0.0.1:11434/api/tags" >/dev/null
echo "Ollama API is reachable on the Debian host."
}
apps() {
local buildx_builder_ready=false
local demos_image_built=false
@ -3069,6 +3114,23 @@ apps() {
wait_for_namespaced_resource heimdall deployment heimdall heimdall 300
wait_for_deployment_ready heimdall heimdall heimdall 300
refresh_argocd_application n8n
wait_for_namespace n8n n8n 300
wait_for_namespaced_resource n8n deployment n8n n8n 300
wait_for_deployment_ready n8n n8n n8n 300
refresh_argocd_application arr-stack
wait_for_namespace arr-stack arr-stack 300
wait_for_namespaced_resource arr-stack deployment prowlarr arr-stack 300
wait_for_deployment_ready arr-stack flaresolverr arr-stack 300
wait_for_deployment_ready arr-stack prowlarr arr-stack 300
wait_for_deployment_ready arr-stack radarr arr-stack 300
wait_for_deployment_ready arr-stack sonarr arr-stack 300
wait_for_deployment_ready arr-stack qbittorrent arr-stack 300
wait_for_deployment_ready arr-stack kapowarr arr-stack 300
wait_for_deployment_ready arr-stack suwayomi arr-stack 300
wait_for_deployment_ready arr-stack maintainerr arr-stack 300
echo "Application deployment successfully completed."
}
@ -3258,7 +3320,7 @@ EOF
rm -f "${REPO_ROOT}"/bootstrap/edge/.terraform.tfstate.lock.info
rm -rf "${REPO_ROOT}"/bootstrap/edge/.terraform/
echo "Destruction complete. Retained data under /var/openebs/local was left intact."
echo "Destruction complete. Retained data under /data/openebs/local was left intact."
}
case "${1:-}" in
@ -3274,6 +3336,9 @@ case "${1:-}" in
website-translation-model)
website_translation_model
;;
website-ollama-listen)
website_ollama_listen
;;
deploy-gitea)
deploy_gitea
;;
@ -3302,7 +3367,7 @@ case "${1:-}" in
nuke
;;
*)
echo "Usage: $0 {up|rebuild-cluster|apps|website-translation-model|deploy-gitea|bootstrap-gitea-repo|backup-gitea|drill-gitea-restore|install-gitea-runner|move-prometheus-stack-workers|doctor-versions|openwrt|nuke}"
echo "Usage: $0 {up|rebuild-cluster|apps|website-translation-model|website-ollama-listen|deploy-gitea|bootstrap-gitea-repo|backup-gitea|drill-gitea-restore|install-gitea-runner|move-prometheus-stack-workers|doctor-versions|openwrt|nuke}"
exit 1
;;
esac