prole/etc/init_monitoring.sh
chrisfu 00f0ebec07 Merge claude/crazy-bose-fec256 into main
Bringing the long-running session-feature branch back into main in one
deliberate sweep. The branch carried the cluster work that's been live for
weeks (cross-cluster CNPG metrics, Grafana w/ Google OAuth, supabase
oauth2-proxy, cluster recovery, pg.0.knoe.dev + per-engineer onboarding,
GCS-backed CNPG backups via Workload Identity, the env-contamination
guard, the Junie brief queue, the cnpg-grafana CSRF + memory-request
fixes from today), while main accumulated Junie's parallel knoe-auth
Phase 2 OIDC work (full provider surface: discovery, authorize, token,
userinfo, JWKS, RS256 signing, code exchange, session services).

Key decision: the two branches did COMPETING rebrands off the same
starting point (5ba9b63, 2026-04-27):

  - claude branch (commit b355855, earlier): org.prole.authority.* →
                                              dev.knoe.auth.*
                                              (artifact renamed to
                                              knoe-auth.jar)
  - main (commit 9daa94b, recent): org.prole.authority.* →
                                    dev.knoe.authority.*
                                    (kept "authority" artifact name)

dev.knoe.auth wins: cluster runs from this name, the Maven artifact is
already knoe-auth.jar, and the broader rename is the documented
namespace direction (per ~/.claude/projects/-Users-chrisfu-dev-knoe-db/
memory/MEMORY.md). All of main's recent Phase 2 OIDC content was ported
from authority/src/.../dev/knoe/authority/ into
authority/src/.../dev/knoe/auth/ with package declarations rewritten.

== File-level resolution summary ==

Textual conflicts (4):

  authority/pom.xml
    - Took our artifactId="auth"
    - Took our branch's removal of spring-security-kerberos-client
      (verified: Junie's Phase 2 OIDC code does not import it; the dep
      was already-dead config)

  docs/pipeline-phases.md
    - Took our branch's "Phase 1 not started" status. Main had a
      misplaced " Complete" with a knoe-auth-Phase-1 commit ref
      in the autobuild Phase 1 section — different domain.

  docs/plans/knoe-auth-round-1.md
    - Took our branch's dev.knoe.auth file table (vs main's
      dev.knoe.authority listing). Pure rename mismatch.

  supabase/helm/knoe-supabase/templates/kong/config.yaml
    - Took our branch's onboard route + plain dashboard wiring.
      Main had an oauth2proxy.enabled toggle that put oauth2-proxy as
      a Kong upstream — but the deployed architecture (commit 25f1b2e)
      has oauth2-proxy in FRONT of Kong, not behind. Main's wrapper
      reflected an architecture that was never deployed.
    - Took our branch's removal of basic-auth from dashboard route
      (queue #15 brief still tracks the matching values.yaml /
      kong/deployment.yaml cleanup).

Java tree reconciliation (44 file-pairs):

  20 dual-path source files + 2 dual-path tests
    Body-identical between main's authority/ and our branch's auth/
    after stripping package decls — main's commit 9daa94b was a pure
    rebrand. Took our branch's auth/ version for all 22.

  8 main-only source files (Phase 2 OIDC), ported into auth/:
    web/JwksController.java
    web/OidcAuthorizeController.java
    web/OidcDiscoveryController.java
    web/OidcTokenController.java
    web/OidcUserInfoController.java
    session/OidcCodeService.java
    session/OidcTokenService.java
    session/SessionService.java

  12 main-only test files, ported into auth/:
    HealthControllerTest.java
    enroll/EnrollValueTypesTest.java
    enroll/EnrollmentControllerTest.java
    enroll/TotpServiceTest.java
    kerberos/KadminClientTest.java
    kerberos/KerberosSpnegoResultTest.java
    web/LoginControllerTest.java
    admin/AdminControllerTest.java
    user/PrincipalNormalizerTest.java
    regression/IdentityRegressionTest.java
    session/OidcCodeServiceTest.java
    session/SessionServiceTest.java

  Port mechanics: read main:authority/...<file> via git show, then sed
  rewrite `package dev.knoe.authority` → `package dev.knoe.auth` and
  `import dev.knoe.authority` → `import dev.knoe.auth`. Body content
  unchanged.

  authority/src/main/java/dev/knoe/authority/ — DELETED (duplicate)
  authority/src/test/java/dev/knoe/authority/  — DELETED (duplicate)

== Verification ==

