Compare commits
10 Commits
2b636b97e2
...
39d500866c
| Author | SHA1 | Date |
|---|---|---|
|
|
39d500866c | |
|
|
207f7274fe | |
|
|
3429069b9e | |
|
|
a69966c457 | |
|
|
f121910cba | |
|
|
a270c6eca4 | |
|
|
b45cd2d508 | |
|
|
d5abeca4b1 | |
|
|
ab68569dbe | |
|
|
35c6c6f654 |
|
|
@ -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
117
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- storage.yaml
|
||||
- web-app.yaml
|
||||
- ingress.yaml
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: arr-stack
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- web-app.yaml
|
||||
- ingress.yaml
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: n8n
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
[debian_pc]
|
||||
localhost ansible_connection=local
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
75
lab.sh
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue