diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5f4109c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "yaml.schemas": { + "kubernetes://schema/v1@persistentvolumeclaim": "file:///home/henry/HomeLabScripts/k3s/apps/gitLab/manifest/pv-pvc.yaml" + } +} \ No newline at end of file diff --git a/WISSENSBASIS.md b/WISSENSBASIS.md new file mode 100644 index 0000000..03d689d --- /dev/null +++ b/WISSENSBASIS.md @@ -0,0 +1,97 @@ +# HomeLabScripts – Wissensbasis + +## Zweck +Persönliches Home-Lab-Infrastruktur-Repository. Orchestriert eine selbst gehostete Cloud-Umgebung auf K3s (Kubernetes) auf lokaler Hardware. + +## Technologien +- **K3s** – Kubernetes-Distribution +- **Helm** – Paketmanager für K8s +- **NFS** – Netzwerk-Dateisystem (shared storage) +- **Longhorn** – verteilter Block-Storage (2 Replicas) +- **PostgreSQL / MariaDB / Redis** – Datenbanken & Caching +- **Authentik** – OAuth2/OIDC-Authentifizierungsserver + +--- + +## Repo-Struktur +``` +HomeLabScripts/ +├── k3s/ +│ ├── apps/ # App-Manifeste & Helm-Charts +│ ├── install.sh # K3s-Cluster-Installation +│ ├── get_helm.sh # Helm herunterladen +│ ├── installHelm.sh # Helm installieren +│ └── k8sUser/ # Benutzer-/Kubeconfig-Setup +├── nfs/ # NFS-Server- & Client-Skripte +└── mountscript/ # Disk-Partitionierung & Einhängen +``` + +--- + +## Anwendungen + +| App | Namespace | NodePort | Storage | Beschreibung | +|-----|-----------|----------|---------|--------------| +| **Authentik** | authentik | 32222 | PostgreSQL (intern) | OAuth2/OIDC-Provider | +| **Homarr** | homarr | 30757 | Longhorn 5Gi | Homepage-Dashboard | +| **K8s Dashboard** | kubernetes-dashboard | 443 | – | Cluster-Management-UI | +| **Gitea** | gitea | – | NFS 30Gi (Repos) + 10Gi (DB) | Leichter Git-Dienst | +| **GitLab** | gitlab | 80/443/22 | NFS 50Gi (RWX) | Full GitLab mit CI/CD | +| **Nextcloud** | nextcloud | 30180 | NFS (Daten) + Longhorn (DB) | Datei-Hosting | +| **Immich** | photoprism | 3001/2283 | NFS photos | Fotoverwaltung (Google Photos Alternative) | +| **PhotoPrism** | photoprism | – | NFS photos | KI-Fotoverwaltung | +| **iCloudPD** | photoprism | – | NFS /data/originals | Apple-iCloud-Foto-Sync | +| **Longhorn** | longhorn-system | – | – | Storage-Provisioner | + +--- + +## Storage-Strategie +- **Longhorn** → Datenbanken, kleine Konfigurationen (schnell, lokal) +- **NFS** → Medien, Repos, Nextcloud-Daten (groß, geteilt, RWX) +- NFS-Server: `192.168.178.166`, Pfade: `/export/fastData/`, `/export/slowData/` + +--- + +## Netzwerk +- **NodePort** für externen Zugriff +- **ClusterIP** für Pod-to-Pod-Kommunikation (DBs) +- **Multus** bei Immich (separates IoT-Netz: `192.168.1.192/24`) +- Domain: `henryathome.home64.de` + +--- + +## Datenbank-Zuordnung +| App | DB-Typ | User | Hinweis | +|-----|--------|------|---------| +| Authentik | PostgreSQL | – | intern | +| Nextcloud | MariaDB 10.8 | nextcloud | PW: nextcloud | +| GitLab | MariaDB | – | NFS-Backend | +| Gitea | PostgreSQL | – | NFS-Backend | +| Immich | PostgreSQL 14 (pgvecto-rs) | immich | PW: password | +| PhotoPrism | MariaDB | photoprism | PW: photoprism | + +--- + +## Wichtige Skripte + +| Skript | Zweck | +|--------|-------| +| `k3s/install.sh` | K3s installieren | +| `k3s/installHelm.sh` | Helm 3 installieren | +| `k3s/k8sUser/addUser.sh` | ServiceAccount + ClusterRoleBinding + Kubeconfig erstellen | +| `k3s/apps/dashboard/getToken.sh` | Admin-Token für K8s Dashboard | +| `k3s/apps/photo/icloudpd/base64pw.sh` | iCloud-Passwort base64-kodieren | +| `k3s/apps/Nextcloud/helm/cleanRestart.sh` | Nextcloud sauber neu starten | +| `nfs/server.sh` | NFS-Server konfigurieren | +| `nfs/client.sh` / `nfsClient2.sh` | NFS-Client einrichten & in fstab eintragen | +| `mountscript/mount-plus.sh` | Festplatte partitionieren, formatieren, einhängen | + +--- + +## Muster & Konventionen +- Secrets: `*-secret.yaml` je App, base64-kodiert +- Init-Container: Warten auf DB-Bereitschaft (Nextcloud, Immich) +- `imagePullPolicy: IfNotPresent` (kein automatisches Re-Pull) +- `nodeSelector: knode0` bei Nextcloud (Kernel 6.1 für NFS) +- fsGroup für NFS-Berechtigungen (z. B. `33` für www-data) +- GitLab-Runner-Token: `glrt-3nNma_nEvL1Bq2zc8m5Zu286MQpwOjIKdDozCnU6MTAQ` diff --git a/k3s/apps/gitLab/manifest/gitlab-deployment.yaml b/k3s/apps/gitLab/manifest/gitlab-deployment.yaml index 2729cc6..1bff2e2 100644 --- a/k3s/apps/gitLab/manifest/gitlab-deployment.yaml +++ b/k3s/apps/gitLab/manifest/gitlab-deployment.yaml @@ -99,7 +99,7 @@ spec: volumes: - name: gitlab-data # lokal (postgresql, redis, etc.) persistentVolumeClaim: - claimName: gitlab-data-pvc + claimName: gitlab-data-longhorn-pvc - name: gitlab-git # NFS (Git-Repositories) persistentVolumeClaim: @@ -107,7 +107,7 @@ spec: - name: gitlab-config # lokal persistentVolumeClaim: - claimName: gitlab-config-pvc + claimName: gitlab-config-longhorn-pvc - name: gitlab-logs # ephemeral emptyDir: {} diff --git a/k3s/apps/gitLab/manifest/migrate-to-longhorn.yaml b/k3s/apps/gitLab/manifest/migrate-to-longhorn.yaml new file mode 100644 index 0000000..57f7443 --- /dev/null +++ b/k3s/apps/gitLab/manifest/migrate-to-longhorn.yaml @@ -0,0 +1,48 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: gitlab-migrate-to-longhorn + namespace: gitlab +spec: + backoffLimit: 4 + template: + metadata: + name: gitlab-migrate-to-longhorn + spec: + restartPolicy: Never + containers: + - name: migrate + image: alpine:3.18 + command: + - sh + - -c + - | + set -euo pipefail + apk add --no-cache rsync + echo "Starting migration: /var/opt/gitlab (data)" + rsync -aHAX --numeric-ids --delete /var/opt_gitlab_old/ /var/opt_gitlab_new/ + echo "Finished data copy. Starting config copy: /etc/gitlab" + rsync -aHAX --numeric-ids --delete /etc_gitlab_old/ /etc_gitlab_new/ + echo "Migration complete" + volumeMounts: + - name: old-data + mountPath: /var/opt_gitlab_old + - name: new-data + mountPath: /var/opt_gitlab_new + - name: old-config + mountPath: /etc_gitlab_old + - name: new-config + mountPath: /etc_gitlab_new + volumes: + - name: old-data + persistentVolumeClaim: + claimName: gitlab-data-pvc + - name: new-data + persistentVolumeClaim: + claimName: gitlab-data-longhorn-pvc + - name: old-config + persistentVolumeClaim: + claimName: gitlab-config-pvc + - name: new-config + persistentVolumeClaim: + claimName: gitlab-config-longhorn-pvc \ No newline at end of file diff --git a/k3s/apps/gitLab/manifest/pv-pvc.yaml b/k3s/apps/gitLab/manifest/pv-pvc.yaml index 67d25ab..283676c 100644 --- a/k3s/apps/gitLab/manifest/pv-pvc.yaml +++ b/k3s/apps/gitLab/manifest/pv-pvc.yaml @@ -96,3 +96,32 @@ spec: requests: storage: 1Gi volumeName: gitlab-config-pv + +--- +# ─── Neue Longhorn-PVCs zum Migrieren der Daten (dynamisch provisioniert) ─ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: gitlab-data-longhorn-pvc + namespace: gitlab +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 20Gi + +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: gitlab-config-longhorn-pvc + namespace: gitlab +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 1Gi diff --git a/k3s/apps/gitLab/migrate-to-longhorn.sh b/k3s/apps/gitLab/migrate-to-longhorn.sh new file mode 100755 index 0000000..5d99458 --- /dev/null +++ b/k3s/apps/gitLab/migrate-to-longhorn.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +set -euo pipefail + +NAMESPACE="gitlab" +DEPLOYMENT_NAME="gitlab" +JOB_NAME="gitlab-migrate-to-longhorn" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +MANIFEST_DIR="${SCRIPT_DIR}/manifest" +PV_PVC_MANIFEST="${MANIFEST_DIR}/pv-pvc.yaml" +MIGRATION_JOB_MANIFEST="${MANIFEST_DIR}/migrate-to-longhorn.yaml" + +SOURCE_DATA_PVC="gitlab-data-pvc" +SOURCE_CONFIG_PVC="gitlab-config-pvc" +TARGET_DATA_PVC="gitlab-data-longhorn-pvc" +TARGET_CONFIG_PVC="gitlab-config-longhorn-pvc" + +BIND_TIMEOUT_SECONDS=900 +JOB_TIMEOUT_SECONDS=7200 +POD_STOP_TIMEOUT_SECONDS=300 + +ORIGINAL_REPLICAS="1" +SCALED_DOWN="false" +DRY_RUN="false" +AUTO_CONFIRM="false" + +info() { + echo "[INFO] $*" +} + +warn() { + echo "[WARN] $*" >&2 +} + +err() { + echo "[ERROR] $*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: migrate-to-longhorn.sh [--dry-run] [--yes] [--help] + +Options: + --dry-run Show planned actions without changing cluster state. + --yes Skip interactive confirmation before scaling down GitLab. + --help Show this help. +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN="true" + ;; + --yes) + AUTO_CONFIRM="true" + ;; + --help|-h) + usage + exit 0 + ;; + *) + err "Unknown argument: $1" + ;; + esac + shift + done +} + +restore_deployment_on_error() { + if [[ "${SCALED_DOWN}" == "true" ]]; then + warn "Migration failed. Restoring deployment replicas to ${ORIGINAL_REPLICAS}." + kubectl -n "${NAMESPACE}" scale deployment "${DEPLOYMENT_NAME}" --replicas="${ORIGINAL_REPLICAS}" >/dev/null || true + fi +} + +on_exit() { + local exit_code=$? + if [[ ${exit_code} -ne 0 ]]; then + restore_deployment_on_error + warn "Script failed with exit code ${exit_code}." + fi +} +trap on_exit EXIT + +require_tool() { + local tool="$1" + command -v "${tool}" >/dev/null 2>&1 || err "Required tool not found: ${tool}" +} + +require_file() { + local path="$1" + [[ -f "${path}" ]] || err "Required file not found: ${path}" +} + +wait_for_pvc_bound() { + local pvc_name="$1" + local timeout="$2" + local elapsed=0 + local interval=5 + + info "Waiting for PVC ${pvc_name} to become Bound..." + while true; do + local phase + phase="$(kubectl -n "${NAMESPACE}" get pvc "${pvc_name}" -o jsonpath='{.status.phase}' 2>/dev/null || true)" + + if [[ "${phase}" == "Bound" ]]; then + info "PVC ${pvc_name} is Bound." + return 0 + fi + + if (( elapsed >= timeout )); then + err "Timeout while waiting for PVC ${pvc_name} to bind." + fi + + sleep "${interval}" + elapsed=$((elapsed + interval)) + done +} + +wait_for_gitlab_pods_stopped() { + local timeout="$1" + local elapsed=0 + local interval=5 + + info "Waiting for pods with label app=gitlab to stop..." + while true; do + local count + count="$(kubectl -n "${NAMESPACE}" get pods -l app=gitlab --no-headers 2>/dev/null | wc -l | tr -d ' ')" + + if [[ "${count}" == "0" ]]; then + info "All GitLab pods are stopped." + return 0 + fi + + if (( elapsed >= timeout )); then + err "Timeout while waiting for GitLab pods to stop." + fi + + sleep "${interval}" + elapsed=$((elapsed + interval)) + done +} + +wait_for_job_complete() { + local timeout="$1" + + info "Waiting for migration job ${JOB_NAME} to complete..." + if kubectl -n "${NAMESPACE}" wait --for=condition=complete "job/${JOB_NAME}" --timeout="${timeout}s" >/dev/null; then + info "Migration job completed successfully." + return 0 + fi + + warn "Migration job did not complete in time or failed." + kubectl -n "${NAMESPACE}" describe job "${JOB_NAME}" || true + kubectl -n "${NAMESPACE}" logs "job/${JOB_NAME}" --tail=-1 || true + err "Migration job failed." +} + +validate_prerequisites() { + require_tool kubectl + require_file "${PV_PVC_MANIFEST}" + require_file "${MIGRATION_JOB_MANIFEST}" + + kubectl get namespace "${NAMESPACE}" >/dev/null 2>&1 || err "Namespace does not exist: ${NAMESPACE}" + kubectl -n "${NAMESPACE}" get deployment "${DEPLOYMENT_NAME}" >/dev/null 2>&1 || err "Deployment not found: ${DEPLOYMENT_NAME}" + + kubectl -n "${NAMESPACE}" get pvc "${SOURCE_DATA_PVC}" >/dev/null 2>&1 || err "Source PVC not found: ${SOURCE_DATA_PVC}" + kubectl -n "${NAMESPACE}" get pvc "${SOURCE_CONFIG_PVC}" >/dev/null 2>&1 || err "Source PVC not found: ${SOURCE_CONFIG_PVC}" +} + +confirm_scale_down() { + if [[ "${AUTO_CONFIRM}" == "true" ]]; then + return 0 + fi + + echo + warn "GitLab deployment ${DEPLOYMENT_NAME} in namespace ${NAMESPACE} will be scaled to 0 during migration." + read -r -p "Type MIGRATE to continue: " confirmation + if [[ "${confirmation}" != "MIGRATE" ]]; then + err "Confirmation failed. Aborted by user." + fi +} + +print_dry_run_plan() { + info "Dry-run mode active. No cluster changes will be made." + info "Planned steps:" + info "1) kubectl apply -f ${PV_PVC_MANIFEST}" + info "2) Wait for PVCs ${TARGET_DATA_PVC} and ${TARGET_CONFIG_PVC} to be Bound" + info "3) Scale deployment ${DEPLOYMENT_NAME} to 0" + info "4) Recreate and run job ${JOB_NAME} from ${MIGRATION_JOB_MANIFEST}" + info "5) Wait for job completion and verify target PVCs" + info "6) Scale deployment ${DEPLOYMENT_NAME} back to ${ORIGINAL_REPLICAS}" + info "No PVC switch in deployment is performed by this script." +} + +run_migration() { + ORIGINAL_REPLICAS="$(kubectl -n "${NAMESPACE}" get deployment "${DEPLOYMENT_NAME}" -o jsonpath='{.spec.replicas}')" + ORIGINAL_REPLICAS="${ORIGINAL_REPLICAS:-1}" + + if [[ "${DRY_RUN}" == "true" ]]; then + print_dry_run_plan + return 0 + fi + + confirm_scale_down + + info "Applying PVC manifest to ensure Longhorn target PVCs exist." + kubectl apply -f "${PV_PVC_MANIFEST}" >/dev/null + + wait_for_pvc_bound "${TARGET_DATA_PVC}" "${BIND_TIMEOUT_SECONDS}" + wait_for_pvc_bound "${TARGET_CONFIG_PVC}" "${BIND_TIMEOUT_SECONDS}" + + info "Scaling deployment ${DEPLOYMENT_NAME} down to 0 for consistent copy." + kubectl -n "${NAMESPACE}" scale deployment "${DEPLOYMENT_NAME}" --replicas=0 >/dev/null + SCALED_DOWN="true" + wait_for_gitlab_pods_stopped "${POD_STOP_TIMEOUT_SECONDS}" + + info "Recreating migration job ${JOB_NAME}." + kubectl -n "${NAMESPACE}" delete job "${JOB_NAME}" --ignore-not-found >/dev/null + kubectl apply -f "${MIGRATION_JOB_MANIFEST}" >/dev/null + + info "Streaming migration job logs." + kubectl -n "${NAMESPACE}" logs -f "job/${JOB_NAME}" || true + + wait_for_job_complete "${JOB_TIMEOUT_SECONDS}" + + info "Quick verification: target PVCs are still Bound." + wait_for_pvc_bound "${TARGET_DATA_PVC}" 30 + wait_for_pvc_bound "${TARGET_CONFIG_PVC}" 30 + + info "Scaling deployment ${DEPLOYMENT_NAME} back to ${ORIGINAL_REPLICAS}." + kubectl -n "${NAMESPACE}" scale deployment "${DEPLOYMENT_NAME}" --replicas="${ORIGINAL_REPLICAS}" >/dev/null + SCALED_DOWN="false" + + info "Migration finished successfully." + info "No deployment PVC switch was done by this script." + info "NFS PVC/PV (gitlab-git-pvc / gitlab-git-pv) were not changed." + info "If you want to switch GitLab to Longhorn later, apply the updated deployment manifest manually." +} + +main() { + parse_args "$@" + info "Starting GitLab local PVC to Longhorn migration." + if [[ "${DRY_RUN}" == "true" ]]; then + info "Running in dry-run mode." + fi + validate_prerequisites + run_migration +} + +main "$@" diff --git a/k3s/apps/gitea/gitea-runner.yaml b/k3s/apps/gitea/gitea-runner.yaml new file mode 100644 index 0000000..0b9e715 --- /dev/null +++ b/k3s/apps/gitea/gitea-runner.yaml @@ -0,0 +1,82 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: gitea-runner-data + namespace: gitea +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 5Gi + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gitea-runner + namespace: gitea +spec: + replicas: 1 + selector: + matchLabels: + app: gitea-runner + template: + metadata: + labels: + app: gitea-runner + spec: + containers: + - name: runner + image: gitea/act_runner:latest + imagePullPolicy: IfNotPresent + env: + - name: GITEA_INSTANCE_URL + value: "http://gitea.gitea.svc.cluster.local" + - name: GITEA_RUNNER_NAME + value: "k3s-runner-1" + - name: GITEA_RUNNER_LABELS + value: "linux-x64:host,ubuntu-latest:docker://node:20-bookworm,alpine:docker://alpine:3.20" + - name: GITEA_RUNNER_REGISTRATION_TOKEN + valueFrom: + secretKeyRef: + name: gitea-runner-secret + key: GITEA_RUNNER_REGISTRATION_TOKEN + - name: DOCKER_HOST + value: "tcp://localhost:2375" + command: + - /bin/sh + - -c + args: + - | + set -e + if [ ! -f /data/.runner ]; then + act_runner register \ + --no-interactive \ + --instance "${GITEA_INSTANCE_URL}" \ + --token "${GITEA_RUNNER_REGISTRATION_TOKEN}" \ + --name "${GITEA_RUNNER_NAME}" \ + --labels "${GITEA_RUNNER_LABELS}" + fi + exec act_runner daemon + volumeMounts: + - name: runner-data + mountPath: /data + - name: dind + image: docker:27-dind + imagePullPolicy: IfNotPresent + securityContext: + privileged: true + env: + - name: DOCKER_TLS_CERTDIR + value: "" + volumeMounts: + - name: docker-lib + mountPath: /var/lib/docker + volumes: + - name: runner-data + persistentVolumeClaim: + claimName: gitea-runner-data + - name: docker-lib + emptyDir: {} diff --git a/k3s/apps/gitea/gitea.yaml b/k3s/apps/gitea/gitea.yaml index e05402d..1b04db4 100644 --- a/k3s/apps/gitea/gitea.yaml +++ b/k3s/apps/gitea/gitea.yaml @@ -65,6 +65,21 @@ spec: requests: storage: 10Gi +# PVC: PostgreSQL (Longhorn target) +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: postgres-longhorn-pvc + namespace: gitea +spec: + storageClassName: longhorn + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + # Deployment: PostgreSQL --- apiVersion: apps/v1 @@ -74,6 +89,8 @@ metadata: namespace: gitea spec: replicas: 1 + strategy: + type: Recreate selector: matchLabels: app: postgres @@ -97,14 +114,12 @@ spec: volumeMounts: - name: postgres-storage mountPath: /var/lib/postgresql/data - securityContext: - runAsUser: 1001 - runAsGroup: 1000 -# fsGroup: 1000 + securityContext: + fsGroup: 999 volumes: - name: postgres-storage persistentVolumeClaim: - claimName: postgres-pvc + claimName: postgres-longhorn-pvc # Service: PostgreSQL --- @@ -172,10 +187,8 @@ spec: volumeMounts: - name: gitea-storage mountPath: /data - securityContext: -# runAsUser: 1001 -# runAsGroup: 1000 -# fsGroup: 1000 + securityContext: + fsGroup: 1000 volumes: - name: gitea-storage persistentVolumeClaim: diff --git a/k3s/apps/gitea/migrate-postgres-to-longhorn.sh b/k3s/apps/gitea/migrate-postgres-to-longhorn.sh new file mode 100644 index 0000000..72d93ad --- /dev/null +++ b/k3s/apps/gitea/migrate-postgres-to-longhorn.sh @@ -0,0 +1,335 @@ +#!/usr/bin/env bash +set -euo pipefail + +NAMESPACE="gitea" +GITEA_DEPLOYMENT="gitea" +POSTGRES_DEPLOYMENT="postgres" +POSTGRES_VOLUME_NAME="postgres-storage" +JOB_NAME="gitea-postgres-migrate-to-longhorn" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +TARGET_PVC_MANIFEST="${SCRIPT_DIR}/postgres-longhorn-pvc.yaml" +JOB_MANIFEST="${SCRIPT_DIR}/migrate-postgres-to-longhorn.yaml" + +SOURCE_PVC="postgres-pvc" +TARGET_PVC="postgres-longhorn-pvc" + +PVC_BIND_TIMEOUT_SECONDS=900 +POD_STOP_TIMEOUT_SECONDS=300 +POSTGRES_READY_TIMEOUT_SECONDS=300 +JOB_TIMEOUT_SECONDS=3600 + +ORIGINAL_GITEA_REPLICAS="1" +ORIGINAL_POSTGRES_REPLICAS="1" +SOURCE_NODE_NAME="" +SCALED_DOWN_GITEA="false" +SCALED_DOWN_POSTGRES="false" +POSTGRES_CLAIM_SWITCHED="false" +DRY_RUN="false" +AUTO_CONFIRM="false" + +info() { + echo "[INFO] $*" +} + +warn() { + echo "[WARN] $*" >&2 +} + +err() { + echo "[ERROR] $*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: migrate-postgres-to-longhorn.sh [--dry-run] [--yes] [--help] + +Options: + --dry-run Show planned actions without changing cluster state. + --yes Skip interactive confirmation before scaling down Gitea. + --help Show this help. +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN="true" + ;; + --yes) + AUTO_CONFIRM="true" + ;; + --help|-h) + usage + exit 0 + ;; + *) + err "Unknown argument: $1" + ;; + esac + shift + done +} + +require_tool() { + local tool="$1" + command -v "${tool}" >/dev/null 2>&1 || err "Required tool not found: ${tool}" +} + +require_file() { + local path="$1" + [[ -f "${path}" ]] || err "Required file not found: ${path}" +} + +wait_for_pvc_bound() { + local pvc_name="$1" + local timeout="$2" + local elapsed=0 + local interval=5 + + info "Waiting for PVC ${pvc_name} to become Bound..." + while true; do + local phase + phase="$(kubectl -n "${NAMESPACE}" get pvc "${pvc_name}" -o jsonpath='{.status.phase}' 2>/dev/null || true)" + + if [[ "${phase}" == "Bound" ]]; then + info "PVC ${pvc_name} is Bound." + return 0 + fi + + if (( elapsed >= timeout )); then + err "Timeout while waiting for PVC ${pvc_name} to bind." + fi + + sleep "${interval}" + elapsed=$((elapsed + interval)) + done +} + +wait_for_no_pods() { + local selector="$1" + local timeout="$2" + local elapsed=0 + local interval=5 + + info "Waiting for pods with selector ${selector} to stop..." + while true; do + local count + count="$(kubectl -n "${NAMESPACE}" get pods -l "${selector}" --no-headers 2>/dev/null | awk '$3 != "Completed" && $3 != "Succeeded" {count++} END {print count+0}')" + + if [[ "${count}" == "0" ]]; then + info "No pods left for selector ${selector}." + return 0 + fi + + if (( elapsed >= timeout )); then + err "Timeout while waiting for pods with selector ${selector} to stop." + fi + + sleep "${interval}" + elapsed=$((elapsed + interval)) + done +} + +discover_source_node() { + SOURCE_NODE_NAME="$(kubectl -n "${NAMESPACE}" get pod -l app=postgres -o jsonpath='{.items[0].spec.nodeName}' 2>/dev/null || true)" + [[ -n "${SOURCE_NODE_NAME}" ]] || err "Could not determine the current PostgreSQL node before shutdown." + info "Using source node ${SOURCE_NODE_NAME} for the migration job." +} + +apply_migration_job() { + sed "/restartPolicy: Never/a\ nodeName: ${SOURCE_NODE_NAME}" "${JOB_MANIFEST}" | kubectl apply -f - >/dev/null +} + +wait_for_deployment_ready() { + local deployment_name="$1" + local timeout="$2" + + info "Waiting for deployment ${deployment_name} rollout to finish..." + kubectl -n "${NAMESPACE}" rollout status "deployment/${deployment_name}" --timeout="${timeout}s" >/dev/null +} + +wait_for_job_complete() { + local timeout="$1" + + info "Waiting for migration job ${JOB_NAME} to complete..." + if kubectl -n "${NAMESPACE}" wait --for=condition=complete "job/${JOB_NAME}" --timeout="${timeout}s" >/dev/null; then + info "Migration job completed successfully." + return 0 + fi + + warn "Migration job did not complete in time or failed." + kubectl -n "${NAMESPACE}" describe job "${JOB_NAME}" || true + kubectl -n "${NAMESPACE}" logs "job/${JOB_NAME}" --tail=-1 || true + err "Migration job failed." +} + +verify_target_data() { + local verification + verification="$(kubectl -n "${NAMESPACE}" get job "${JOB_NAME}" -o jsonpath='{.status.succeeded}' 2>/dev/null || true)" + [[ "${verification}" == "1" ]] || err "Migration job did not report success." + + info "Verifying target PVC content markers before starting Gitea." + kubectl -n "${NAMESPACE}" run gitea-postgres-verify \ + --rm --restart=Never --image=alpine:3.20 \ + --overrides='{ + "apiVersion":"v1", + "spec":{ + "containers":[{ + "name":"verify", + "image":"alpine:3.20", + "command":["sh","-c","set -e; test -f /target/PG_VERSION; test -d /target/base; find /target -mindepth 1 | wc -l; ls -la /target | head -n 20"], + "volumeMounts":[{ + "name":"target", + "mountPath":"/target" + }] + }], + "volumes":[{ + "name":"target", + "persistentVolumeClaim":{ + "claimName":"postgres-longhorn-pvc" + } + }] + } + }' \ + --attach=true >/dev/null +} + +patch_postgres_claim() { + local claim_name="$1" + + kubectl -n "${NAMESPACE}" patch deployment "${POSTGRES_DEPLOYMENT}" \ + --type=strategic \ + -p "{\"spec\":{\"template\":{\"spec\":{\"volumes\":[{\"name\":\"${POSTGRES_VOLUME_NAME}\",\"persistentVolumeClaim\":{\"claimName\":\"${claim_name}\"}}]}}}}" >/dev/null +} + +restore_on_error() { + if [[ "${POSTGRES_CLAIM_SWITCHED}" == "true" ]]; then + warn "Migration failed after deployment patch. Switching PostgreSQL back to ${SOURCE_PVC}." + patch_postgres_claim "${SOURCE_PVC}" || true + POSTGRES_CLAIM_SWITCHED="false" + fi + + if [[ "${SCALED_DOWN_POSTGRES}" == "true" ]]; then + warn "Restoring PostgreSQL replicas to ${ORIGINAL_POSTGRES_REPLICAS}." + kubectl -n "${NAMESPACE}" scale deployment "${POSTGRES_DEPLOYMENT}" --replicas="${ORIGINAL_POSTGRES_REPLICAS}" >/dev/null || true + fi + + if [[ "${SCALED_DOWN_GITEA}" == "true" ]]; then + warn "Restoring Gitea replicas to ${ORIGINAL_GITEA_REPLICAS}." + kubectl -n "${NAMESPACE}" scale deployment "${GITEA_DEPLOYMENT}" --replicas="${ORIGINAL_GITEA_REPLICAS}" >/dev/null || true + fi +} + +on_exit() { + local exit_code=$? + if [[ ${exit_code} -ne 0 ]]; then + restore_on_error + warn "Script failed with exit code ${exit_code}." + fi +} +trap on_exit EXIT + +validate_prerequisites() { + require_tool kubectl + require_file "${TARGET_PVC_MANIFEST}" + require_file "${JOB_MANIFEST}" + + kubectl get namespace "${NAMESPACE}" >/dev/null 2>&1 || err "Namespace does not exist: ${NAMESPACE}" + kubectl -n "${NAMESPACE}" get deployment "${GITEA_DEPLOYMENT}" >/dev/null 2>&1 || err "Deployment not found: ${GITEA_DEPLOYMENT}" + kubectl -n "${NAMESPACE}" get deployment "${POSTGRES_DEPLOYMENT}" >/dev/null 2>&1 || err "Deployment not found: ${POSTGRES_DEPLOYMENT}" + kubectl -n "${NAMESPACE}" get pvc "${SOURCE_PVC}" >/dev/null 2>&1 || err "Source PVC not found: ${SOURCE_PVC}" +} + +confirm_scale_down() { + if [[ "${AUTO_CONFIRM}" == "true" ]]; then + return 0 + fi + + echo + warn "Gitea and PostgreSQL in namespace ${NAMESPACE} will be stopped for the migration." + read -r -p "Type MIGRATE to continue: " confirmation + if [[ "${confirmation}" != "MIGRATE" ]]; then + err "Confirmation failed. Aborted by user." + fi +} + +print_dry_run_plan() { + info "Dry-run mode active. No cluster changes will be made." + info "Planned steps:" + info "1) Apply ${TARGET_PVC_MANIFEST}" + info "2) Wait for PVC ${TARGET_PVC} to be Bound" + info "3) Scale deployments ${GITEA_DEPLOYMENT} and ${POSTGRES_DEPLOYMENT} to 0" + info "4) Run job ${JOB_NAME} from ${JOB_MANIFEST}" + info "5) Verify PG_VERSION and base/ exist on ${TARGET_PVC}" + info "6) Patch ${POSTGRES_DEPLOYMENT} to use ${TARGET_PVC}" + info "7) Start PostgreSQL and wait until ready" + info "8) Start Gitea after PostgreSQL is ready" +} + +run_migration() { + ORIGINAL_GITEA_REPLICAS="$(kubectl -n "${NAMESPACE}" get deployment "${GITEA_DEPLOYMENT}" -o jsonpath='{.spec.replicas}')" + ORIGINAL_POSTGRES_REPLICAS="$(kubectl -n "${NAMESPACE}" get deployment "${POSTGRES_DEPLOYMENT}" -o jsonpath='{.spec.replicas}')" + ORIGINAL_GITEA_REPLICAS="${ORIGINAL_GITEA_REPLICAS:-1}" + ORIGINAL_POSTGRES_REPLICAS="${ORIGINAL_POSTGRES_REPLICAS:-1}" + discover_source_node + + if [[ "${DRY_RUN}" == "true" ]]; then + print_dry_run_plan + return 0 + fi + + confirm_scale_down + + info "Applying Longhorn target PVC manifest." + kubectl apply -f "${TARGET_PVC_MANIFEST}" >/dev/null + wait_for_pvc_bound "${TARGET_PVC}" "${PVC_BIND_TIMEOUT_SECONDS}" + + info "Scaling Gitea down first to stop writes." + kubectl -n "${NAMESPACE}" scale deployment "${GITEA_DEPLOYMENT}" --replicas=0 >/dev/null + SCALED_DOWN_GITEA="true" + wait_for_no_pods "app=gitea" "${POD_STOP_TIMEOUT_SECONDS}" + + info "Scaling PostgreSQL down for a consistent filesystem copy." + kubectl -n "${NAMESPACE}" scale deployment "${POSTGRES_DEPLOYMENT}" --replicas=0 >/dev/null + SCALED_DOWN_POSTGRES="true" + wait_for_no_pods "app=postgres" "${POD_STOP_TIMEOUT_SECONDS}" + + info "Recreating migration job ${JOB_NAME}." + kubectl -n "${NAMESPACE}" delete job "${JOB_NAME}" --ignore-not-found >/dev/null + apply_migration_job + + info "Streaming migration job logs." + kubectl -n "${NAMESPACE}" logs -f "job/${JOB_NAME}" || true + + wait_for_job_complete "${JOB_TIMEOUT_SECONDS}" + verify_target_data + + info "Switching PostgreSQL deployment to Longhorn PVC ${TARGET_PVC}." + patch_postgres_claim "${TARGET_PVC}" + POSTGRES_CLAIM_SWITCHED="true" + + info "Starting PostgreSQL on Longhorn." + kubectl -n "${NAMESPACE}" scale deployment "${POSTGRES_DEPLOYMENT}" --replicas="${ORIGINAL_POSTGRES_REPLICAS}" >/dev/null + SCALED_DOWN_POSTGRES="false" + wait_for_deployment_ready "${POSTGRES_DEPLOYMENT}" "${POSTGRES_READY_TIMEOUT_SECONDS}" + + info "Starting Gitea after PostgreSQL verification succeeded." + kubectl -n "${NAMESPACE}" scale deployment "${GITEA_DEPLOYMENT}" --replicas="${ORIGINAL_GITEA_REPLICAS}" >/dev/null + SCALED_DOWN_GITEA="false" + wait_for_deployment_ready "${GITEA_DEPLOYMENT}" "${POSTGRES_READY_TIMEOUT_SECONDS}" + + info "Migration finished successfully." + info "Source PVC ${SOURCE_PVC} remains untouched as rollback source." +} + +main() { + parse_args "$@" + info "Starting Gitea PostgreSQL migration from NFS to Longhorn." + validate_prerequisites + run_migration +} + +main "$@" \ No newline at end of file diff --git a/k3s/apps/gitea/migrate-postgres-to-longhorn.yaml b/k3s/apps/gitea/migrate-postgres-to-longhorn.yaml new file mode 100644 index 0000000..09e7e6d --- /dev/null +++ b/k3s/apps/gitea/migrate-postgres-to-longhorn.yaml @@ -0,0 +1,50 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: gitea-postgres-migrate-to-longhorn + namespace: gitea +spec: + backoffLimit: 2 + template: + metadata: + name: gitea-postgres-migrate-to-longhorn + spec: + restartPolicy: Never + containers: + - name: migrate + image: alpine:3.20 + command: + - sh + - -c + - | + set -euo pipefail + apk add --no-cache rsync findutils coreutils + + src_count="$(find /source -mindepth 1 | wc -l | tr -d ' ')" + echo "Source entries before copy: ${src_count}" + + rsync -aHAX --numeric-ids --delete /source/ /target/ + + target_count="$(find /target -mindepth 1 | wc -l | tr -d ' ')" + echo "Target entries after copy: ${target_count}" + + test -f /target/PG_VERSION + test -d /target/base + test "${target_count}" -gt 0 + + echo "Top-level target contents:" + ls -la /target + echo "Migration verification successful" + volumeMounts: + - name: source + mountPath: /source + readOnly: true + - name: target + mountPath: /target + volumes: + - name: source + persistentVolumeClaim: + claimName: postgres-pvc + - name: target + persistentVolumeClaim: + claimName: postgres-longhorn-pvc \ No newline at end of file diff --git a/k3s/apps/gitea/postgres-longhorn-pvc.yaml b/k3s/apps/gitea/postgres-longhorn-pvc.yaml new file mode 100644 index 0000000..4d3573c --- /dev/null +++ b/k3s/apps/gitea/postgres-longhorn-pvc.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: postgres-longhorn-pvc + namespace: gitea +spec: + storageClassName: longhorn + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi \ No newline at end of file diff --git a/k3s/apps/homarr/homarr-deployment.yaml b/k3s/apps/homarr/homarr-deployment.yaml new file mode 100644 index 0000000..0ee1c97 --- /dev/null +++ b/k3s/apps/homarr/homarr-deployment.yaml @@ -0,0 +1,41 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: homarr + namespace: homarr + labels: + app: homarr +spec: + replicas: 1 + selector: + matchLabels: + app: homarr + template: + metadata: + labels: + app: homarr + spec: + securityContext: + fsGroup: 1000 + containers: + - name: homarr + image: ghcr.io/homarr-labs/homarr:latest + imagePullPolicy: Always + env: + - name: SECRET_ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: homarr-secret + key: SECRET_ENCRYPTION_KEY + ports: + - containerPort: 7575 + protocol: TCP + volumeMounts: + - name: homarr-data + mountPath: /appdata + - name: homarr-data + mountPath: /app/data + volumes: + - name: homarr-data + persistentVolumeClaim: + claimName: homarr-pvc diff --git a/k3s/apps/homarr/homarr-pvc.yaml b/k3s/apps/homarr/homarr-pvc.yaml new file mode 100644 index 0000000..6fb61a0 --- /dev/null +++ b/k3s/apps/homarr/homarr-pvc.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: homarr-pvc + namespace: homarr +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 5Gi diff --git a/k3s/apps/homarr/homarr-secret.yaml b/k3s/apps/homarr/homarr-secret.yaml new file mode 100644 index 0000000..c9fe5c3 --- /dev/null +++ b/k3s/apps/homarr/homarr-secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: homarr-secret + namespace: homarr +type: Opaque +stringData: + SECRET_ENCRYPTION_KEY: "0fddc6753cb94b4a1dc38f26c52c4d4dbce019237457ede59893fb1a74017512" diff --git a/k3s/apps/homarr/homarr-service.yaml b/k3s/apps/homarr/homarr-service.yaml new file mode 100644 index 0000000..eca867b --- /dev/null +++ b/k3s/apps/homarr/homarr-service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: homarr + namespace: homarr +spec: + type: NodePort + selector: + app: homarr + ports: + - port: 7575 + targetPort: 7575 + protocol: TCP + nodePort: 30757 diff --git a/k3s/apps/homarr/namespace.yaml b/k3s/apps/homarr/namespace.yaml new file mode 100644 index 0000000..2db5ebc --- /dev/null +++ b/k3s/apps/homarr/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: homarr diff --git a/k3s/apps/speedtest-tracker/namespace.yaml b/k3s/apps/speedtest-tracker/namespace.yaml new file mode 100644 index 0000000..3ab3bca --- /dev/null +++ b/k3s/apps/speedtest-tracker/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: speedtest-tracker diff --git a/k3s/apps/speedtest-tracker/speedtest-tracker-deployment.yaml b/k3s/apps/speedtest-tracker/speedtest-tracker-deployment.yaml new file mode 100644 index 0000000..aebf14a --- /dev/null +++ b/k3s/apps/speedtest-tracker/speedtest-tracker-deployment.yaml @@ -0,0 +1,62 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: speedtest-tracker + namespace: speedtest-tracker + labels: + app.kubernetes.io/name: speedtest-tracker +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: speedtest-tracker + template: + metadata: + labels: + app.kubernetes.io/name: speedtest-tracker + spec: + dnsPolicy: None + dnsConfig: + nameservers: + - 10.152.183.10 + - 8.8.8.8 + searches: + - speedtest-tracker.svc.cluster.local + options: + - name: ndots + value: "5" + containers: + - name: speedtest-tracker + image: lscr.io/linuxserver/speedtest-tracker:latest + env: + - name: PUID + value: "1000" + - name: PGID + value: "1000" + - name: APP_KEY + valueFrom: + secretKeyRef: + name: speedtest-tracker-secret + key: APP_KEY + - name: DISPLAY_TIMEZONE + value: Europe/Berlin + - name: DB_CONNECTION + value: sqlite + - name: SPEEDTEST_SCHEDULE + value: "0 * * * *" + - name: SPEEDTEST_SERVERS + value: "" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + volumeMounts: + - mountPath: /config + name: speedtest-tracker + volumes: + - name: speedtest-tracker + persistentVolumeClaim: + claimName: speedtest-tracker diff --git a/k3s/apps/speedtest-tracker/speedtest-tracker-ingress.yaml b/k3s/apps/speedtest-tracker/speedtest-tracker-ingress.yaml new file mode 100644 index 0000000..06d495e --- /dev/null +++ b/k3s/apps/speedtest-tracker/speedtest-tracker-ingress.yaml @@ -0,0 +1,20 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: speedtest-tracker + namespace: speedtest-tracker + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web +spec: + ingressClassName: traefik + rules: + - host: speedtest.henryathome.home64.de + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: speedtest-tracker + port: + number: 80 diff --git a/k3s/apps/speedtest-tracker/speedtest-tracker-pvc.yaml b/k3s/apps/speedtest-tracker/speedtest-tracker-pvc.yaml new file mode 100644 index 0000000..9f86f7c --- /dev/null +++ b/k3s/apps/speedtest-tracker/speedtest-tracker-pvc.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: speedtest-tracker + namespace: speedtest-tracker +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 5Gi diff --git a/k3s/apps/speedtest-tracker/speedtest-tracker-secret.yaml b/k3s/apps/speedtest-tracker/speedtest-tracker-secret.yaml new file mode 100644 index 0000000..e88e290 --- /dev/null +++ b/k3s/apps/speedtest-tracker/speedtest-tracker-secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: speedtest-tracker-secret + namespace: speedtest-tracker +type: Opaque +stringData: + APP_KEY: "base64:kRwkJqieSmtYw0+066zNiNgXInLSexYxT9RgIyONNMI=" # https://speedtest-tracker.dev diff --git a/k3s/apps/speedtest-tracker/speedtest-tracker-service.yaml b/k3s/apps/speedtest-tracker/speedtest-tracker-service.yaml new file mode 100644 index 0000000..47b3b27 --- /dev/null +++ b/k3s/apps/speedtest-tracker/speedtest-tracker-service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: speedtest-tracker + namespace: speedtest-tracker + labels: + app.kubernetes.io/name: speedtest-tracker +spec: + type: NodePort + selector: + app.kubernetes.io/name: speedtest-tracker + ports: + - port: 80 + targetPort: 80 + protocol: TCP + nodePort: 30800