- grep -rln '<<<<<<<' across .java/.md/.yaml/.yml/.sh/.xml/.tpl: clean
- find authority/src -path '*/dev/knoe/authority*': empty (subtree gone)
- grep 'package dev.knoe.authority' across repo: clean
- bash -n install.sh deploy.sh etc/preflight_kubecontext.sh: clean
- git ls-files -u | wc -l: 0 unmerged paths
- helm lint supabase/helm/knoe-supabase: pre-existing failure on
  studioIngress.enabled undefined in values.yaml (introduced by Junie
  on main; unrelated to this merge — flagging as follow-up).

== Followups (carried into TODO ranked queue or noted here) ==

  - helm lint failure: studioIngress block in values.yaml is missing
    enable flag; templates/studio/{ingress,oauth2proxy-deployment,
    oauth2proxy-service}.yaml all reference studioIngress.enabled with
    no default. Pre-existing on main; not introduced by this merge.
  - The five Junie briefs filed on this branch are now reachable from
    main at docs/plans/junie/{02,06,07,13,15}-*.md. Junie can pick them
    up in any order.
  - knoe-auth Phase 2 OIDC source (now at dev.knoe.auth.*) is not yet
    deployed to the cluster. Deployment is its own task.
  - The branch claude/crazy-bose-fec256 stays in place (worktree at
    .claude/worktrees/crazy-bose-fec256 may have ongoing context for
    Claude Code sessions). Safe to delete once next session starts
    cleanly from main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:39:10 -07:00

