my-homelab-configs/jeannie

386 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${REPO_ROOT}/lib/jeannie/core.sh"
source "${REPO_ROOT}/lib/jeannie/env.sh"
source "${REPO_ROOT}/lib/jeannie/toolchain.sh"
source "${REPO_ROOT}/lib/jeannie/pimox.sh"
BUILDX_CONFIG="/tmp/buildx-config.toml"
HOMELAB_STATE_DIR="${HOMELAB_STATE_DIR:-${XDG_DATA_HOME:-${HOME}/.local/share}/homelab}"
COSIGN_KEY_PREFIX="${COSIGN_KEY_PREFIX:-${HOMELAB_STATE_DIR}/cosign/cosign}"
COSIGN_KEY_PATH="${COSIGN_KEY_PATH:-${COSIGN_KEY_PREFIX}.key}"
COSIGN_PUBLIC_KEY_PATH="${COSIGN_PUBLIC_KEY_PATH:-${COSIGN_KEY_PREFIX}.pub}"
COSIGN_PASSWORD_FILE="${COSIGN_PASSWORD_FILE:-${HOMELAB_STATE_DIR}/cosign/cosign.password}"
COSIGN_VERSION="${COSIGN_VERSION:-2.6.3}"
COSIGN_BIN="${COSIGN_BIN:-}"
HOMELAB_COSIGN_PUBLIC_KEY_CONFIGMAP="${HOMELAB_COSIGN_PUBLIC_KEY_CONFIGMAP:-homelab-cosign-public-key}"
HOMELAB_SBOM_PREDICATE_TYPE="${HOMELAB_SBOM_PREDICATE_TYPE:-https://spdx.dev/Document}"
JEANNIE_LOG_DIR="${JEANNIE_LOG_DIR:-${HOMELAB_STATE_DIR}/logs}"
JEANNIE_LOG_FILE="${JEANNIE_LOG_FILE:-}"
JEANNIE_ERROR_LOG_FILE="${JEANNIE_ERROR_LOG_FILE:-}"
JEANNIE_STEP_INDEX=0
JEANNIE_STEP_TOTAL=0
# shellcheck disable=SC1091
source "${REPO_ROOT}/scripts/report-ui"
trap 'rm -f "${BUILDX_CONFIG}"' EXIT
load_homelab_inventory_defaults
export_homelab_inventory_tf_vars
KUBECONFIG_PATH="${KUBECONFIG_PATH:-${TF_VAR_kubeconfig_path:-${LAB_KUBECONFIG_PATH:-/home/jv/.kube/config}}}"
adopt_platform_existing_resources() {
local stack="bootstrap/platform"
adopt_tofu_helm_release "${stack}" "helm_release.calico_crds" "tigera-operator" "calico-crds"
adopt_tofu_helm_release "${stack}" "helm_release.calico" "tigera-operator" "calico"
adopt_tofu_helm_release "${stack}" "helm_release.openebs" "openebs" "openebs"
adopt_tofu_helm_release "${stack}" "helm_release.metallb[\"enabled\"]" "metallb-system" "metallb"
adopt_tofu_helm_release "${stack}" "helm_release.traefik[\"enabled\"]" "traefik" "traefik"
adopt_tofu_helm_release "${stack}" "helm_release.argocd" "argocd" "argocd"
adopt_tofu_helm_release "${stack}" "helm_release.kyverno" "kyverno" "kyverno"
adopt_tofu_helm_release "${stack}" "helm_release.kyverno_policies" "kyverno" "kyverno-policies"
adopt_tofu_helm_release "${stack}" "helm_release.loki" "monitoring" "loki"
adopt_tofu_helm_release "${stack}" "helm_release.promtail" "monitoring" "promtail"
adopt_tofu_helm_release "${stack}" "helm_release.prometheus_stack" "monitoring" "prometheus-stack"
adopt_tofu_kubernetes_resource \
"${stack}" \
"kubernetes_storage_class_v1.openebs_hostpath_retain" \
"" \
"storageclass" \
"openebs-hostpath-retain" \
"openebs-hostpath-retain"
adopt_tofu_kubernetes_resource \
"${stack}" \
"kubernetes_namespace_v1.monitoring" \
"" \
"namespace" \
"monitoring" \
"monitoring"
}
adopt_apps_existing_resources() {
local stack="bootstrap/apps"
local namespace="${TF_VAR_argocd_namespace:-argocd}"
adopt_tofu_kubernetes_manifest \
"${stack}" \
'kubernetes_manifest.argocd_application["container-registry"]' \
"${namespace}" \
"applications.argoproj.io" \
"argoproj.io/v1alpha1" \
"Application" \
"container-registry"
adopt_tofu_kubernetes_manifest \
"${stack}" \
'kubernetes_manifest.argocd_application["website-production"]' \
"${namespace}" \
"applications.argoproj.io" \
"argoproj.io/v1alpha1" \
"Application" \
"website-production"
adopt_tofu_kubernetes_manifest \
"${stack}" \
'kubernetes_manifest.argocd_application["demos-static"]' \
"${namespace}" \
"applications.argoproj.io" \
"argoproj.io/v1alpha1" \
"Application" \
"demos-static"
adopt_tofu_kubernetes_manifest \
"${stack}" \
'kubernetes_manifest.argocd_application["heimdall"]' \
"${namespace}" \
"applications.argoproj.io" \
"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"]' \
"${namespace}" \
"applications.argoproj.io" \
"argoproj.io/v1alpha1" \
"Application" \
"supply-chain-policy"
}
ensure_homelab_node_labels() {
local control_plane_node="${LAB_CONTROL_PLANE_NODE_NAME:-debian}"
local raspberry_node="${LAB_RASPBERRY_NODE_NAME:-raspberry}"
local prometheus_selector="homelab.dev/node-role=app,homelab.dev/storage=ssd"
local node
local target_nodes
echo "Applying homelab labels to existing Kubernetes nodes..."
while IFS= read -r node; do
[[ -n "${node}" ]] || continue
if [[ "${node}" == "${control_plane_node}" ]]; then
kubectl --kubeconfig "${KUBECONFIG_PATH}" label node "${node}" \
homelab.dev/node-role=control-plane \
homelab.dev/storage=local \
homelab.dev/workload-class=control-plane \
--overwrite
continue
fi
kubectl --kubeconfig "${KUBECONFIG_PATH}" label node "${node}" \
node-role.kubernetes.io/worker=worker \
--overwrite
if [[ "${node}" == pimox-worker-* ]]; then
kubectl --kubeconfig "${KUBECONFIG_PATH}" label node "${node}" \
homelab.dev/node-role=app \
homelab.dev/storage=ssd \
homelab.dev/workload-class=platform \
--overwrite
elif [[ "${node}" == "${raspberry_node}" ]]; then
kubectl --kubeconfig "${KUBECONFIG_PATH}" label node "${node}" \
homelab.dev/node-role=edge-app \
homelab.dev/storage=local \
homelab.dev/workload-class=edge \
--overwrite
fi
done < <(kubectl --kubeconfig "${KUBECONFIG_PATH}" get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}')
target_nodes="$(kubectl --kubeconfig "${KUBECONFIG_PATH}" get nodes -l "${prometheus_selector}" -o name)"
if [[ -z "${target_nodes}" ]]; then
echo "No nodes match ${prometheus_selector}; refusing to move prometheus-stack." >&2
exit 1
fi
}
delete_prometheus_stack_storage() {
local namespace="${1:-monitoring}"
local pattern='(^|-)prometheus-stack-(prometheus|alertmanager|grafana)(-|$)|^prometheus-prometheus-stack|^alertmanager-prometheus-stack|^storage-prometheus-stack-grafana'
local pvc_names
local pv_names
pvc_names="$(kubectl --kubeconfig "${KUBECONFIG_PATH}" -n "${namespace}" get pvc -o name 2>/dev/null |
awk -F/ -v pattern="${pattern}" '$2 ~ pattern {print $2}')"
pv_names="$(kubectl --kubeconfig "${KUBECONFIG_PATH}" get pv \
-o jsonpath='{range .items[?(@.spec.claimRef.namespace=="'"${namespace}"'")]}{.metadata.name}{"\t"}{.spec.claimRef.name}{"\n"}{end}' 2>/dev/null |
awk -v pattern="${pattern}" '$2 ~ pattern {print $1}')"
if [[ -n "${pvc_names}" ]]; then
echo "Deleting old prometheus-stack PVCs in ${namespace}; saved Prometheus, Alertmanager, and Grafana data will be discarded..."
printf '%s\n' "${pvc_names}" |
xargs -r kubectl --kubeconfig "${KUBECONFIG_PATH}" -n "${namespace}" delete pvc --wait=true --timeout=180s
fi
if [[ -n "${pv_names}" ]]; then
echo "Deleting old prometheus-stack retained PV objects..."
printf '%s\n' "${pv_names}" |
xargs -r kubectl --kubeconfig "${KUBECONFIG_PATH}" delete pv --wait=false
fi
}
run_tofu_stack() {
local stack="$1"
local auto_approve="${L
... [OUTPUT TRUNCATED - 267,570 chars omitted out of 317,495 total] ...
s not installed. Run ./jeannie secrets-init first." >&2
return 1
fi
key_file="$(sops_age_key_file)"
if [[ ! -s "${key_file}" ]]; then
echo "Missing SOPS age key at ${key_file}. Run ./jeannie secrets-init, or copy the age key to this machine." >&2
return 1
fi
out="$(SOPS_AGE_KEY_FILE="${key_file}" sops --decrypt "${file}")" || {
echo "SOPS decryption failed for ${file}." >&2
return 1
}
# Export every bare `KEY: value` line (ignore comments/blank).
local line key val syn="0"
while IFS= read -r line; do
[[ "${line}" =~ ^[A-Za-z_][A-Za-z0-9_]*: ]] || continue
key="${line%%:*}"
val="${line#*: }"
# Strip surrounding double quotes, if sops kept them.
val="${val%\"}"; val="${val#\"}"
export "${key}=${val}"
syn="$((syn + 1))"
done <<<"${out}"
if ((syn == 0)); then
echo "No KEY: value pairs found in decrypted ${file}." >&2
return 1
fi
}
tailnet_policy_check() {
"${REPO_ROOT}/scripts/validate-tailnet-policy"
}
ai_index() {
local index_dir="${LAB_AI_KNOWLEDGE_INDEX_DIR:-/data/homelab-ai/index}"
require_debian_server "ai-index"
ensure_python3
if [[ "${index_dir}" == /data/* ]]; then
sudo mkdir -p "${index_dir}"
sudo chown "${USER}:$(id -gn)" "${index_dir}"
fi
"${REPO_ROOT}/scripts/build-homelab-ai-index" --index-dir "${index_dir}"
}
ai_check() {
local index_dir="${LAB_AI_KNOWLEDGE_INDEX_DIR:-/data/homelab-ai/index}"
local endpoint="${LAB_AI_GATEWAY_URL:-${LAB_OLLAMA_URL:-http://127.0.0.1:11434}}"
local model="${LAB_AI_GATEWAY_MODEL:-qwen2.5:0.5b}"
local failures=0
require_debian_server "ai-check"
status_section "AI Knowledge"
if command -v python3 >/dev/null 2>&1; then
printf '%-28s ok\n' "python3"
else
printf '%-28s missing\n' "python3"
failures=$((failures + 1))
fi
if [[ -s "${index_dir}/index.json" ]]; then
printf '%-28s %s\n' "knowledge index" "${index_dir}/index.json"
else
printf '%-28s missing - run ./jeannie ai-index\n' "knowledge index"
failures=$((failures + 1))
fi
if "${REPO_ROOT}/scripts/query-homelab-ai-index" --index-dir "${index_dir}" --context-only --limit 2 "gitea edge traefik" >/dev/null 2>&1; then
printf '%-28s ok\n' "retrieval smoke test"
else
printf '%-28s failed\n' "retrieval smoke test"
failures=$((failures + 1))
fi
if backstage_brain_enabled; then
if curl -fsS --max-time 5 "${endpoint%/}/api/tags" >/dev/null 2>&1; then
printf '%-28s ok - %s\n' "ollama endpoint" "${endpoint}"
if python3 "${REPO_ROOT}/scripts/ai-model-check.py" "${endpoint}" "${model}"
then
printf '%-28s ok - %s\n' "ollama model" "${model}"
else
printf '%-28s missing - run: ollama pull %s\n' "ollama model" "${model}"
failures=$((failures + 1))
fi
else
printf '%-28s unreachable - %s\n' "ollama endpoint" "${endpoint}"
failures=$((failures + 1))
fi
else
printf '%-28s disabled\n' "backstage helper"
fi
if ((failures > 0)); then
echo "AI check failed with ${failures} issue(s)." >&2
exit 1
fi
echo "AI check passed."
}
ask_homelab() {
require_debian_server "ask"
"${REPO_ROOT}/scripts/ask" "${@:2}"
}
ai_evals() {
"${REPO_ROOT}/scripts/ai-evals" "${@:2}"
}
impact() {
"${REPO_ROOT}/scripts/impact" "${@:2}"
}
review_last_change() {
"${REPO_ROOT}/scripts/review-last-change" "${@:2}"
}
security_scan() {
require_debian_server "security-scan"
"${REPO_ROOT}/scripts/security-scan" all
}
security_prepare() {
require_debian_server "security-prepare"
"${REPO_ROOT}/scripts/security-scan" prepare
}
security_zap() {
require_debian_server "security-zap"
"${REPO_ROOT}/scripts/security-scan" zap
}
security_k8s() {
require_debian_server "security-k8s"
"${REPO_ROOT}/scripts/security-scan" kube-bench
}
security_host() {
require_debian_server "security-host"
"${REPO_ROOT}/scripts/security-scan" host
}
security_trivy() {
require_debian_server "security-trivy"
"${REPO_ROOT}/scripts/security-scan" trivy
}
security_secrets() {
require_debian_server "security-secrets"
"${REPO_ROOT}/scripts/security-scan" secrets
}
security_nuclei() {
require_debian_server "security-nuclei"
"${REPO_ROOT}/scripts/security-scan" nuclei
}
security_web() {
require_debian_server "security-web"
"${REPO_ROOT}/scripts/security-scan" web
}
security_logs() {
require_debian_server "security-logs"
"${REPO_ROOT}/scripts/security-logs"
}
security_runtime() {
require_debian_server "security-runtime"
if ! command -v kubectl >/dev/null 2>&1; then
echo "kubectl is required for security-runtime." >&2
exit 1
fi
kubectl --kubeconfig "${KUBECONFIG}" -n kube-system rollout status daemonset/tetragon --timeout=30s
kubectl --kubeconfig "${KUBECONFIG}" -n kube-system get pods -l app.kubernetes.io/name=tetragon -o wide
cat <<'EOF'
Watch recent Tetragon events:
kubectl -n kube-system logs -l app.kubernetes.io/name=tetragon -c export-stdout --tail=100 -f
Useful practice trigger:
kubectl -n security-lab exec deploy/juice-shop -- sh -c 'id; uname -a'
EOF
}