#!/usr/bin/env bash set -euo pipefail # init_monitoring.sh # Purpose: # - Configure k3d environment for monitoring (Prometheus and Grafana) # - Setup kube-prometheus-stack and CNPG prometheus rules SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) PROLE_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) # Load environment and config via knoe_cfg.sh # shellcheck disable=SC1090 source "$SCRIPT_DIR/knoe_cfg.sh" if [[ "${1:-}" == "--mode" || "${1:-}" == "-m" ]]; then knoe_set_mode "${2:-}" shift 2 elif [[ "${1:-}" == --mode=* || "${1:-}" == -m=* ]]; then knoe_set_mode "${1#*=}" shift fi if [[ -z "${KNOE_SERVICE:-}" ]]; then echo "ERROR: KNOE_SERVICE is not defined. Provide KNOE_HOME/env.sh or ~/.knoe/env.sh" >&2 exit 1 fi GRAFANA_RELEASE="grafana" LEGACY_GRAFANA_RELEASE="grafana-knoe" ts() { date "+%Y-%m-%d %H:%M:%S" } log() { echo "==> [$(ts)] $*" } err() { echo "ERROR: [$(ts)] $*" >&2 } section() { echo "==> [$(ts)] --- $* ---" } ensure_tools() { for t in helm kubectl curl jq; do command -v "$t" >/dev/null || { err "Missing required tool: $t"; exit 1; } done } kubectl_retry() { local attempts="${KUBECTL_RETRY_ATTEMPTS:-5}" local delay="${KUBECTL_RETRY_DELAY:-2}" local i for (( i=1; i<=attempts; i++ )); do if kubectl "$@"; then return 0 fi if (( i >= attempts )); then return 1 fi sleep "$delay" delay=$(( delay * 2 )) done } ensure_namespace() { local ns="$1" if ! kubectl get namespace "$ns" >/dev/null 2>&1; then log "Creating namespace '$ns' ..." kubectl create namespace "$ns" >/dev/null 2>&1 || true fi } storage_class_exists() { local sc="$1" [[ -n "$sc" ]] || return 1 kubectl get storageclass "$sc" >/dev/null 2>&1 } default_storage_class() { kubectl get storageclass -o jsonpath='{range .items[?(@.metadata.annotations.storageclass\.kubernetes\.io/is-default-class=="true")]}{.metadata.name}{"\n"}{end}' 2>/dev/null | head -n1 } monitoring_mode() { local mode mode="${KNOE_MODE:-${DEPLOYMENT_MODE:-}}" mode=$(printf '%s' "$mode" | tr '[:upper:]' '[:lower:]') case "$mode" in gke|prod|k8s) echo "k8s" ;; k3s|k3d) echo "$mode" ;; *) echo "$mode" ;; esac } choose_monitoring_storage_class() { local mode mode=$(monitoring_mode) if [[ -n "${MONITORING_STORAGE_CLASS:-}" ]]; then echo "$MONITORING_STORAGE_CLASS" return 0 fi if [[ "$mode" == "k3s" ]]; then if storage_class_exists "merlin-local-iscsi"; then echo "merlin-local-iscsi" return 0 fi # Backward compatibility (older runs created knoe-monitoring-) if storage_class_exists "knoe-monitoring-d004"; then echo "knoe-monitoring-d004" return 0 fi fi local default_sc default_sc=$(default_storage_class) if [[ -n "$default_sc" ]]; then echo "$default_sc" return 0 fi if [[ "$mode" == "k3s" || "$mode" == "k3d" ]] && storage_class_exists "local-path"; then echo "local-path" return 0 fi echo "" } monitoring_storage_class_for_role() { local role="$1" local base="${MONITORING_STORAGE_CLASS_SELECTED:-}" [[ -n "${base:-}" ]] || return 0 # For the static local-PV setup on the primary k3s node, use dedicated # storageClasses per component to avoid nondeterministic PV binding. if [[ "$(monitoring_mode)" == "k3s" && "$base" == "merlin-local-iscsi" ]]; then local candidate candidate="${base}-${role}" if storage_class_exists "$candidate"; then echo "$candidate" else echo "$base" fi return 0 fi echo "$base" } validate_monitoring_storage_classes() { local selected="${MONITORING_STORAGE_CLASS_SELECTED:-}" [[ -n "${selected:-}" ]] || return 0 local role sc local -a missing=() local -a seen=() for role in prometheus alertmanager grafana; do sc=$(monitoring_storage_class_for_role "$role") [[ -n "${sc:-}" ]] || continue local already_seen=0 local seen_sc for seen_sc in "${seen[@]}"; do if [[ "$seen_sc" == "$sc" ]]; then already_seen=1 break fi done (( already_seen == 1 )) && continue seen+=("$sc") if ! storage_class_exists "$sc"; then missing+=("$sc") fi done if [[ ${#missing[@]} -gt 0 ]]; then local mode mode=$(monitoring_mode) err "Monitoring storageClass validation failed for mode '${mode:-unknown}': rendered class(es) not found: ${missing[*]}. Configure MONITORING_STORAGE_CLASS in conf/gke.cfg (or the selected cluster config) to an existing StorageClass." return 1 fi return 0 } monitoring_nodes_available() { kubectl get nodes -l "knoe.org/node-role=general" -o name 2>/dev/null | grep -q . } resolve_monitoring_primary_node() { local node if [[ -n "${MONITORING_PRIMARY_NODE:-}" ]]; then printf '%s' "${MONITORING_PRIMARY_NODE}" return 0 fi if kubectl get node merlin.knoe.org >/dev/null 2>&1; then printf '%s' "merlin.knoe.org" return 0 fi node=$(kubectl get nodes -l "knoe.org/node-role=general" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) if [[ -n "${node:-}" ]]; then printf '%s' "$node" return 0 fi node=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) printf '%s' "$node" } prepare_k3s_monitoring_data_dirs() { local node="$1" # NOTE: Historically this came from knoe.cfg. For now we pin it to the # monitoring deployment default and allow overrides via env var. local base="${PROLE_MONITORING_DATA_DIR:-/synology/d004}" base="${base%/}" if [[ -z "${node:-}" ]]; then err "Could not resolve a monitoring node to prepare storage on" return 1 fi local parent base_name parent=$(dirname "$base") base_name=$(basename "$base") section "Preparing monitoring data directories on node '${node}'" log "Checking base directory exists on node: ${base}" local job_ns job_name timeout image job_ns="${MONITORING_DIRPREP_NAMESPACE:-kube-system}" job_name="${MONITORING_DIRPREP_JOB_NAME:-knoe-monitoring-dirprep}" timeout="${MONITORING_DIRPREP_TIMEOUT:-600s}" image="${MONITORING_DIRPREP_IMAGE:-alpine:3.20}" kubectl -n "$job_ns" delete job "$job_name" --ignore-not-found --wait=true --timeout=120s >/dev/null 2>&1 || true cat </dev/null apiVersion: batch/v1 kind: Job metadata: name: ${job_name} namespace: ${job_ns} spec: backoffLimit: 0 template: spec: restartPolicy: Never nodeName: ${node} containers: - name: dirprep image: ${image} securityContext: runAsUser: 0 runAsGroup: 0 command: ["/bin/sh", "-ec"] args: - | BASE_DIR="/host-parent/${base_name}" echo "[dirprep] node=${node} base=${base}" if [ ! -d "\${BASE_DIR}" ]; then echo "[dirprep][ERROR] Base monitoring directory does not exist on node: ${base}" >&2 echo "[dirprep][ERROR] Ensure the mount exists and is mounted on node '${node}'." >&2 exit 2 fi for d in prometheus alertmanager grafana; do mkdir -p "\${BASE_DIR}/\${d}" chmod 0777 "\${BASE_DIR}/\${d}" 2>/dev/null || true done echo "[dirprep][OK] Prepared ${base}/{prometheus,alertmanager,grafana}" volumeMounts: - name: host-parent mountPath: /host-parent volumes: - name: host-parent hostPath: path: ${parent} type: Directory EOF if ! kubectl -n "$job_ns" wait --for=condition=complete "job/${job_name}" --timeout="$timeout" >/dev/null 2>&1; then err "Monitoring directory preparation job did not complete successfully (job=${job_name} ns=${job_ns})." set +e kubectl -n "$job_ns" logs "job/${job_name}" 2>/dev/null || true kubectl -n "$job_ns" describe "job/${job_name}" 2>/dev/null || true kubectl -n "$job_ns" get pod -l job-name="${job_name}" -o wide 2>/dev/null || true set -e return 1 fi set +e kubectl -n "$job_ns" logs "job/${job_name}" 2>/dev/null || true kubectl -n "$job_ns" delete job "$job_name" --ignore-not-found >/dev/null 2>&1 || true set -e log "[OK] Monitoring storage paths prepared under ${base}" } apply_k3s_monitoring_local_pvs() { local mode="$1" [[ "$mode" == "k3s" ]] || return 0 local node node=$(resolve_monitoring_primary_node) if [[ -z "${node:-}" ]]; then err "No Kubernetes node found; cannot prepare k3s monitoring local PV directories" return 1 fi local base volume_id sc_base sc_prom sc_am sc_graf base="${PROLE_MONITORING_DATA_DIR:-/synology/d004}" base="${base%/}" volume_id=$(basename "$base") if [[ -z "${volume_id:-}" || "$volume_id" == "/" || "$volume_id" == "." ]]; then err "Invalid PROLE_MONITORING_DATA_DIR: '$base'" return 1 fi sc_base="merlin-local-iscsi" sc_prom="${sc_base}-prometheus" sc_am="${sc_base}-alertmanager" sc_graf="${sc_base}-grafana" prepare_k3s_monitoring_data_dirs "$node" section "Applying monitoring StorageClass and PersistentVolumes" log "Applying monitoring StorageClasses ('${sc_base}', '${sc_prom}', '${sc_am}', '${sc_graf}') ..." cat </dev/null 2>&1 || true continue fi if [[ -n "$expected_sc" && -n "$pvc_sc" && "$pvc_sc" != "$expected_sc" ]]; then log "Deleting Pending PVC '$pvc_name' with storageClass '$pvc_sc' (expected '$expected_sc') ..." kubectl -n "$ns" delete pvc "$pvc_name" >/dev/null 2>&1 || true fi done <<< "$pending" } apply_grafana_dashboard() { local ns="$1" local prom_uid="${GRAFANA_PROMETHEUS_DATASOURCE_UID:-prometheus}" local dashboard_path="" if [[ -n "${PROLE_GRAFANA_DASHBOARD_PATH:-}" ]]; then dashboard_path="$PROLE_GRAFANA_DASHBOARD_PATH" elif [[ -n "${KNOE_SERVICE:-}" && -f "$KNOE_SERVICE/knoe-db/grafana-dashboard.json" ]]; then dashboard_path="$KNOE_SERVICE/knoe-db/grafana-dashboard.json" elif [[ -f "$PROLE_ROOT/knoe-db/grafana-dashboard.json" ]]; then dashboard_path="$PROLE_ROOT/knoe-db/grafana-dashboard.json" fi if [[ -z "$dashboard_path" || ! -f "$dashboard_path" ]]; then log "Grafana dashboard not found; skipping." return 0 fi local tmp tmp=$(mktemp) sed "s/\\\${DS_PROMETHEUS}/${prom_uid}/g" "$dashboard_path" > "$tmp" local cm_yaml cm_yaml=$(mktemp) kubectl_retry -n "$ns" delete configmap knoe-db-grafana-dashboard --ignore-not-found >/dev/null 2>&1 || true kubectl -n "$ns" create configmap knoe-db-grafana-dashboard --from-file=knoe-db.json="$tmp" --dry-run=client -o yaml > "$cm_yaml" kubectl_retry -n "$ns" apply --server-side --field-manager=knoe-init-monitoring --force-conflicts -f "$cm_yaml" >/dev/null kubectl_retry -n "$ns" label configmap knoe-db-grafana-dashboard grafana_dashboard=1 --overwrite >/dev/null rm -f "$tmp" "$cm_yaml" } apply_grafana_datasource() { local ns="$1" local prom_uid="${GRAFANA_PROMETHEUS_DATASOURCE_UID:-prometheus}" local prom_url="${GRAFANA_PROMETHEUS_DATASOURCE_URL:-http://kps-kube-prometheus-stack-prometheus.${ns}.svc.cluster.local:9090}" local cm_yaml cm_yaml=$(mktemp) cat > "$cm_yaml" </dev/null rm -f "$cm_yaml" } helm_release_status() { local release="$1" local ns="$2" helm status "$release" -n "$ns" -o json 2>/dev/null | jq -r '.info.status' 2>/dev/null || true } wait_for_helm_release() { local release="$1" local ns="$2" local timeout="${HELM_WAIT_TIMEOUT:-${MONITORING_PENDING_TIMEOUT:-600}}" local interval="${HELM_WAIT_INTERVAL:-${MONITORING_PENDING_INTERVAL:-5}}" local start start=$(date +%s) while true; do local status status=$(helm_release_status "$release" "$ns") if [[ -z "$status" || "$status" == "null" ]]; then return 0 fi case "$status" in pending-*) if (( $(date +%s) - start > timeout )); then err "Timed out waiting for Helm release '$release' in '$ns' (status=$status)." return 1 fi log "Helm release '$release' is $status; waiting... (elapsed=$(( $(date +%s) - start ))s, timeout=${timeout}s)" sleep "$interval" ;; *) return 0 ;; esac done } wait_for_monitoring_resources() { local ns="$1" local timeout="${MONITORING_READY_TIMEOUT:-600s}" section "Waiting for Prometheus stack resources to settle (timeout=$timeout)" set +e kubectl -n "$ns" rollout status deployment/kps-kube-prometheus-stack-operator --timeout="$timeout" >/dev/null 2>&1 kubectl -n "$ns" rollout status deployment/kps-kube-state-metrics --timeout="$timeout" >/dev/null 2>&1 kubectl -n "$ns" rollout status statefulset/prometheus-kps-kube-prometheus-stack-prometheus --timeout="$timeout" >/dev/null 2>&1 kubectl -n "$ns" rollout status statefulset/alertmanager-kps-kube-prometheus-stack-alertmanager --timeout="$timeout" >/dev/null 2>&1 set -e } collect_monitoring_diagnostics() { local ns="$1" local release="$2" section "Monitoring diagnostics (namespace=$ns release=$release)" set +e echo "[diag] helm status ${release} -n ${ns}" >&2 helm status "$release" -n "$ns" >&2 echo "[diag] helm history ${release} -n ${ns}" >&2 helm history "$release" -n "$ns" >&2 echo "[diag] kubectl -n ${ns} get all" >&2 kubectl -n "$ns" get all -o wide >&2 echo "[diag] kubectl -n ${ns} get pvc" >&2 kubectl -n "$ns" get pvc -o wide >&2 echo "[diag] kubectl -n ${ns} get events (last 100)" >&2 kubectl -n "$ns" get events --sort-by=.lastTimestamp 2>/dev/null | tail -n 100 >&2 echo "[diag] kubectl -n ${ns} get pods (not Running)" >&2 kubectl -n "$ns" get pods --field-selector=status.phase!=Running -o wide >&2 # Best-effort describe of non-Running pods (helps pinpoint scheduling/PVC/image issues) local pod_names pod_names=$(kubectl -n "$ns" get pods --no-headers 2>/dev/null | awk '$3 != "Running" {print $1}' || true) local p for p in $pod_names; do echo "[diag] kubectl -n ${ns} describe pod/${p}" >&2 kubectl -n "$ns" describe pod "$p" >&2 done local pending_pvcs pending_pvcs=$(kubectl -n "$ns" get pvc --no-headers 2>/dev/null | awk '$2 == "Pending" {print $1}' || true) local pvc for pvc in $pending_pvcs; do echo "[diag] kubectl -n ${ns} describe pvc/${pvc}" >&2 kubectl -n "$ns" describe pvc "$pvc" >&2 done set -e } wait_for_namespace_deleted() { local ns="$1" local timeout="${MONITORING_RESET_TIMEOUT:-300}" local interval="${MONITORING_RESET_INTERVAL:-2}" local start start=$(date +%s) while true; do if ! kubectl get namespace "$ns" >/dev/null 2>&1; then return 0 fi if (( $(date +%s) - start > timeout )); then err "Timed out waiting for namespace '$ns' deletion (timeout=${timeout}s)." return 1 fi log "Waiting for namespace '$ns' to terminate... (elapsed=$(( $(date +%s) - start ))s, timeout=${timeout}s)" sleep "$interval" done } stabilize_cluster_after_reset() { local timeout="${MONITORING_STABILIZE_TIMEOUT:-120}" section "Waiting for cluster to stabilize (timeout=${timeout}s)" set +e kubectl wait --for=condition=Ready nodes --all --timeout="${timeout}s" >/dev/null 2>&1 set -e } reset_monitoring_namespace() { local ns="$1" local release="$2" local helm_timeout="${MONITORING_HELM_TIMEOUT:-${HELM_TIMEOUT:-10m}}" section "Resetting monitoring (namespace=$ns release=$release)" set +e if helm status "$release" -n "$ns" >/dev/null 2>&1; then log "Uninstalling Helm release '$release' from namespace '$ns' (timeout=$helm_timeout)..." helm uninstall "$release" -n "$ns" --wait --timeout "$helm_timeout" >/dev/null 2>&1 || true fi log "Deleting namespace '$ns' ..." kubectl delete namespace "$ns" >/dev/null 2>&1 || true set -e wait_for_namespace_deleted "$ns" || true stabilize_cluster_after_reset || true ensure_namespace "$ns" } reset_monitoring_release() { local ns="$1" local release="$2" local helm_timeout="${MONITORING_HELM_TIMEOUT:-${HELM_TIMEOUT:-10m}}" section "Resetting Helm release only (namespace=$ns release=$release)" set +e if helm status "$release" -n "$ns" >/dev/null 2>&1; then log "Uninstalling Helm release '$release' from namespace '$ns' (timeout=$helm_timeout)..." helm uninstall "$release" -n "$ns" --wait --timeout "$helm_timeout" >/dev/null 2>&1 || true fi set -e } helm_upgrade_with_retry() { local release="$1" local ns="$2" local chart="$3" shift 3 local attempts="${HELM_UPGRADE_RETRIES:-5}" local delay="${HELM_RETRY_DELAY:-5}" local helm_timeout="${MONITORING_HELM_TIMEOUT:-${HELM_TIMEOUT:-10m}}" local max_resets="${MONITORING_RESET_RETRIES:-1}" local reset_scope="${MONITORING_RESET_SCOPE:-namespace}" local reset_count=0 local attempt out rc local delay_current delay_current="$delay" for ((attempt=1; attempt<=attempts; attempt++)); do if ! wait_for_helm_release "$release" "$ns"; then collect_monitoring_diagnostics "$ns" "$release" || true if (( reset_count < max_resets )); then reset_count=$(( reset_count + 1 )) if [[ "$reset_scope" == "release" ]]; then reset_monitoring_release "$ns" "$release" || true else reset_monitoring_namespace "$ns" "$release" || true fi continue fi err "Helm release '$release' remained in a pending state and reset retries are exhausted ($reset_count/$max_resets)." return 1 fi set +e out=$(helm upgrade --install "$release" "$chart" --namespace "$ns" --wait --timeout "$helm_timeout" "$@" 2>&1) rc=$? set -e if [[ $rc -eq 0 ]]; then printf '%s\n' "$out" return 0 fi if echo "$out" | grep -q "another operation (install/upgrade/rollback) is in progress"; then log "Helm release '$release' is busy; retrying ($attempt/$attempts)..." wait_for_helm_release "$release" "$ns" || true sleep "$delay_current" if (( delay_current < 60 )); then delay_current=$(( delay_current * 2 )) if (( delay_current > 60 )); then delay_current=60 fi fi continue fi # Helm/k8s timeouts: diagnose + reset + retry. if echo "$out" | grep -Eq "timed out waiting for the condition|context deadline exceeded"; then err "Helm upgrade timed out (timeout=$helm_timeout)." echo "$out" >&2 collect_monitoring_diagnostics "$ns" "$release" || true if (( reset_count < max_resets )); then reset_count=$(( reset_count + 1 )) if [[ "$reset_scope" == "release" ]]; then reset_monitoring_release "$ns" "$release" || true else reset_monitoring_namespace "$ns" "$release" || true fi continue fi return 1 fi # Transient Kubernetes API/transport failures (common on small/loaded k3s clusters during CRD installs) if echo "$out" | grep -Eq "stream error: stream ID|INTERNAL_ERROR|the server was unable to return a response in the time allotted|i/o timeout|EOF"; then log "Transient error during Helm upgrade; retrying ($attempt/$attempts)..." echo "$out" >&2 sleep "$delay_current" if (( delay_current < 60 )); then delay_current=$(( delay_current * 2 )) if (( delay_current > 60 )); then delay_current=60 fi fi continue fi echo "$out" >&2 return "$rc" done err "Helm upgrade failed after $attempts attempts for release '$release' in '$ns'." return 1 } openbao_url() { if [[ -n "${PROLE_OPENBAO_URL:-}" ]]; then echo "$PROLE_OPENBAO_URL" return 0 fi if knoe_is_in_cluster; then echo "http://openbao.${SERVICE_NAMESPACE:-${NAMESPACE:-default}}.svc.cluster.local:8200" return 0 fi # In k3s mode, scripts run outside the cluster must reach OpenBao via the k3s host # (never via localhost or kubectl port-forward). if [[ "${KNOE_MODE:-${DEPLOYMENT_MODE:-}}" == "k3s" ]]; then if command -v _knoe_host_from_url >/dev/null 2>&1; then local host host=$(_knoe_host_from_url "${PROLE_K3S_SERVER:-${K3S_SERVER_URL:-}}") if [[ -n "${host:-}" ]]; then echo "http://${host}:8200" return 0 fi fi echo "" return 0 fi if curl -sS "http://127.0.0.1:8200/v1/sys/health" >/dev/null 2>&1; then echo "http://127.0.0.1:8200" return 0 fi echo "" return 0 } openbao_token() { if [[ -f "$KNOE_SERVICE/secrets/openbao-root-token" ]]; then cat "$KNOE_SERVICE/secrets/openbao-root-token" else echo "${OPENBAO_ROOT_TOKEN:-}" fi } fetch_openbao_secret() { local path="$1" local key="$2" local token url token=$(openbao_token) url=$(openbao_url) if [[ -z "$token" || -z "$url" ]]; then echo "" return 0 fi curl -sS -H "X-Vault-Token: $token" "$url/v1/kv/data/$path" | jq -r ".data.data.\"$key\"" || echo "" } resolve_grafana_password() { if [[ -z "${GRAFANA_ADMIN_PASSWORD:-}" || "${GRAFANA_ADMIN_PASSWORD}" == '${OPENBAO:'* || "${GRAFANA_ADMIN_PASSWORD}" == '${KNOE_SECRET:'* ]]; then local fetched fetched=$(fetch_openbao_secret "knoe/${NAMESPACE:-default}/monitoring" "grafana_admin_password") if [[ -n "$fetched" && "$fetched" != "null" ]]; then GRAFANA_ADMIN_PASSWORD="$fetched" fi fi if [[ -z "${GRAFANA_ADMIN_PASSWORD:-}" || "${GRAFANA_ADMIN_PASSWORD}" == '${OPENBAO:'* || "${GRAFANA_ADMIN_PASSWORD}" == '${KNOE_SECRET:'* ]]; then local db_pw="${DB_PASSWORD:-}" if [[ -z "$db_pw" || "$db_pw" == '${OPENBAO:'* || "$db_pw" == '${KNOE_SECRET:'* ]]; then local fetched_db fetched_db=$(fetch_openbao_secret "knoe/${NAMESPACE:-default}/db" "password") if [[ -n "$fetched_db" && "$fetched_db" != "null" ]]; then db_pw="$fetched_db" fi fi if [[ -n "$db_pw" ]]; then GRAFANA_ADMIN_PASSWORD="$db_pw" fi fi # Strip any trailing newlines/carriage-returns that may have crept in via # knoe.cfg parsing, file reads, or shell substitution edge-cases. local _pw _pw="${GRAFANA_ADMIN_PASSWORD:-}" _pw="${_pw//$'\n'/}" _pw="${_pw//$'\r'/}" GRAFANA_ADMIN_PASSWORD="$_pw" } write_grafana_password_to_openbao() { local token url token=$(openbao_token) url=$(openbao_url) if [[ -z "$token" || -z "$url" || -z "${GRAFANA_ADMIN_PASSWORD:-}" ]]; then return 0 fi local _clean_grafana_pw _clean_grafana_pw="${GRAFANA_ADMIN_PASSWORD//$'\n'/}" _clean_grafana_pw="${_clean_grafana_pw//$'\r'/}" curl -sS -H "X-Vault-Token: $token" -H 'Content-Type: application/json' \ -X POST "$url/v1/kv/data/knoe/${NAMESPACE:-default}/monitoring" \ -d "{\"data\":{\"grafana_admin_password\":\"$_clean_grafana_pw\"}}" >/dev/null || true } cleanup_grafana_rbac_conflicts() { local release="$GRAFANA_RELEASE" local ns="$NAMESPACE" local cr="${release}-clusterrole" local crb="${release}-clusterrolebinding" local rel_ns rel_name if kubectl get clusterrole "$cr" >/dev/null 2>&1; then rel_ns=$(kubectl get clusterrole "$cr" -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-namespace}' 2>/dev/null || true) rel_name=$(kubectl get clusterrole "$cr" -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-name}' 2>/dev/null || true) if [[ -n "$rel_ns" && "$rel_ns" != "$ns" ]]; then log "Detected existing ClusterRole '$cr' owned by release '${rel_name:-unknown}' in namespace '$rel_ns'." if [[ -n "$rel_name" ]] && helm status "$rel_name" -n "$rel_ns" >/dev/null 2>&1; then log "Uninstalling old Grafana release '$rel_name' from '$rel_ns' ..." helm uninstall "$rel_name" -n "$rel_ns" || true fi if kubectl get clusterrole "$cr" >/dev/null 2>&1; then log "Deleting orphaned ClusterRole '$cr' ..." kubectl delete clusterrole "$cr" || true fi fi fi if kubectl get clusterrolebinding "$crb" >/dev/null 2>&1; then rel_ns=$(kubectl get clusterrolebinding "$crb" -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-namespace}' 2>/dev/null || true) rel_name=$(kubectl get clusterrolebinding "$crb" -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-name}' 2>/dev/null || true) if [[ -n "$rel_ns" && "$rel_ns" != "$ns" ]]; then log "Detected existing ClusterRoleBinding '$crb' owned by release '${rel_name:-unknown}' in namespace '$rel_ns'." if [[ -n "$rel_name" ]] && helm status "$rel_name" -n "$rel_ns" >/dev/null 2>&1; then log "Uninstalling old Grafana release '$rel_name' from '$rel_ns' ..." helm uninstall "$rel_name" -n "$rel_ns" || true fi if kubectl get clusterrolebinding "$crb" >/dev/null 2>&1; then log "Deleting orphaned ClusterRoleBinding '$crb' ..." kubectl delete clusterrolebinding "$crb" || true fi fi fi } cleanup_legacy_grafana_release() { local ns="$NAMESPACE" if helm status "$LEGACY_GRAFANA_RELEASE" -n "$ns" >/dev/null 2>&1; then log "Uninstalling legacy Grafana release '$LEGACY_GRAFANA_RELEASE' from '$ns' ..." helm uninstall "$LEGACY_GRAFANA_RELEASE" -n "$ns" || true fi } install_monitoring() { ensure_tools local monitoring_ns="monitoring" ensure_namespace "$monitoring_ns" section "Pre-check monitoring state" log "Helm release status: kps=$(helm_release_status kps "$monitoring_ns"), grafana=$(helm_release_status "$GRAFANA_RELEASE" "$monitoring_ns")" local mode mode="${KNOE_MODE:-${DEPLOYMENT_MODE:-}}" # For k3s service clusters using local PVs, ensure the backing host directories # exist before applying StorageClass/PV manifests. apply_k3s_monitoring_local_pvs "$mode" if ! monitoring_nodes_available; then err "No nodes labeled knoe.org/node-role=general; monitoring requires general nodes." return 2 fi MONITORING_STORAGE_CLASS_SELECTED=$(choose_monitoring_storage_class) if [[ -n "$MONITORING_STORAGE_CLASS_SELECTED" ]]; then log "Using storageClass '$MONITORING_STORAGE_CLASS_SELECTED' for monitoring PVCs." else log "No storageClass detected; disabling persistence for Grafana and skipping Prometheus/Alertmanager storage." fi if ! validate_monitoring_storage_classes; then return 1 fi # Don’t enforce a single storageClass here: in k3s static local-PV mode we use # per-component storageClasses (e.g. merlin-local-iscsi-prometheus|grafana|alertmanager). cleanup_pending_pvcs "$monitoring_ns" "" local separate_grafana separate_grafana="${MONITORING_SEPARATE_GRAFANA:-false}" case "$separate_grafana" in 1|true|TRUE|yes|YES|on|ON) separate_grafana="true" ;; *) separate_grafana="false" ;; esac # Derive PV names from PROLE_MONITORING_DATA_DIR (same logic as apply_monitoring_pvs) local _mpv_base _mpv_vid _mpv_base="${PROLE_MONITORING_DATA_DIR:-/synology/d004}" _mpv_base="${_mpv_base%/}" _mpv_vid=$(basename "$_mpv_base") pv_prom="merlin-local-iscsi-${_mpv_vid}-prometheus" pv_am="merlin-local-iscsi-${_mpv_vid}-alertmanager" pv_graf="merlin-local-iscsi-${_mpv_vid}-grafana" # Release any Released monitoring PVs so new PVCs can bind (idempotent) for _mpv in "${pv_prom}" "${pv_am}" "${pv_graf}"; do _mpv_phase=$(kubectl get pv "$_mpv" -o jsonpath='{.status.phase}' 2>/dev/null || true) if [[ "$_mpv_phase" == "Released" ]]; then log "Clearing stale claimRef on Released PV '$_mpv' ..." kubectl patch pv "$_mpv" -p '{"spec":{"claimRef":null}}' 2>/dev/null || true fi done section "Installing Prometheus stack" helm repo add prometheus-community https://prometheus-community.github.io/helm-charts || true helm repo update prometheus-community || true resolve_grafana_password if [[ -z "${GRAFANA_ADMIN_PASSWORD:-}" ]]; then GRAFANA_ADMIN_PASSWORD="admin" # Fallback fi local values_file values_file=$(mktemp) cat > "$values_file" < "$grafana_values" </dev/null || true kubectl rollout status deployment/kps-grafana -n "$monitoring_ns" --timeout=60s 2>/dev/null || true log "Applying Knoe Grafana dashboard..." apply_grafana_dashboard "$monitoring_ns" # Register port forwards knoe_register_port_forward "prometheus" "$monitoring_ns" "svc/kps-kube-prometheus-stack-prometheus" "9090" "9090" "127.0.0.1" "TCP" "Prometheus" knoe_register_port_forward "grafana" "$monitoring_ns" "svc/kps-grafana" "3000" "80" "0.0.0.0" "TCP" "Grafana" } check_monitoring_status() { local monitoring_ns="monitoring" local rc=0 # Check that the Helm releases exist and are deployed local helm_status helm_status=$(helm status kps -n "$monitoring_ns" -o json 2>/dev/null | jq -r '.info.status' 2>/dev/null || true) if [[ -z "$helm_status" || "$helm_status" == "null" ]]; then echo "[FAIL] kube-prometheus-stack Helm release not found in namespace '$monitoring_ns'" return 1 fi if [[ "$helm_status" != "deployed" ]]; then echo "[FAIL] kube-prometheus-stack Helm release status: $helm_status" rc=1 else echo "[OK] kube-prometheus-stack Helm release is deployed" fi local separate_grafana separate_grafana="${MONITORING_SEPARATE_GRAFANA:-false}" case "$separate_grafana" in 1|true|TRUE|yes|YES|on|ON) separate_grafana="true" ;; *) separate_grafana="false" ;; esac if [[ "$separate_grafana" == "true" ]]; then local grafana_helm_status grafana_helm_status=$(helm status "$GRAFANA_RELEASE" -n "$monitoring_ns" -o json 2>/dev/null | jq -r '.info.status' 2>/dev/null || true) if [[ -z "$grafana_helm_status" || "$grafana_helm_status" == "null" ]]; then echo "[FAIL] Grafana Helm release '$GRAFANA_RELEASE' not found in namespace '$monitoring_ns'" rc=1 elif [[ "$grafana_helm_status" != "deployed" ]]; then echo "[FAIL] Grafana Helm release '$GRAFANA_RELEASE' status: $grafana_helm_status" rc=1 else echo "[OK] Grafana Helm release '$GRAFANA_RELEASE' is deployed" fi else echo "[OK] Separate Grafana Helm release is disabled (Grafana expected via kube-prometheus-stack)" fi # Check key deployments local deps=("kps-kube-prometheus-stack-operator" "kps-kube-state-metrics" "kps-grafana") for dep in "${deps[@]}"; do local avail avail=$(kubectl -n "$monitoring_ns" get deploy "$dep" -o jsonpath='{.status.availableReplicas}' 2>/dev/null || true) if [[ -z "$avail" || "$avail" == "0" ]]; then echo "[FAIL] deployment/$dep not available in namespace '$monitoring_ns'" rc=1 else echo "[OK] deployment/$dep available (${avail} replicas)" fi done # Check Prometheus StatefulSet local prom_ready prom_ready=$(kubectl -n "$monitoring_ns" get statefulset prometheus-kps-kube-prometheus-stack-prometheus -o jsonpath='{.status.readyReplicas}' 2>/dev/null || true) if [[ -z "$prom_ready" || "$prom_ready" == "0" ]]; then echo "[FAIL] statefulset/prometheus-kps-kube-prometheus-stack-prometheus not ready" rc=1 else echo "[OK] Prometheus StatefulSet ready (${prom_ready} replicas)" fi # Check Alertmanager StatefulSet local am_ready am_ready=$(kubectl -n "$monitoring_ns" get statefulset alertmanager-kps-kube-prometheus-stack-alertmanager -o jsonpath='{.status.readyReplicas}' 2>/dev/null || true) if [[ -z "$am_ready" || "$am_ready" == "0" ]]; then echo "[FAIL] statefulset/alertmanager not ready" rc=1 else echo "[OK] Alertmanager StatefulSet ready (${am_ready} replicas)" fi return $rc } case "${1:-}" in initialize) install_monitoring ;; status) check_monitoring_status ;; *) echo "Usage: $0 {initialize|status}" exit 1 ;; esac