1552 lines
47 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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-<volumeId>)
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 "prole.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.prole.org >/dev/null 2>&1; then
printf '%s' "merlin.prole.org"
return 0
fi
node=$(kubectl get nodes -l "prole.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 <<EOF | kubectl apply -f - >/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 <<EOF | kubectl apply -f -
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ${sc_base}
provisioner: kubernetes.io/no-provisioner
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ${sc_prom}
provisioner: kubernetes.io/no-provisioner
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ${sc_am}
provisioner: kubernetes.io/no-provisioner
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ${sc_graf}
provisioner: kubernetes.io/no-provisioner
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
EOF
log "Applying monitoring PersistentVolumes (local paths under ${base}) ..."
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolume
metadata:
name: merlin-local-iscsi-${volume_id}-prometheus
labels:
synology.storage/role: prometheus
synology.storage/volume: ${volume_id}
spec:
capacity:
storage: 30Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
storageClassName: ${sc_prom}
persistentVolumeReclaimPolicy: Retain
local:
path: ${base}/prometheus
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- ${node}
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: merlin-local-iscsi-${volume_id}-alertmanager
labels:
synology.storage/role: alertmanager
synology.storage/volume: ${volume_id}
spec:
capacity:
storage: 5Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
storageClassName: ${sc_am}
persistentVolumeReclaimPolicy: Retain
local:
path: ${base}/alertmanager
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- ${node}
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: merlin-local-iscsi-${volume_id}-grafana
labels:
synology.storage/role: grafana
synology.storage/volume: ${volume_id}
spec:
capacity:
storage: 10Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
storageClassName: ${sc_graf}
persistentVolumeReclaimPolicy: Retain
local:
path: ${base}/grafana
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- ${node}
EOF
# Default to the local-PV storageClass on k3s unless explicitly overridden.
if [[ -z "${MONITORING_STORAGE_CLASS:-}" ]]; then
MONITORING_STORAGE_CLASS="${sc_base}"
export MONITORING_STORAGE_CLASS
fi
}
render_node_selector() {
local indent="$1"
cat <<EOF
${indent}nodeSelector:
${indent} prole.org/node-role: general
EOF
}
render_primary_node_selector() {
local indent="$1"
local mode
mode="${KNOE_MODE:-${DEPLOYMENT_MODE:-}}"
[[ "${mode}" == "k3s" ]] || { render_node_selector "$indent"; return 0; }
local node
node=$(resolve_monitoring_primary_node)
if [[ -n "${node:-}" ]]; then
cat <<EOF
${indent}nodeSelector:
${indent} kubernetes.io/hostname: ${node}
EOF
else
render_node_selector "$indent"
fi
}
render_tolerations() {
local indent="$1"
# On k3s, we pin monitoring workloads to the primary node for local-PV storage.
# That node is typically tainted to keep non-monitoring workloads off it.
local mode
mode="${KNOE_MODE:-${DEPLOYMENT_MODE:-}}"
local key value effect
key="${MONITORING_TAINT_KEY:-prole.org/monitoring}"
value="${MONITORING_TAINT_VALUE:-true}"
effect="${MONITORING_TAINT_EFFECT:-NoSchedule}"
if [[ "$mode" == "k3s" ]]; then
cat <<EOF
${indent}tolerations:
${indent} - key: "${key}"
${indent} operator: "Equal"
${indent} value: "${value}"
${indent} effect: "${effect}"
EOF
fi
}
render_node_affinity() {
local indent="$1"
cat <<EOF
${indent}affinity:
${indent} nodeAffinity:
${indent} requiredDuringSchedulingIgnoredDuringExecution:
${indent} nodeSelectorTerms:
${indent} - matchExpressions:
${indent} - key: prole.org/node-role
${indent} operator: In
${indent} values:
${indent} - general
EOF
}
render_primary_node_affinity() {
local indent="$1"
local mode
mode="${KNOE_MODE:-${DEPLOYMENT_MODE:-}}"
[[ "${mode}" == "k3s" ]] || { render_node_affinity "$indent"; return 0; }
local node
node=$(resolve_monitoring_primary_node)
if [[ -n "${node:-}" ]]; then
cat <<EOF
${indent}affinity:
${indent} nodeAffinity:
${indent} requiredDuringSchedulingIgnoredDuringExecution:
${indent} nodeSelectorTerms:
${indent} - matchExpressions:
${indent} - key: kubernetes.io/hostname
${indent} operator: In
${indent} values:
${indent} - ${node}
EOF
else
render_node_affinity "$indent"
fi
}
render_prometheus_storage() {
local indent="$1"
local size="$2"
if [[ -n "${MONITORING_STORAGE_CLASS_SELECTED:-}" ]]; then
local sc
sc=$(monitoring_storage_class_for_role "prometheus")
cat <<EOF
${indent}storageSpec:
${indent} volumeClaimTemplate:
${indent} spec:
${indent} storageClassName: ${sc}
${indent} accessModes: ["ReadWriteOnce"]
${indent} resources:
${indent} requests:
${indent} storage: ${size}
EOF
fi
}
render_alertmanager_storage() {
local indent="$1"
local size="$2"
if [[ -n "${MONITORING_STORAGE_CLASS_SELECTED:-}" ]]; then
local sc
sc=$(monitoring_storage_class_for_role "alertmanager")
cat <<EOF
${indent}storage:
${indent} volumeClaimTemplate:
${indent} spec:
${indent} storageClassName: ${sc}
${indent} accessModes: ["ReadWriteOnce"]
${indent} resources:
${indent} requests:
${indent} storage: ${size}
EOF
fi
}
render_grafana_persistence() {
local indent="$1"
local size="$2"
if [[ -n "${MONITORING_STORAGE_CLASS_SELECTED:-}" ]]; then
local sc
sc=$(monitoring_storage_class_for_role "grafana")
cat <<EOF
${indent}persistence:
${indent} enabled: true
${indent} type: sts
${indent} storageClassName: ${sc}
${indent} accessModes: ["ReadWriteOnce"]
${indent} size: ${size}
EOF
else
cat <<EOF
${indent}persistence:
${indent} enabled: false
EOF
fi
}
render_grafana_external_url() {
local indent="$1"
local host="${SERVICE_HOSTNAME:-}"
host="${host//$'\n'/}"
host="${host//$'\r'/}"
if [[ -z "$host" ]]; then
return 0
fi
local sso_enabled="${PROLE_GRAFANA_SSO_ENABLED:-0}"
local sso_block=""
case "$sso_enabled" in
1|true|TRUE|True|yes|YES|on|ON)
sso_block=$(cat <<EOF
${indent} auth.proxy:
${indent} enabled: true
${indent} header_name: X-WEBAUTH-USER
${indent} header_property: username
${indent} auto_sign_up: true
${indent} auth:
${indent} disable_login_form: true
${indent} auth.anonymous:
${indent} enabled: false
EOF
)
;;
esac
local google_client_id="${GRAFANA_GOOGLE_CLIENT_ID:-}"
local google_client_secret="${GRAFANA_GOOGLE_CLIENT_SECRET:-}"
local google_block=""
if [[ -n "$google_client_id" && -n "$google_client_secret" ]]; then
google_block=$(cat <<EOF
${indent} auth.google:
${indent} enabled: true
${indent} client_id: "${google_client_id}"
${indent} client_secret: "${google_client_secret}"
${indent} scopes: openid email profile
${indent} auth_url: https://accounts.google.com/o/oauth2/v2/auth
${indent} token_url: https://accounts.google.com/o/oauth2/token
${indent} api_url: https://www.googleapis.com/oauth2/v3/userinfo
${indent} allowed_domains: knoey.com
${indent} use_pkce: true
EOF
)
fi
cat <<EOF
${indent}grafana.ini:
${indent} server:
${indent} domain: "${host}"
${indent} root_url: "https://${host}/"
${indent} serve_from_sub_path: false
${sso_block}${google_block}
EOF
}
cleanup_pending_pvcs() {
local ns="$1"
local expected_sc="$2"
local pending
pending=$(kubectl -n "$ns" get pvc -o json | jq -r '.items[] | select(.status.phase=="Pending") | "\(.metadata.name)|\(.spec.storageClassName // "")"' || true)
while IFS='|' read -r pvc_name pvc_sc; do
[[ -n "$pvc_name" ]] || continue
if [[ -n "$pvc_sc" ]] && ! storage_class_exists "$pvc_sc"; then
log "Deleting Pending PVC '$pvc_name' with missing storageClass '$pvc_sc' ..."
kubectl -n "$ns" delete pvc "$pvc_name" >/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)
# Pass the dashboard JSON through unchanged. The source JSON templates the
# datasource UID via the dashboard's own `DS_PROMETHEUS` variable (resolved
# at render time by Grafana, configurable per-user via the Datasource
# dropdown). The earlier sed-substitution to a fixed `prom_uid` baked
# `uid: prometheus` into every variable + panel definition, defeating that
# template — the dashboard then queried only the default Prometheus
# datasource regardless of what the user selected. With cross-cluster
# CNPG metrics on `cnpg-prometheus` (commit 09f2c1a), passing through
# unchanged is required for variable dropdowns + panels to follow the
# Datasource selector.
cp "$dashboard_path" "$tmp"
: "$prom_uid" # silence unused-var warning
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() {
# No-op: kube-prometheus-stack already provisions the Prometheus + Alertmanager
# datasources via its own ConfigMap (`<release>-grafana-datasource`). The
# earlier extra `knoe-grafana-datasource` ConfigMap created here was a
# duplicate of that — it registered the same `uid: prometheus`, which collided
# with the chart's datasource and caused Grafana's provisioning reload to
# error out (HTTP 500 → datasources never refreshed).
#
# Additional datasources (e.g. the cross-cluster `cnpg-prometheus` pointing
# at the DB-cluster Prometheus) are wired through the chart values at
# `monitoring/kps-values-gke.yaml` `grafana.additionalDataSources`, NOT
# through this script. Keeping this function as a no-op so call sites don't
# need to change.
return 0
}
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
# Dont 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" <<EOF
prometheus:
prometheusSpec:
$(render_primary_node_selector " ")
$(render_primary_node_affinity " ")
$(render_tolerations " ")
$(render_prometheus_storage " " "30Gi")
additionalScrapeConfigs:
- job_name: 'kubernetes-pods'
kubernetes_sd_configs:
- role: pod
relabel_configs:
- action: keep
source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
regex: true
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
action: replace
target_label: __metrics_path__
regex: (.+)
- source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
action: replace
target_label: __address__
regex: (.*?):\d+;(\d+)
replacement: \$1:\$2
- job_name: 'cnpg-metrics'
kubernetes_sd_configs:
- role: pod
relabel_configs:
- action: keep
source_labels: [__meta_kubernetes_pod_label_cnpg_io_cluster]
regex: .+
- action: keep
source_labels: [__meta_kubernetes_pod_phase]
regex: Running
- action: replace
source_labels: [__meta_kubernetes_pod_ip]
target_label: __address__
replacement: \$1:9187
- action: replace
source_labels: [__meta_kubernetes_namespace]
target_label: namespace
- action: replace
source_labels: [__meta_kubernetes_pod_name]
target_label: pod
- action: replace
source_labels: [__meta_kubernetes_pod_label_cnpg_io_cluster]
target_label: cluster
grafana:
enabled: $([[ "$separate_grafana" == "true" ]] && echo "false" || echo "true")
adminPassword: "${GRAFANA_ADMIN_PASSWORD}"
initChownData:
enabled: false
$(render_grafana_external_url " ")
service:
port: 80
targetPort: 3000
sidecar:
dashboards:
enabled: true
label: grafana_dashboard
labelValue: "1"
datasources:
enabled: true
label: grafana_datasource
labelValue: "1"
$(render_primary_node_selector " ")
$(render_primary_node_affinity " ")
$(render_tolerations " ")
$(render_grafana_persistence " " "10Gi")
alertmanager:
alertmanagerSpec:
$(render_primary_node_selector " ")
$(render_primary_node_affinity " ")
$(render_tolerations " ")
$(render_alertmanager_storage " " "5Gi")
prometheusOperator:
$(render_node_selector " ")
$(render_node_affinity " ")
$(render_tolerations " ")
kube-state-metrics:
$(render_node_selector " ")
$(render_node_affinity " ")
$(render_tolerations " ")
prometheus-node-exporter:
$(render_node_selector " ")
$(render_node_affinity " ")
$(render_tolerations " ")
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: NotIn
values:
- pi.prole.org
EOF
local monitoring_helm_server_side
monitoring_helm_server_side="${MONITORING_HELM_SERVER_SIDE:-}"
if [[ -z "$monitoring_helm_server_side" ]]; then
if [[ "$mode" == "k3s" ]]; then
monitoring_helm_server_side="false"
else
monitoring_helm_server_side="true"
fi
fi
local helm_apply_flags=()
case "$monitoring_helm_server_side" in
1|true|TRUE|yes|YES|on|ON)
helm_apply_flags=(--force-conflicts --server-side=true)
;;
esac
helm_upgrade_with_retry \
"kps" \
"$monitoring_ns" \
"prometheus-community/kube-prometheus-stack" \
"${helm_apply_flags[@]}" \
-f "$values_file"
rm -f "$values_file"
wait_for_monitoring_resources "$monitoring_ns" || true
section "Applying Prometheus-related additional resources"
log "Applying myrddin-node-exporter resources in namespace '$monitoring_ns'..."
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Service
metadata:
name: myrddin-node-exporter
namespace: $monitoring_ns
labels:
app: myrddin-node-exporter
spec:
ports:
- name: metrics
port: 9100
targetPort: 9100
---
apiVersion: v1
kind: Endpoints
metadata:
name: myrddin-node-exporter
namespace: $monitoring_ns
subsets:
- addresses:
- ip: 10.0.0.203 # myrddin LAN IP
ports:
- name: metrics
port: 9100
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: myrddin-node-exporter
namespace: $monitoring_ns
spec:
selector:
matchLabels:
app: myrddin-node-exporter
endpoints:
- port: metrics
interval: 15s
EOF
log "Applying CNPG prometheus rules in namespace '$monitoring_ns'..."
kubectl apply --namespace "$monitoring_ns" -f https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/main/docs/src/samples/monitoring/prometheusrule.yaml
if [[ "$separate_grafana" == "true" ]]; then
section "Installing Grafana (after Prometheus stack is ready)"
cleanup_legacy_grafana_release
cleanup_grafana_rbac_conflicts
helm repo add grafana https://grafana.github.io/helm-charts || true
helm repo update grafana || true
resolve_grafana_password
if [[ -z "${GRAFANA_ADMIN_PASSWORD:-}" ]]; then
GRAFANA_ADMIN_PASSWORD="admin" # Fallback
fi
# Belt-and-suspenders: ensure no newline survives into the Helm values YAML.
GRAFANA_ADMIN_PASSWORD="${GRAFANA_ADMIN_PASSWORD//$'\n'/}"
GRAFANA_ADMIN_PASSWORD="${GRAFANA_ADMIN_PASSWORD//$'\r'/}"
local grafana_values
grafana_values=$(mktemp)
cat > "$grafana_values" <<EOF
fullnameOverride: kps-grafana
adminPassword: "${GRAFANA_ADMIN_PASSWORD}"
$(render_grafana_external_url "")
service:
port: 80
targetPort: 3000
sidecar:
dashboards:
enabled: true
label: grafana_dashboard
labelValue: "1"
datasources:
enabled: true
label: grafana_datasource
labelValue: "1"
$(render_primary_node_selector "")
$(render_primary_node_affinity "")
$(render_tolerations "")
$(render_grafana_persistence "" "10Gi")
EOF
MONITORING_RESET_SCOPE=release \
helm_upgrade_with_retry \
"$GRAFANA_RELEASE" \
"$monitoring_ns" \
"grafana/grafana" \
-f "$grafana_values"
rm -f "$grafana_values"
else
log "Skipping separate Grafana Helm release; Grafana is managed by kube-prometheus-stack (kps)."
fi
# Restart Grafana deployment so it picks up the (possibly updated) admin password.
# Helm upgrade updates the secret but a running pod won't re-read it without a restart.
log "Applying Grafana Prometheus datasource provisioning..."
apply_grafana_datasource "$monitoring_ns"
log "Restarting Grafana deployment to apply admin credentials..."
kubectl rollout restart deployment/kps-grafana -n "$monitoring_ns" 2>/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