#!/usr/bin/env bash # Shared stages for the deploy workflow. Runs on the workstation, invoked as: # REPO=/srv/homelab APPLY_PRUNE=false bash -se <<'EOF' # source "$REPO/.gitea/workflows/deploy-lib.sh" # run_stage "$STAGE" # EOF set -euo pipefail : "${REPO:?REPO must be set}" APPLY_PRUNE="${APPLY_PRUNE:-false}" log() { echo "== $* ==" } collect_k8s() { git -C "$REPO" ls-files -- "$1" \ | grep -E '\.ya?ml$' \ | grep -Ev '/overlays/' \ | grep -Ev '(^|/)(kustomization\.ya?ml|.*\.example\.ya?ml|.*values\.ya?ml|patch-.*\.ya?ml)$' \ | grep -Ev '(^|/)[^/]*secret[^/]*\.ya?ml$' \ | sort } kustomize_overlay() { if [ -f "$1/overlays/prod/kustomization.yaml" ]; then echo "$1/overlays/prod" elif [ -f "$1/base/kustomization.yaml" ]; then echo "$1/base" fi } select_manifests() { K8S_MANIFESTS=() KUSTOMIZE_APPS=() COMPOSE_STACKS=() local kd_rel kd overlay cf_rel cf f while IFS= read -r kd_rel; do kd="$REPO/$kd_rel" if [ ! -f "$kd/active" ]; then echo "skip (no k8s/active): $kd_rel" continue fi overlay="$(kustomize_overlay "$kd" || true)" if [ -n "${overlay:-}" ]; then echo "kustomize app: ${overlay#$REPO/}" KUSTOMIZE_APPS+=("$overlay") else while IFS= read -r f; do [ -n "$f" ] && K8S_MANIFESTS+=("$REPO/$f") done < <(collect_k8s "$kd_rel" || true) fi done < <( git -C "$REPO" ls-files '*.yaml' '*.yml' \ | grep -E '(^|/)k8s/' \ | sed -E 's#((^|.*/)k8s)/.*#\1#' \ | sort -u ) while IFS= read -r cf_rel; do cf="$REPO/$cf_rel" if [ -f "$(dirname "$cf")/active" ]; then echo "compose: $cf_rel" COMPOSE_STACKS+=("$cf") else echo "skip (no root active): $cf_rel" fi done < <(git -C "$REPO" ls-files '*/compose.yaml' '*/compose.yml' compose.yaml compose.yml | sort) } stage_preflight() { if [ ! -d "$REPO/.git" ]; then echo "Repository not found at $REPO" exit 1 fi git -C "$REPO" fetch origin main log "Workstation state" echo " local: $(git -C "$REPO" rev-parse --short HEAD)" echo " remote: $(git -C "$REPO" rev-parse --short origin/main)" if [ -n "$(git -C "$REPO" status --porcelain --untracked-files=no)" ]; then echo "ERROR: workstation has local tracked modifications, refusing reset:" git -C "$REPO" status --porcelain --untracked-files=no git -C "$REPO" diff --stat exit 1 fi git -C "$REPO" reset --hard origin/main } stage_validate() { cd "$REPO" select_manifests local m k cf log "Validate compose stacks" for cf in ${COMPOSE_STACKS[@]+"${COMPOSE_STACKS[@]}"}; do echo " config: $cf" docker compose -f "$cf" config --quiet done log "Validate k8s manifests (kubectl dry-run=client)" for m in ${K8S_MANIFESTS[@]+"${K8S_MANIFESTS[@]}"}; do kubectl apply --dry-run=client -f "$m" >/dev/null done for k in ${KUSTOMIZE_APPS[@]+"${KUSTOMIZE_APPS[@]}"}; do kubectl apply -k "$k" --dry-run=client >/dev/null done log "Validate k8s manifests (kubectl dry-run=server)" for m in ${K8S_MANIFESTS[@]+"${K8S_MANIFESTS[@]}"}; do kubectl apply --dry-run=server -f "$m" >/dev/null done for k in ${KUSTOMIZE_APPS[@]+"${KUSTOMIZE_APPS[@]}"}; do kubectl apply -k "$k" --dry-run=server >/dev/null done log "Checking referenced Secrets exist" echo " (deploy never applies *secret*.yaml; create missing ones from the laptop)" local ref_secrets=() missing_secrets=() all_secrets s if [ "${#K8S_MANIFESTS[@]}" -gt 0 ]; then while IFS= read -r s; do [ -n "$s" ] && ref_secrets+=("$s") done < <( { grep -h -A1 -E 'secretRef:|secretKeyRef:' "${K8S_MANIFESTS[@]}" 2>/dev/null || true grep -h -E 'secretName:' "${K8S_MANIFESTS[@]}" 2>/dev/null || true } | grep -E 'name:' | sed -E 's/.*name:[[:space:]]*//' | tr -d '"'"'"' "'"'" | sed -E 's/[[:space:]]*#.*//' | awk 'NF' | sort -u || true ) fi all_secrets="$(kubectl get secrets -A --no-headers -o custom-columns=:metadata.name 2>/dev/null || true)" for s in ${ref_secrets[@]+"${ref_secrets[@]}"}; do if printf '%s\n' "$all_secrets" | grep -qx "$s"; then echo " ok: $s" else echo " MISSING: $s" missing_secrets+=("$s") fi done if [ "${#missing_secrets[@]}" -gt 0 ]; then echo "ERROR: ${#missing_secrets[@]} referenced Secret(s) not found in the cluster:" printf ' - %s\n' "${missing_secrets[@]}" echo "Create them manually from the laptop, e.g.:" echo " kubectl apply -f SERVICE/k8s/secrets.yaml # see SERVICE/k8s/secrets.yaml.example" exit 1 fi } stage_apply_k8s() { cd "$REPO" select_manifests >/dev/null local ns_files=() other_files=() m k prune_opts=() for m in ${K8S_MANIFESTS[@]+"${K8S_MANIFESTS[@]}"}; do case "$m" in */namespace.y?ml) ns_files+=("$m") ;; *) other_files+=("$m") ;; esac done if [ "$APPLY_PRUNE" = "true" ]; then prune_opts=(--prune -l app.kubernetes.io/managed-by=homelab-deploy) fi if [ "${#ns_files[@]}" -gt 0 ]; then log "Applying namespaces (${#ns_files[@]} files)" for m in "${ns_files[@]}"; do kubectl apply -f "$m" done fi if [ -f "$REPO/prometheus-stack/k8s/active" ]; then if [ ! -f "$REPO/prometheus-stack/k8s/grafana-values.yaml" ]; then echo "ERROR: prometheus-stack/k8s/grafana-values.yaml (gitignored) missing on workstation, restore it first." exit 1 fi log "Upgrading kube-prometheus-stack" helm upgrade --install prometheus-stack prometheus-community/kube-prometheus-stack \ --namespace prometheus \ --version 86.2.3 \ --values "$REPO/prometheus-stack/k8s/grafana-values.yaml" \ --wait --timeout 10m fi if [ -f "$REPO/loki/k8s/active" ]; then log "Upgrading loki/alloy" helm repo add grafana https://grafana.github.io/helm-charts >/dev/null 2>&1 || true helm repo update grafana >/dev/null 2>&1 || true helm upgrade --install loki grafana/loki \ --version 7.3.0 \ --namespace prometheus \ --values "$REPO/loki/k8s/loki-values.yaml" \ --wait --timeout 10m helm upgrade --install alloy grafana/alloy \ --version 1.12.1 \ --namespace prometheus \ --values "$REPO/loki/k8s/alloy-values.yaml" \ --wait --timeout 10m fi if [ "${#other_files[@]}" -gt 0 ]; then log "Applying resources (${#other_files[@]} files)" for m in "${other_files[@]}"; do kubectl apply "${prune_opts[@]}" -f "$m" done fi for k in ${KUSTOMIZE_APPS[@]+"${KUSTOMIZE_APPS[@]}"}; do log "Applying kustomize app: ${k#$REPO/}" kubectl apply -k "$k" done if [ -f "$REPO/userbot/k8s/active" ]; then log "userbot panel hook" if kubectl get secret userbot-common-secrets -n userbot >/dev/null 2>&1; then echo " userbot-common-secrets already present in userbot ns, not touching" elif kubectl get secret userbot-common-secrets -n default >/dev/null 2>&1; then echo " bootstrapping userbot-common-secrets into userbot ns" kubectl get secret userbot-common-secrets -n default -o json \ | jq 'del(.metadata.annotations,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.uid,.metadata.managedFields) | .metadata.namespace = "userbot"' \ | kubectl apply -f - else echo " WARNING: userbot-common-secrets missing in both default and userbot ns; create it manually from the laptop" fi kubectl rollout restart deployment/userbot-panel -n userbot kubectl rollout status deployment/userbot-panel -n userbot --timeout=180s fi } stage_apply_compose() { cd "$REPO" select_manifests >/dev/null local cf log "Redeploying docker compose stacks (${#COMPOSE_STACKS[@]} stacks)" for cf in ${COMPOSE_STACKS[@]+"${COMPOSE_STACKS[@]}"}; do echo " compose: $cf" if grep -Eq '^\s+pull_policy:\s*build\b' "$cf"; then docker compose -f "$cf" build docker compose -f "$cf" push fi docker compose -f "$cf" up -d --pull always --remove-orphans done } run_stage() { case "${1:?stage required}" in preflight) stage_preflight ;; validate) stage_validate ;; apply-k8s) stage_apply_k8s ;; apply-compose) stage_apply_compose ;; *) echo "ERROR: unknown stage: $1" exit 1 ;; esac }