cluster

Infrastructure files for Nordgedanken and Midnightthoughts.
git clone git://archive.git.mtrnord.blog/MTRNord/cluster.git
Log | Files | Refs | README

commit fe6ec93cf55999b2fb5fe9da8e6b08247bab1985
parent 225fc80cf390642f8c4d0c7627138f00d3fdc46b
Author: MTRNord <MTRNord@users.noreply.github.com>
Date:   Mon, 23 Mar 2026 11:00:40 +0100

make inline docs happen

Signed-off-by: MTRNord <MTRNord@users.noreply.github.com>

Diffstat:
A.github/workflows/docs.yml | 35+++++++++++++++++++++++++++++++++++
M.gitignore | 1+
MDISASTER_RECOVERY.md | 67+++++++++++++++++++++++++++++++++++++------------------------------
AMakefile | 23+++++++++++++++++++++++
MREADME.md | 153++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Aapps/.pages | 1+
Aapps/talos_cluster/.pages | 1+
Aapps/talos_cluster/k6-operator/README.md | 1+
Adocs/gen_pages.py | 38++++++++++++++++++++++++++++++++++++++
Ainfrastructure_talos/.pages | 1+
Amkdocs.yml | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Arequirements-docs.txt | 3+++
12 files changed, 284 insertions(+), 90 deletions(-)

diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml @@ -0,0 +1,35 @@ +name: Deploy Documentation + +on: + push: + branches: [main] + paths: + - "**/*.md" + - "mkdocs.yml" + - "requirements-docs.txt" + - ".pages" + - "**/.pages" + workflow_dispatch: + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements-docs.txt + + - name: Install dependencies + run: pip install -r requirements-docs.txt + + - name: Deploy to GitHub Pages + run: mkdocs gh-deploy --force diff --git a/.gitignore b/.gitignore @@ -1,4 +1,5 @@ age.agekey +site/ bak_synapse.txt audit.txt homeserver_config.yaml diff --git a/DISASTER_RECOVERY.md b/DISASTER_RECOVERY.md @@ -1,4 +1,4 @@ -# Disaster Recovery Runbook — cluster-2025 +# Disaster Recovery Runbook Last updated: 2026-03-21 @@ -8,12 +8,12 @@ Last updated: 2026-03-21 This cluster has three layers of backup: -| Layer | Tool | Scope | Frequency | Retention | Storage | -|---|---|---|---|---|---| -| Application + Volumes | Velero (Kopia) | All namespaces + hcloud-volumes | 4× daily (0,6,12,18 UTC) | 14 days | `mtrnord-talos-velero` S3 bucket | -| Longhorn Volumes | Longhorn backup (incremental, full every 14d) | All Longhorn volumes (group: default) | Daily 02:00 UTC | 14 backups | `mtrnord-longhorn-backup` S3 bucket | -| Longhorn Config | Longhorn system-backup | Longhorn settings + metadata | Daily 05:00 UTC | 7 backups | `mtrnord-longhorn-backup` S3 bucket | -| PostgreSQL WAL | CNPG barman-cloud | postgres-cluster | Continuous + scheduled | 30 days | `mtrnord-talos-pg-backup` S3 bucket | +| Layer | Tool | Scope | Frequency | Retention | Storage | +| --------------------- | --------------------------------------------- | ------------------------------------- | ------------------------ | ---------- | ----------------------------------- | +| Application + Volumes | Velero (Kopia) | All namespaces + hcloud-volumes | 4× daily (0,6,12,18 UTC) | 14 days | `mtrnord-talos-velero` S3 bucket | +| Longhorn Volumes | Longhorn backup (incremental, full every 14d) | All Longhorn volumes (group: default) | Daily 02:00 UTC | 14 backups | `mtrnord-longhorn-backup` S3 bucket | +| Longhorn Config | Longhorn system-backup | Longhorn settings + metadata | Daily 05:00 UTC | 7 backups | `mtrnord-longhorn-backup` S3 bucket | +| PostgreSQL WAL | CNPG barman-cloud | postgres-cluster | Continuous + scheduled | 30 days | `mtrnord-talos-pg-backup` S3 bucket | > **Key constraint**: hcloud-volumes CSI does NOT support snapshots. Velero uses Kopia (file-system copy into S3) for those volumes, which requires applications to be quiesced or tolerant of slightly inconsistent snapshots. @@ -50,6 +50,7 @@ velero restore create --from-backup cluster-backup-0600-20260228060000 \ ### Critical: how Velero Kopia volume restore actually works Kopia does NOT restore data directly into a PVC. Instead: + 1. Velero restores the **pod** (with an injected `restore-wait` init container) 2. The node-agent writes backup data into the PVC while the init container waits 3. Only after kopia completes does the main container start @@ -123,6 +124,7 @@ This is safe when the restored pod was deleted immediately (kopia never started) ### Backup consistency: databases being written during backup Velero Kopia takes a file-system snapshot of a live volume. For databases like RocksDB (continuwuity/conduwuit), the backup may capture an inconsistent state if: + - The database was in recovery mode during backup - The CURRENT file was written pointing to a MANIFEST that was later renamed/replaced before the snapshot completed @@ -174,7 +176,7 @@ bootstrap: recovery: source: pg-s3-backup recoveryTarget: - targetTime: "2026-02-27T20:00:00Z" # RFC 3339, adjust as needed + targetTime: "2026-02-27T20:00:00Z" # RFC 3339, adjust as needed ``` ### 2c. Important lessons from past migrations @@ -224,30 +226,34 @@ Backup target: `s3://mtrnord-longhorn-backup` (Hetzner Object Storage HEL1) **Current configuration:** -| Job name | Type | Schedule | Group | Retain | Concurrency | Notes | -|---|---|---|---|---|---|---| -| `volume-backup` | `backup` | `0 2 * * *` (02:00) | default | 14 | 2 | Volume data → S3. Primary recovery source. Incremental, full every 14 days. | -| `post-backup-cleanup` | `snapshot-delete` | `0 3 * * *` (03:00) | default | 2 | 2 | Enforces max 2 snapshots per volume after backup runs. Prevents copy/move failures. | -| `system-backup` | `system-backup` | `0 5 * * *` (05:00) | — | 7 | — | Longhorn config/metadata backup. `volume-backup-policy: if-not-present`. | -| `filesystem-trim` | `filesystem-trim` | `0 4 * * *` (04:00) | default | — | 2 | Reclaim space from deleted files. Runs between backup (02:00) and system-backup (05:00). | +| Job name | Type | Schedule | Group | Retain | Concurrency | Notes | +| --------------------- | ----------------- | ------------------- | ------- | ------ | ----------- | ---------------------------------------------------------------------------------------- | +| `volume-backup` | `backup` | `0 2 * * *` (02:00) | default | 14 | 2 | Volume data → S3. Primary recovery source. Incremental, full every 14 days. | +| `post-backup-cleanup` | `snapshot-delete` | `0 3 * * *` (03:00) | default | 2 | 2 | Enforces max 2 snapshots per volume after backup runs. Prevents copy/move failures. | +| `system-backup` | `system-backup` | `0 5 * * *` (05:00) | — | 7 | — | Longhorn config/metadata backup. `volume-backup-policy: if-not-present`. | +| `filesystem-trim` | `filesystem-trim` | `0 4 * * *` (04:00) | default | — | 2 | Reclaim space from deleted files. Runs between backup (02:00) and system-backup (05:00). | **Global Longhorn settings:** + - Max snapshots per volume: **5** (hard ceiling, monitoring before raising — `snapshot-delete` retain=2 enforces the soft limit, leaving 3 slots for system snapshots during replica rebuilds) - Backup target: `s3://mtrnord-longhorn-backup` (Hetzner Object Storage HEL1) **Why no `snapshot` or `snapshot-cleanup` job:** + - No `snapshot` job: with a low global snapshot limit, an hourly retain=24 would immediately hit the ceiling. Velero (6h) + Longhorn backup (daily) provide sufficient recovery points without in-cluster snapshots. - No `snapshot-cleanup`: redundant when `backup` job does pre-backup cleanup and `snapshot-delete` enforces the count hard limit. ### 3a. Restore a single Longhorn volume from backup **Via Longhorn UI (easiest):** + 1. Go to Longhorn UI → Backup 2. Find the volume backup 3. Click Restore → enter a name for the restored volume 4. Once restored, create a PVC pointing to the new volume or update the app's PVC **Via kubectl:** + ```bash # List available backups kubectl -n longhorn-system get backups.longhorn.io @@ -308,12 +314,12 @@ Velero recreates the VolumeSnapshot and Longhorn restores the volume from it aut ### 3d. Which method to use? -| Situation | Best method | -|---|---| -| Single volume, recent data loss | Longhorn UI restore (3a) | -| Need data from >4 days ago | Velero restore (3c) — 14-day retention | -| Total Longhorn state loss | Longhorn system-backup restore (3b) | -| App namespace fully deleted | Velero namespace restore (Scenario 1) | +| Situation | Best method | +| ------------------------------- | -------------------------------------- | +| Single volume, recent data loss | Longhorn UI restore (3a) | +| Need data from >4 days ago | Velero restore (3c) — 14-day retention | +| Total Longhorn state loss | Longhorn system-backup restore (3b) | +| App namespace fully deleted | Velero namespace restore (Scenario 1) | --- @@ -322,6 +328,7 @@ Velero recreates the VolumeSnapshot and Longhorn restores the volume from it aut Use this when the entire cluster is gone and you need to rebuild from scratch. ### Prerequisites + - Terraform state intact (in Hetzner Cloud or backed up) - Access to S3 buckets: `mtrnord-talos-velero` and `mtrnord-talos-pg-backup` - Age private key (stored separately — see below) @@ -409,16 +416,16 @@ flux get ks apps ## Key Backup Locations -| What | Where | Path | -|---|---|---| -| Velero backups (all namespaces + volumes) | Hetzner Object Storage HEL1 | `mtrnord-talos-velero` bucket | -| Postgres WAL archives | Hetzner Object Storage HEL1 | `mtrnord-talos-pg-backup/pg-base-backup/pg-cluster-v2/` | -| Postgres scheduled base backups | same bucket | `mtrnord-talos-pg-backup/pg-base-backup/pg-cluster-v2/base/` | -| Longhorn volume backups | Hetzner Object Storage HEL1 | `mtrnord-longhorn-backup` bucket | -| Longhorn system backups | same bucket | `mtrnord-longhorn-backup` bucket | -| GitOps repo | GitHub | MTRNord/gitops | -| Terraform state | Hetzner Cloud S3 / local | cluster2025-talos/cloud/terraform.tfstate | -| Age private key | Local machine | `~/.config/sops/age/keys.txt` or `age.agekey` | +| What | Where | Path | +| ----------------------------------------- | --------------------------- | ------------------------------------------------------------ | +| Velero backups (all namespaces + volumes) | Hetzner Object Storage HEL1 | `mtrnord-talos-velero` bucket | +| Postgres WAL archives | Hetzner Object Storage HEL1 | `mtrnord-talos-pg-backup/pg-base-backup/pg-cluster-v2/` | +| Postgres scheduled base backups | same bucket | `mtrnord-talos-pg-backup/pg-base-backup/pg-cluster-v2/base/` | +| Longhorn volume backups | Hetzner Object Storage HEL1 | `mtrnord-longhorn-backup` bucket | +| Longhorn system backups | same bucket | `mtrnord-longhorn-backup` bucket | +| GitOps repo | GitHub | MTRNord/gitops | +| Terraform state | Hetzner Cloud S3 / local | cluster2025-talos/cloud/terraform.tfstate | +| Age private key | Local machine | `~/.config/sops/age/keys.txt` or `age.agekey` | > **CRITICAL**: The age private key is the master key for all cluster secrets. Store it in a password manager (Bitwarden, etc.) in addition to the local file. Without it you cannot decrypt any secret in the cluster. diff --git a/Makefile b/Makefile @@ -0,0 +1,23 @@ +export DISABLE_MKDOCS_2_WARNING := true + +.PHONY: docs-install docs docs-build docs-deploy docs-clean + +## Install documentation dependencies (run once) +docs-install: + pip install -r requirements-docs.txt + +## Live preview with auto-reload at http://127.0.0.1:8000 +docs: + mkdocs serve + +## Build static site into site/ (check for errors without deploying) +docs-build: + mkdocs build --strict + +## Manually push to GitHub Pages (same as CI does) +docs-deploy: + mkdocs gh-deploy --force + +## Remove built site output +docs-clean: + rm -rf site/ diff --git a/README.md b/README.md @@ -1,29 +1,43 @@ -# Talos Kubernetes GitOps Repository +# Midnightthoughts GitOps GitOps configuration for Kubernetes on [Talos Linux](https://www.talos.dev/) using [Flux CD](https://fluxcd.io/). ## Stack -- **OS**: Talos Linux -- **GitOps**: Flux CD -- **Secrets**: SOPS + age -- **CNI**: Cilium -- **Updates**: Renovate - -## Structure +| Component | Technology | +|---|---| +| OS | [Talos Linux](https://www.talos.dev/) | +| GitOps | [Flux CD](https://fluxcd.io/) | +| Cloud | [Hetzner Cloud](https://www.hetzner.com/cloud) (CAX nodes) + Hetzner LBs | +| CNI | [Cilium](https://cilium.io/) | +| Ingress | [Envoy Gateway](https://gateway.envoyproxy.io/) | +| Storage | [Longhorn](https://longhorn.io/) (block) + Hetzner Object Storage (S3) | +| Database | [CloudNativePG](https://cloudnative-pg.io/) (PostgreSQL 18) | +| Secrets | [SOPS](https://getsops.io/) + [age](https://age-encryption.org/) | +| Backups | [Velero](https://velero.io/) (4× daily) | +| Monitoring | VictoriaMetrics + Grafana + Loki | +| Auth | [Authentik](https://goauthentik.io/) | +| Updates | [Renovate](https://docs.renovatebot.com/) | +| TLS | [cert-manager](https://cert-manager.io/) + Let's Encrypt | + +## Repository Structure ``` -├── clusters/talos_cluster/ # Flux bootstrap -├── infrastructure_talos/ # Controllers, monitoring -├── apps/ # Applications -└── .github/workflows/ # CI (validation, renovate) +├── clusters/talos_cluster/ # Flux bootstrap & Kustomizations +├── infrastructure_talos/ # Controllers (CNI, storage, cert-manager, monitoring) +│ ├── controllers/ # Longhorn, Velero, CloudNativePG, etc. +│ └── configs/ # Cluster config (CNPG cluster, issuers, secrets) +├── apps/ +│ └── talos_cluster/ # All deployed applications +├── .github/workflows/ # CI: validation, security scans, docs deployment +└── scripts/ # Helper scripts ``` ## Quick Start ```bash -# Bootstrap Talos -talosctl gen config my-cluster https://CONTROL_PLANE_IP:6443 +# Generate Talos config +talosctl gen config cluster-2025 https://CONTROL_PLANE_IP:6443 talosctl apply-config --insecure --nodes CONTROL_PLANE_IP --file controlplane.yaml talosctl bootstrap --nodes CONTROL_PLANE_IP talosctl kubeconfig --nodes CONTROL_PLANE_IP @@ -32,84 +46,103 @@ talosctl kubeconfig --nodes CONTROL_PLANE_IP flux check --pre kubectl apply -k clusters/talos_cluster/flux-system -# Setup SOPS (generate NEW key, never use the one in repo!) +# Setup SOPS (generate a NEW key — never reuse an existing one) age-keygen -o age.key kubectl create secret generic sops-age --namespace=flux-system --from-file=age.agekey=age.key -# Update .sops.yaml with your public key, store age.key securely +# Update .sops.yaml with your public key +# Store age.key somewhere secure (password manager, not in this repo) -# Deploy +# Trigger reconciliation flux reconcile kustomization flux-system --with-source ``` -## Secrets +## Working with Secrets ```bash -# Encrypt -sops --encrypt --encrypted-regex '^(data|stringData)$' secret.yaml > secret.enc.yaml +# Encrypt a new secret +sops -e -i secret.yaml + +# Edit an encrypted secret +sops secret.yaml -# Edit -sops secret.enc.yaml +# View decrypted (don't commit output) +sops -d secret.yaml ``` -**Never commit:** `age.key`, `age.agekey`, decrypted secrets +The `encrypted_regex` in `.sops.yaml` controls which fields are encrypted. All secrets are encrypted with age using a shared cluster key stored in the `sops-age` Kubernetes secret. -## Common Tasks +## Common Operations ```bash -# Deploy changes -git commit -am "update" && git push - -# Force reconcile +# Force reconcile after a push flux reconcile kustomization flux-system --with-source -# Check status +# Check status of all Flux resources flux get all -A -flux logs --level=error -# Rollback -git revert COMMIT && git push +# Show recent Flux errors +flux logs --level=error --all-namespaces + +# Rollback: revert the commit and push +git revert HEAD && git push ``` -## CI/CD +## Backups + +Velero runs 4× daily (00:00, 06:00, 12:00, 18:00 UTC) backing up all cluster resources and Longhorn volumes to Hetzner Object Storage. CloudNativePG WAL archiving provides continuous PostgreSQL backup to a separate S3 bucket. -- **validate.yaml**: Validates manifests, runs security scans (gitleaks, trivy, kubeaudit) -- **renovate.yaml**: Automated dependency updates (daily 2 AM UTC) +```bash +# Check backup status +kubectl get backup.velero.io -n velero --sort-by='.metadata.creationTimestamp' -Run locally: `./scripts/validate.sh` +# Trigger manual backup +velero backup create manual-$(date +%Y%m%d-%H%M) --include-namespaces '*' +``` -## Troubleshooting +## CI/CD + +| Workflow | Trigger | Purpose | +|---|---|---| +| `validate.yaml` | PR / push to main | Manifest validation, security scans (gitleaks, trivy, kubescape) | +| `docs.yml` | Push to main (*.md changes) | Build and deploy MkDocs to GitHub Pages | +| `build-continuwuity.yaml` | Manual | Custom Continuwuity image build | +Run validation locally: ```bash -# Flux -flux check -flux logs --all-namespaces +./scripts/validate.sh +``` -# SOPS -kubectl get secret sops-age -n flux-system +## Documentation -# Apps -kubectl describe pod POD -n NAMESPACE -kubectl logs POD -n NAMESPACE +```bash +# Install doc dependencies (once) +make docs-install -# Talos -talosctl health --nodes NODE_IP -talosctl logs -n NODE_IP -``` +# Preview locally at http://127.0.0.1:8000 +make docs -## Security Notes +# Build to verify (outputs to site/) +make docs-build +``` -**Critical**: The `age.agekey` in this repo is exposed and must be rotated immediately. +Add a `README.md` to any app directory under `apps/talos_cluster/<app>/` and it will automatically appear in the docs navigation. -Remove sensitive files: `age.agekey`, `gerrit_key*`, `*.log`, `audit.txt`, `bak_*` +## Troubleshooting -Apply security policies: +```bash +# Flux +flux check +flux logs --all-namespaces -- Pod Security Standards (restricted mode) -- LimitRanges for resource defaults -- ResourceQuotas for namespace limits -- NetworkPolicies (default-deny) +# App not reconciling +kubectl describe kustomization <name> -n flux-system +kubectl describe helmrelease <name> -n <namespace> -## Resources +# Check pod +kubectl describe pod <pod> -n <namespace> +kubectl logs <pod> -n <namespace> --previous -- [Flux Docs](https://fluxcd.io/docs/) -- [Talos Docs](https://www.talos.dev/docs/) +# Talos node health +talosctl health --nodes <NODE_IP> +talosctl logs -n <NODE_IP> +``` diff --git a/apps/.pages b/apps/.pages @@ -0,0 +1 @@ +title: Applications diff --git a/apps/talos_cluster/.pages b/apps/talos_cluster/.pages @@ -0,0 +1 @@ +title: Talos Cluster diff --git a/apps/talos_cluster/k6-operator/README.md b/apps/talos_cluster/k6-operator/README.md @@ -0,0 +1 @@ +# K6 Operator diff --git a/docs/gen_pages.py b/docs/gen_pages.py @@ -0,0 +1,38 @@ +""" +Auto-discover markdown files from the repo and expose them as virtual MkDocs pages. + +README.md files in any directory are included as the page for that directory. +DISASTER_RECOVERY.md and other root-level .md files are included as top-level pages. +PLAN.md files are excluded (internal planning docs, not committed). +.pages files are copied so mkdocs-awesome-pages-plugin picks up nav labels. + +Run by mkdocs-gen-files during `mkdocs serve` / `mkdocs build`. +""" + +from pathlib import Path + +import mkdocs_gen_files + +ROOT = Path(".") +EXCLUDE_DIRS = {".git", ".github", "docs", "site", "security-policies"} + + +def is_excluded(path: Path) -> bool: + return any(part in EXCLUDE_DIRS for part in path.parts) + + +# Copy markdown files into the virtual docs directory +for md_path in sorted(ROOT.rglob("*.md")): + if is_excluded(md_path): + continue + if md_path.name == "PLAN.md": + continue + with mkdocs_gen_files.open(str(md_path), "w") as f: + f.write(md_path.read_text()) + +# Copy .pages files so mkdocs-awesome-pages-plugin picks up nav labels and ordering +for pages_path in sorted(ROOT.rglob(".pages")): + if is_excluded(pages_path): + continue + with mkdocs_gen_files.open(str(pages_path), "w") as f: + f.write(pages_path.read_text()) diff --git a/infrastructure_talos/.pages b/infrastructure_talos/.pages @@ -0,0 +1 @@ +title: Infrastructure diff --git a/mkdocs.yml b/mkdocs.yml @@ -0,0 +1,50 @@ +site_name: Midnightthoughts GitOps +site_description: Kubernetes GitOps configuration for the Midnightthoughts infrastructure + +docs_dir: docs + +watch: + - apps + - infrastructure_talos + - clusters + - scripts + - README.md + - DISASTER_RECOVERY.md + +theme: + name: material + features: + - navigation.tabs + - navigation.sections + - navigation.indexes + - navigation.top + - search.highlight + - content.code.copy + palette: + - scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to light mode + +plugins: + - search + - gen-files: + scripts: + - docs/gen_pages.py + - awesome-pages: + filename: .pages + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - toc: + permalink: true diff --git a/requirements-docs.txt b/requirements-docs.txt @@ -0,0 +1,3 @@ +mkdocs-material>=9.5,<10 +mkdocs-gen-files>=0.5 +mkdocs-awesome-pages-plugin>=2.9