diff --git a/codebundles/k8s-openobserve-healthcheck/.runwhen/generation-rules/k8s-openobserve-healthcheck.yaml b/codebundles/k8s-openobserve-healthcheck/.runwhen/generation-rules/k8s-openobserve-healthcheck.yaml new file mode 100644 index 000000000..10b10ad23 --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/.runwhen/generation-rules/k8s-openobserve-healthcheck.yaml @@ -0,0 +1,23 @@ +apiVersion: runwhen.com/v1 +kind: GenerationRules +spec: + generationRules: + - resourceTypes: + - deployment + - statefulset + matchRules: + - type: pattern + pattern: "openobserve" + properties: [name] + mode: substring + slxs: + - baseName: k8s-openobserve-hc + shortenedBaseName: openobs-hc + qualifiers: ["resource", "namespace", "cluster"] + baseTemplateName: k8s-openobserve-healthcheck + levelOfDetail: basic + outputItems: + - type: slx + - type: sli + - type: runbook + templateName: k8s-openobserve-healthcheck-taskset.yaml \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/.runwhen/templates/k8s-openobserve-healthcheck-sli.yaml b/codebundles/k8s-openobserve-healthcheck/.runwhen/templates/k8s-openobserve-healthcheck-sli.yaml new file mode 100644 index 000000000..bcd8b8d59 --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/.runwhen/templates/k8s-openobserve-healthcheck-sli.yaml @@ -0,0 +1,63 @@ +apiVersion: runwhen.com/v1 +kind: ServiceLevelIndicator +metadata: + name: {{slx_name}} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} + runwhen.com/sli: "true" +spec: + displayUnitsLong: OK + displayUnitsShort: ok + locations: + - {{default_location}} + description: Aggregates OpenObserve API reachability, workload replica readiness, and storage health for {{match_resource.resource.metadata.name}}. + codeBundle: + {% if repo_url %} + repoUrl: {{repo_url}} + {% else %} + repoUrl: https://github.com/runwhen-contrib/rw-cli-codecollection.git + {% endif %} + {% if ref %} + ref: {{ref}} + {% else %} + ref: main + {% endif %} + pathToRobot: codebundles/k8s-openobserve-healthcheck/sli.robot + intervalStrategy: intermezzo + intervalSeconds: 300 + configProvided: + - name: CONTEXT + value: "{{context}}" + - name: NAMESPACE + value: "{{match_resource.resource.metadata.namespace}}" + - name: OBJECT_NAME + value: "{{match_resource.resource.metadata.name}}" + - name: KUBERNETES_DISTRIBUTION_BINARY + value: "{{custom.kubernetes_distribution_binary | default('kubectl')}}" + - name: OPENOBSERVE_SERVICE_NAME + value: "" + - name: OPENOBSERVE_HTTP_PORT + value: "5080" + - name: OPENOBSERVE_LOCAL_PORT + value: "5080" + - name: OPENOBSERVE_ORGANIZATION + value: "default" + - name: OPENOBSERVE_BASE_URL + value: "" + - name: OPENOBSERVE_API_TIMEOUT + value: "25" + - name: STORAGE_S3_FAILURE_THRESHOLD + value: "10" + secretsProvided: + {% if wb_version %} + {% include "kubernetes-auth.yaml" ignore missing %} + {% else %} + - name: kubeconfig + workspaceKey: {{custom.kubeconfig_secret_name | default("kubeconfig")}} + {% endif %} + alertConfig: + tasks: + persona: eager-edgar + sessionTTL: 10m \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/.runwhen/templates/k8s-openobserve-healthcheck-slx.yaml b/codebundles/k8s-openobserve-healthcheck/.runwhen/templates/k8s-openobserve-healthcheck-slx.yaml new file mode 100644 index 000000000..9fde6b79c --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/.runwhen/templates/k8s-openobserve-healthcheck-slx.yaml @@ -0,0 +1,31 @@ +apiVersion: runwhen.com/v1 +kind: ServiceLevelX +metadata: + name: {{slx_name}} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + imageURL: https://storage.googleapis.com/runwhen-nonprod-shared-images/icons/kubernetes/resources/labeled/ns.svg + alias: OpenObserve Health Check for {{match_resource.resource.metadata.name}} + asMeasuredBy: OpenObserve workload / Pod / API health and storage metrics in Kubernetes. + configProvided: + - name: OBJECT_NAME + value: {{match_resource.resource.metadata.name}} + owners: + - {{workspace.owner_email}} + statement: The OpenObserve deployment in the namespace should have ready replicas, stable Pods, a reachable HTTP API, and healthy storage/ingestion/cluster state. + additionalContext: + {% include "kubernetes-hierarchy.yaml" ignore missing %} + qualified_name: "{{ match_resource.qualified_name }}" + tags: + {% include "kubernetes-tags.yaml" ignore missing %} + - name: cloud + value: kubernetes + - name: service + value: openobserve + - name: scope + value: project + - name: access + value: read-only \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/.runwhen/templates/k8s-openobserve-healthcheck-taskset.yaml b/codebundles/k8s-openobserve-healthcheck/.runwhen/templates/k8s-openobserve-healthcheck-taskset.yaml new file mode 100644 index 000000000..f4cc44d7c --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/.runwhen/templates/k8s-openobserve-healthcheck-taskset.yaml @@ -0,0 +1,73 @@ +apiVersion: runwhen.com/v1 +kind: Runbook +metadata: + name: {{slx_name}} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + location: {{default_location}} + description: OpenObserve workload, Pod, API, storage, cluster, ingestion queue, and PVC health checks for {{match_resource.resource.metadata.name}} in namespace {{match_resource.resource.metadata.namespace}}. + codeBundle: + {% if repo_url %} + repoUrl: {{repo_url}} + {% else %} + repoUrl: https://github.com/runwhen-contrib/rw-cli-codecollection.git + {% endif %} + {% if ref %} + ref: {{ref}} + {% else %} + ref: main + {% endif %} + pathToRobot: codebundles/k8s-openobserve-healthcheck/runbook.robot + configProvided: + - name: CONTEXT + value: "{{context}}" + - name: NAMESPACE + value: "{{match_resource.resource.metadata.namespace}}" + - name: OBJECT_NAME + value: "{{match_resource.resource.metadata.name}}" + - name: KUBERNETES_DISTRIBUTION_BINARY + value: "{{custom.kubernetes_distribution_binary | default('kubectl')}}" + - name: OPENOBSERVE_SERVICE_NAME + value: "" + - name: OPENOBSERVE_HTTP_PORT + value: "5080" + - name: OPENOBSERVE_LOCAL_PORT + value: "5080" + - name: OPENOBSERVE_ORGANIZATION + value: "default" + - name: OPENOBSERVE_BASE_URL + value: "" + - name: OPENOBSERVE_API_TIMEOUT + value: "25" + - name: OPENOBSERVE_PF_WAIT_SECS + value: "15" + - name: OPENOBSERVE_CREDENTIAL_SECRET_NAME + value: "{{custom.openobserve_credential_secret_name | default('')}}" + - name: OPENOBSERVE_CREDENTIAL_USER_KEY + value: "" + - name: OPENOBSERVE_CREDENTIAL_PASSWORD_KEY + value: "" + - name: OPENOBSERVE_ALLOW_EXEC + value: "false" + - name: OPENOBSERVE_POD_RESTART_THRESHOLD + value: "5" + - name: STORAGE_S3_FAILURE_THRESHOLD + value: "10" + - name: INGESTION_QUEUE_KEY_THRESHOLD + value: "100000" + - name: CLUSTER_PENDING_JOBS_THRESHOLD + value: "100" + - name: PVC_USAGE_THRESHOLD_PERCENT + value: "80" + secretsProvided: + {% if wb_version %} + {% include "kubernetes-auth.yaml" ignore missing %} + {% else %} + - name: kubeconfig + workspaceKey: {{custom.kubeconfig_secret_name | default("kubeconfig")}} + - name: openobserve_credentials + workspaceKey: {{custom.openobserve_credentials_secret_name | default("openobserve_credentials")}} + {% endif %} \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/.test/README.md b/codebundles/k8s-openobserve-healthcheck/.test/README.md new file mode 100644 index 000000000..2700fe06f --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/.test/README.md @@ -0,0 +1,48 @@ +# k8s-openobserve-healthcheck - Tests + +## Overview + +This `.test/` directory provides infrastructure and Taskfile automation used to +validate the `k8s-openobserve-healthcheck` CodeBundle against a Kubernetes +cluster with RunWhen Local. + +## Prerequisites + +- A running Kubernetes cluster (local `kind`/`minikube`/k3s or a real cluster). +- `kubectl` configured with a kubeconfig file. +- Docker (to run the `runwhen-local` container). +- `task`, `yq`, `curl`, `ajv` on PATH. + +## Quick start + +1. Point `kubectl` at your test cluster. +2. Drop a kubeconfig file named `kubeconfig.secret` in this directory + (used by RunWhen Local discovery). +3. `task build-infra` - creates the test namespace and OpenObserve resources. +4. `task default` - runs discovery and generates SLX/SLI/runbook configs under + `output/workspaces/`. +5. `task clean` - removes the test objects and discovery output. + +## Test resources + +The `kubernetes/manifest.yaml` creates a namespace `test-openobserve-health` +containing: + +- a Secret `openobserve-credentials` with `ROOT_USER_EMAIL` / + `ROOT_USER_PASSWORD` (matching Helm defaults) +- a Service `openobserve` exposing port `5080` +- a Deployment `openobserve` (name matches the `openobserve` substring used by + the generation rule) +- a PVC `openobserve-data` + +The generation rule matches the Deployment by name, so discovery should produce +one SLX named after the workload `openobserve` in `test-openobserve-health`. + +## Scenarios + +The deployment declares `ZO_LOCAL_MODE=true` and a bound PVC. For failure +scenarios you can edit the manifest before applying: + +- `replicas: 2` with a broken image -> replica/pod health issues. +- Unbind the PVC (`persistentVolumeReclaimPolicy` / delete the PV) -> PVC issues. +- Point the Service at a bogus port -> API health issues. \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/.test/Taskfile.yaml b/codebundles/k8s-openobserve-healthcheck/.test/Taskfile.yaml new file mode 100644 index 000000000..61a1bb004 --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/.test/Taskfile.yaml @@ -0,0 +1,173 @@ +version: "3" + +includes: + shared: + taskfile: ../../.test-tasks/Taskfile.yaml + dir: . + + +tasks: + default: + desc: "Run/refresh config and RunWhen Local discovery" + cmds: + - task: check-unpushed-commits + - task: generate-rwl-config + - task: run-rwl-discovery + + clean: + desc: "Run cleanup tasks" + cmds: + - task: remove-kubernetes-objects + - task: delete-slxs + - task: clean-rwl-discovery + + build-infra: + desc: "Build test infrastructure" + cmds: + - task: create-kubernetes-objects + + create-kubernetes-objects: + desc: "Apply manifests from kubernetes directory using kubectl" + cmds: + - kubectl apply -f kubernetes/ + silent: true + + remove-kubernetes-objects: + desc: "Delete kubernetes objects" + cmds: + - kubectl delete -f kubernetes/ --ignore-not-found=true + silent: true + + check-unpushed-commits: + desc: Check if outstanding commits or file updates need to be pushed before testing. + vars: + BASE_DIR: "../" + cmds: + - | + echo "Checking for uncommitted changes in $BASE_DIR and $BASE_DIR.runwhen, excluding '.test'..." + UNCOMMITTED_FILES=$(git diff --name-only HEAD | grep -E "^${BASE_DIR}(\.runwhen|[^/]+)" | grep -v "/\.test/" || true) + if [ -n "$UNCOMMITTED_FILES" ]; then + echo "Uncommitted changes found:" + echo "$UNCOMMITTED_FILES" + exit 1 + fi + echo "No uncommitted changes in specified directories." + - | + git fetch origin + UNPUSHED_FILES=$(git diff --name-only origin/$(git rev-parse --abbrev-ref HEAD) HEAD | grep -E "^${BASE_DIR}(\.runwhen|[^/]+)" | grep -v "/\.test/" || true) + if [ -n "$UNPUSHED_FILES" ]; then + echo "Unpushed commits found:" + echo "$UNPUSHED_FILES" + exit 1 + fi + echo "No unpushed commits in specified directories." + silent: true + + generate-rwl-config: + desc: "Generate RunWhen Local configuration (workspaceInfo.yaml)" + env: + RW_WORKSPACE: '{{.RW_WORKSPACE | default "my-workspace"}}' + cmds: + - | + repo_url=$(git config --get remote.origin.url) + branch_name=$(git rev-parse --abbrev-ref HEAD) + codebundle=$(basename "$(dirname "$PWD")") + namespace=$(yq e 'select(.kind == "Namespace") | .metadata.name' kubernetes/manifest.yaml -N) + if [ -z "$namespace" ]; then + echo "Could not determine namespace from kubernetes/manifest.yaml" + exit 1 + fi + + cat < workspaceInfo.yaml + workspaceName: "$RW_WORKSPACE" + workspaceOwnerEmail: authors@runwhen.com + defaultLocation: location-01 + defaultLOD: none + cloudConfig: + kubernetes: + kubeconfigFile: /shared/kubeconfig.secret + namespaceLODs: + $namespace: detailed + namespaces: + - $namespace + codeCollections: + - repoURL: "$repo_url" + branch: "$branch_name" + codeBundles: ["$codebundle"] + custom: + kubeconfig_secret_name: "kubeconfig" + kubernetes_distribution_binary: kubectl + openobserve_credentials_secret_name: "openobserve_credentials" + EOF + echo "Generated workspaceInfo.yaml (namespace=$namespace, codebundle=$codebundle)" + silent: true + + run-rwl-discovery: + desc: "Run RunWhen Local Discovery on test infrastructure" + cmds: + - | + CONTAINER_NAME="RunWhenLocal" + + if [ ! -f "kubeconfig.secret" ]; then + echo "Missing kubeconfig.secret in $(pwd)." + echo "Drop a kubeconfig file named 'kubeconfig.secret' into this directory (pointing at the test cluster) and re-run." + exit 1 + fi + + if docker ps -q --filter "name=$CONTAINER_NAME" | grep -q .; then + echo "Stopping and removing existing container $CONTAINER_NAME..." + docker stop $CONTAINER_NAME && docker rm $CONTAINER_NAME + elif docker ps -a -q --filter "name=$CONTAINER_NAME" | grep -q .; then + echo "Removing existing stopped container $CONTAINER_NAME..." + docker rm $CONTAINER_NAME + else + echo "No existing container named $CONTAINER_NAME found." + fi + + echo "Cleaning up output directory..." + sudo rm -rf output || { echo "Failed to remove output directory"; exit 1; } + mkdir -p output && chmod 777 output || { echo "Failed to set permissions on output directory"; exit 1; } + + echo "Starting new container $CONTAINER_NAME..." + docker run --name $CONTAINER_NAME -p 8081:8081 -v "$(pwd)":/shared -d ghcr.io/runwhen-contrib/runwhen-local:latest || { + echo "Failed to start container"; exit 1; + } + + echo "Running workspace builder script in container..." + docker exec -w /workspace-builder $CONTAINER_NAME ./run.sh $1 --verbose || { + echo "Error executing workspace builder script in container"; exit 1; + } + + echo "Review generated config files under output/workspaces/" + silent: true + + validate-generation-rules: + desc: "Validate YAML files in .runwhen/generation-rules" + cmds: + - | + for cmd in curl yq ajv; do + command -v $cmd >/dev/null || { echo "Missing $cmd"; exit 1; } + done + temp_dir=$(mktemp -d) + curl -s -o "$temp_dir/generation-rule-schema.json" \ + https://raw.githubusercontent.com/runwhen-contrib/runwhen-local/refs/heads/main/src/generation-rule-schema.json + for yaml_file in ../.runwhen/generation-rules/*.yaml; do + json_file="$temp_dir/$(basename "${yaml_file%.*}.json")" + yq -o=json "$yaml_file" > "$json_file" + ajv validate -s "$temp_dir/generation-rule-schema.json" -d "$json_file" --spec=draft2020 --strict=false + done + rm -rf "$temp_dir" + silent: true + + delete-slxs: + desc: "Delete SLX objects via V4 short-name endpoint" + cmds: + - task: shared:delete-slxs + + clean-rwl-discovery: + desc: "Clean RunWhen Local discovery output" + cmds: + - | + sudo rm -rf output || rm -rf output + rm -f workspaceInfo.yaml + silent: true \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/.test/kubernetes/manifest.yaml b/codebundles/k8s-openobserve-healthcheck/.test/kubernetes/manifest.yaml new file mode 100644 index 000000000..80c14c8e8 --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/.test/kubernetes/manifest.yaml @@ -0,0 +1,92 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: test-openobserve-health + +--- +apiVersion: v1 +kind: Secret +metadata: + name: openobserve-credentials + namespace: test-openobserve-health +type: Opaque +data: + ROOT_USER_EMAIL: cm9vdEBleGFtcGxlLmNvbQ== + ROOT_USER_PASSWORD: Q29tcGxleHBhc3MjMTIz + +--- +apiVersion: v1 +kind: Service +metadata: + name: openobserve + namespace: test-openobserve-health + labels: + app.kubernetes.io/name: openobserve +spec: + type: ClusterIP + ports: + - port: 5080 + name: http + protocol: TCP + selector: + app.kubernetes.io/name: openobserve + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: openobserve + namespace: test-openobserve-health + labels: + app.kubernetes.io/name: openobserve +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: openobserve + template: + metadata: + labels: + app.kubernetes.io/name: openobserve + spec: + containers: + - name: openobserve + image: public.ecr.aws/zinclabs/openobserve:latest + imagePullPolicy: IfNotPresent + envFrom: + - secretRef: + name: openobserve-credentials + env: + - name: ZO_LOCAL_MODE + value: "true" + - name: ZO_DATA_DIR + value: /data + ports: + - containerPort: 5080 + name: http + protocol: TCP + readinessProbe: + httpGet: + path: /healthz + port: 5080 + initialDelaySeconds: 10 + periodSeconds: 10 + volumeMounts: + - name: data + mountPath: /data + volumes: + - name: data + emptyDir: {} + +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: openobserve-data + namespace: test-openobserve-health +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/README.md b/codebundles/k8s-openobserve-healthcheck/README.md new file mode 100644 index 000000000..f3922d197 --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/README.md @@ -0,0 +1,121 @@ +# Kubernetes OpenObserve Health Check + +This CodeBundle checks the overall health of an OpenObserve deployment running in Kubernetes: workload/pod health, API reachability, and - because storage cannot be inspected from inside the container - storage/ingestion/cluster state queried through the OpenObserve HTTP API using credentials resolved from Kubernetes secrets (with automatic port-forward to the Service). + +## Overview + +- **Resource discovery**: `kubectl` inventory of OpenObserve workloads, Services, PVCs, Secrets, and ConfigMaps in the namespace. +- **Workload health**: Deployment/StatefulSet ready vs desired replicas. +- **Pod health**: non-Running phases, CrashLoopBackOff, failed readiness, high restart counts. +- **API health**: OpenObserve HTTP health endpoint(s) via `HTTP Basic` auth, with automatic `kubectl port-forward` to the Service. +- **Storage metrics** (API): `storage_metrics` `s3_access_failures`, WAL/parquet growth, upload issues - the source of truth for storage, since storage is not observable from inside the container. +- **Cluster processing** (API): per-node pending compaction jobs via `cluster_info`. +- **Ingestion queue** (API): memory/disk queue key growth that signals ingestion backing up. +- **Storage configuration**: workload env audit of `ZO_LOCAL_MODE`, `ZO_DATA_DIR`, and `ZO_S3_*` settings. +- **PVC capacity**: bound status, capacity, and reclaim policy; never execs `df` or inspects storage from inside a container. +- **SLI**: `sli.robot` averages API reachability, replica readiness, and storage health into a 0-1 score. + +OpenObserve defaults: port **5080** (HTTP API + web UI) and **5081** (grpc). All org-scoped API endpoints use HTTP Basic auth (`Authorization: Basic base64("username:password")`). + +## Configuration + +### Required variables + +- `CONTEXT`: Kubernetes context for kubectl-backed tasks and the automatic port-forward. +- `NAMESPACE`: Namespace where OpenObserve runs. +- `OBJECT_NAME`: OpenObserve workload name (Deployment or StatefulSet) this SLX monitors; used to scope Pod and credential discovery. + +### Optional variables + +- `KUBERNETES_DISTRIBUTION_BINARY`: `kubectl` or `oc` (default: `kubectl`). +- `OPENOBSERVE_SERVICE_NAME`: Service exposing the OpenObserve HTTP port. Auto-discovered from a Service whose name contains `openobserve` when empty. +- `OPENOBSERVE_HTTP_PORT`: OpenObserve HTTP port on the Service (default: `5080`). +- `OPENOBSERVE_LOCAL_PORT`: Local port used when auto port-forwarding (default: `5080`). +- `OPENOBSERVE_ORGANIZATION`: Organization used in API paths (`/api/{org}/...`) (default: `default`). +- `OPENOBSERVE_BASE_URL`: Optional direct Base URL (e.g. `http://openobserve..svc.cluster.local:5080`). Leave empty to auto start `kubectl port-forward` to `svc/${OPENOBSERVE_SERVICE_NAME}` on `${OPENOBSERVE_HTTP_PORT}`. +- `OPENOBSERVE_API_TIMEOUT`: Timeout in seconds for API curl calls (default: `25`). +- `OPENOBSERVE_PF_WAIT_SECS`: Max seconds to wait for the automatic port-forward (default: `15`). +- `OPENOBSERVE_CREDENTIAL_SECRET_NAME`: Optional Kubernetes Secret name holding the OpenObserve credentials. When empty, infers from the workload Pod env (`ROOT_USER_EMAIL`/`ROOT_USER_PASSWORD` or `ZO_ROOT_USER_*`), following `secretKeyRef`. +- `OPENOBSERVE_CREDENTIAL_USER_KEY`: Secret data key for the API username; auto-probes `ROOT_USER_EMAIL`, `ZO_ROOT_USER_EMAIL`, `ROOT_USER_NAME` when empty. +- `OPENOBSERVE_CREDENTIAL_PASSWORD_KEY`: Secret data key for the API password; auto-probes `ROOT_USER_PASSWORD`, `ZO_ROOT_USER_PASSWORD` when empty. +- `OPENOBSERVE_ALLOW_EXEC`: Set to `true` to allow `kubectl exec -- printenv` as a last-resort credential fallback (default: `false`). +- `OPENOBSERVE_POD_RESTART_THRESHOLD`: Minimum container restart count that flags an unstable Pod (default: `5`). +- `STORAGE_S3_FAILURE_THRESHOLD`: Max `s3_access_failures` per `storage_metrics` sample before raising an issue (default: `10`). +- `INGESTION_QUEUE_KEY_THRESHOLD`: Max total (mem + disk) queue keys before flagging an ingestion backlog (default: `100000`). +- `CLUSTER_PENDING_JOBS_THRESHOLD`: Max pending compaction jobs per node before flagging cluster processing backlog (default: `100`). +- `PVC_USAGE_THRESHOLD_PERCENT`: PVC capacity warning threshold, in percent (default: `80`). + +### Secrets + +- `kubeconfig`: Standard Kubernetes kubeconfig for cluster access and the automatic port-forward. +- `openobserve_credentials` *(optional)*: RunWhen secret with the OpenObserve admin credentials. Format is a JSON object with `username` OR `email` and `password`: + ```json + {"username": "root@example.com", "password": "Complexpass#123"} + ``` + When not provided, the codebundle tries to derive credentials from a Kubernetes Secret in `NAMESPACE` or the workload Pod env. + +### Credential resolution + +The credentials are resolved **once in Suite Setup** by `resolve-openobserve-credentials.sh` and cached to `./.openobserve_credentials` (mode 600). Every API task reads the cache via the shared helper. The credential **values are never printed**; only the derivation source is logged. + +Resolution order (first hit wins): + +1. `openobserve_credentials` RunWhen secret (JSON `{username|email, password}`). +2. `OPENOBSERVE_CREDENTIAL_SECRET_NAME` (+ optional `OPENOBSERVE_CREDENTIAL_USER_KEY` / `OPENOBSERVE_CREDENTIAL_PASSWORD_KEY`) read via kubectl. +3. Workload Pod env inference: `ROOT_USER_EMAIL` / `ZO_ROOT_USER_EMAIL` / `ROOT_USER_NAME` and `ROOT_USER_PASSWORD` / `ZO_ROOT_USER_PASSWORD`, following `valueFrom.secretKeyRef` where referenced. +4. `kubectl exec -- printenv` fallback, gated by `OPENOBSERVE_ALLOW_EXEC=true`. + +### Connectivity modes + +1. **Auto port-forward (default when `OPENOBSERVE_BASE_URL` is empty)**: each API task runs `kubectl port-forward svc/ :` in the background, waits for readiness, and tears it down on exit. Works with ClusterIP-only Services as long as the runner can reach the API server with the kubeconfig. +2. **Direct URL**: set `OPENOBSERVE_BASE_URL` to an in-cluster DNS, ingress, or existing port-forward URL. No port-forward is started. + +## Auto-discovery + +The generation rule in `.runwhen/generation-rules/k8s-openobserve-healthcheck.yaml` matches a Kubernetes **Deployment or StatefulSet whose name contains `openobserve`** (also matching Helm releases named `openobserve`). One SLX is generated per matched workload in a namespace. Service, PVC, Secret, and ConfigMap discovery happens at runtime inside the namespace. + +## Tasks overview + +### List OpenObserve Resources in Namespace + +Discovers OpenObserve workloads, Services, PVCs, Secrets, and ConfigMaps and adds a topology summary to the report. Raises an issue when no OpenObserve resources are found (wrong namespace or name). + +### Check OpenObserve Workload Replica Health + +Compares ready vs desired replicas for each OpenObserve Deployment/StatefulSet; flags workloads that are not fully ready or scaled to zero. + +### Check OpenObserve Pod Stability + +Inspects OpenObserve Pods for non-Running phases, CrashLoopBackOff, failed readiness, and high restart counts to surface instability before it becomes an outage. + +### Check OpenObserve API Health + +Resolves the admin credentials and OpenObserve Base URL (auto port-forward when not provided) and GETs the health/status endpoints, flagging non-200 responses and authentication failures. + +### Check OpenObserve Storage Metrics via API + +Calls the OpenObserve `storage_metrics` API to detect S3 storage access failures, WAL/parquet file growth, and upload issues. This is the source of truth for storage health since storage is not observable from inside the container. + +### Check OpenObserve Cluster Processing Health + +Queries `cluster_info` for per-node pending compaction jobs and flags nodes with a compaction backlog above threshold (storage lifecycle work not keeping up). + +### Check OpenObserve Ingestion Queue Health + +Queries the `queues` endpoint and flags memory/disk queue key growth that signals ingestion is backing up and data may be delayed or dropped. + +### Check OpenObserve Storage Configuration + +Reads the OpenObserve workload env for storage topology (`ZO_LOCAL_MODE`, `ZO_DATA_DIR`, `ZO_S3_*`) and validates that S3 settings exist when local mode is disabled. + +### Check OpenObserve PVC Capacity + +Lists the PVCs backing OpenObserve (capacity, status, reclaim policy) and flags unbound volumes or volumes approaching capacity using kubectl-level data only. + +## Notes on API version differences + +The exact OpenObserve API endpoints and response field names have changed across releases. The bundle probes multiple candidate paths (`/api/{org}/health` then `/healthz`; `/api/{org}/cluster/info` then `/api/{org}/cluster_info`; etc.) and parses all numeric fields defensively. Missing endpoints or missing fields on newer versions are **not** raised as issues; only genuine failures (non-200 on a supported endpoint, S3 access failures, compaction backlog, queue growth, 401 auth failures) are flagged. + +## RBAC + +Required: `get`/`list` on Deployment, StatefulSet, Service, Pod, PersistentVolumeClaim, Secret, ConfigMap. Optional (default off): Pod `exec` for credential fallback via `OPENOBSERVE_ALLOW_EXEC=true`. \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/_openobserve_api_common.sh b/codebundles/k8s-openobserve-healthcheck/_openobserve_api_common.sh new file mode 100755 index 000000000..2a7722f47 --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/_openobserve_api_common.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# Shared helper for OpenObserve HTTP API calls. +# +# Provides: +# load_openobserve_credentials - loads cached credentials (if any) into +# OPENOBSERVE_USERNAME / OPENOBSERVE_PASSWORD. +# openobserve_creds_present - returns 0 when username+password are set. +# openobserve_api_get - GETs ${OPENOBSERVE_BASE_URL} with +# optional HTTP Basic auth and echoes +# "HTTP_CODE\t". +# On failure echoes "000\t". +# +# Uses OPENOBSERVE_API_TIMEOUT (default 25) to bound every curl call. +# ----------------------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/_openobserve_credentials_helper.sh" + +OPENOBSERVE_ORGANIZATION="${OPENOBSERVE_ORGANIZATION:-default}" +API_TIMEOUT="${OPENOBSERVE_API_TIMEOUT:-25}" + +load_openobserve_credentials() { + local cache="${OPENOBSERVE_CREDENTIALS_CACHE_FILE:-./.openobserve_credentials}" + if [[ -s "$cache" ]]; then + OPENOBSERVE_USERNAME=$(sed -n '1p' "$cache") + OPENOBSERVE_PASSWORD=$(sed -n '2p' "$cache") + export OPENOBSERVE_USERNAME OPENOBSERVE_PASSWORD + return 0 + fi + OPENOBSERVE_USERNAME="${OPENOBSERVE_USERNAME:-}" + OPENOBSERVE_PASSWORD="${OPENOBSERVE_PASSWORD:-}" + return 1 +} + +openobserve_creds_present() { + [[ -n "${OPENOBSERVE_USERNAME:-}" && -n "${OPENOBSERVE_PASSWORD:-}" ]] +} + +# GET ${OPENOBSERVE_BASE_URL}$1 with optional Basic auth. +# Echoes "HTTP_CODE\t". +openobserve_api_get() { + local path="$1" + local base="${OPENOBSERVE_BASE_URL:-}" + base="${base%/}" + local url="${base}${path}" + local tmpf http_code + tmpf=$(mktemp) + if openobserve_creds_present; then + http_code=$(curl -sS --max-time "$API_TIMEOUT" -u "${OPENOBSERVE_USERNAME}:${OPENOBSERVE_PASSWORD}" -o "$tmpf" -w "%{http_code}" "$url" 2>/dev/null || echo "000") + else + http_code=$(curl -sS --max-time "$API_TIMEOUT" -o "$tmpf" -w "%{http_code}" "$url" 2>/dev/null || echo "000") + fi + printf '%s\t%s' "$http_code" "$(cat "$tmpf" 2>/dev/null || true)" + rm -f "$tmpf" +} \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/_openobserve_credentials_helper.sh b/codebundles/k8s-openobserve-healthcheck/_openobserve_credentials_helper.sh new file mode 100755 index 000000000..33519676d --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/_openobserve_credentials_helper.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# Shared helper to resolve the OpenObserve admin credentials +# (OPENOBSERVE_USERNAME / OPENOBSERVE_PASSWORD) used for HTTP Basic auth. +# +# Resolution order (first hit wins): +# 0. Cached credentials in OPENOBSERVE_CREDENTIALS_CACHE_FILE +# (default: ./.openobserve_credentials). This is what Suite Setup writes +# via resolve-openobserve-credentials.sh so the downstream per-task +# scripts do not re-run kubectl. +# 1. openobserve_credentials (RW secret env var) - JSON like +# {"username": "root@example.com", "password": "..."} or {"email": ..., "password": ...}. +# 2. OPENOBSERVE_CREDENTIAL_SECRET_NAME set -> read that Secret from +# $NAMESPACE, preferring OPENOBSERVE_CREDENTIAL_USER_KEY / +# OPENOBSERVE_CREDENTIAL_PASSWORD_KEY, else probe common candidate keys. +# 3. Infer from the OpenObserve workload's Pod env vars: +# username: ROOT_USER_EMAIL, ZO_ROOT_USER_EMAIL, ROOT_USER_NAME +# password: ROOT_USER_PASSWORD, ZO_ROOT_USER_PASSWORD +# Following valueFrom.secretKeyRef where referenced. +# 4. Exec fallback: `kubectl exec -- printenv `, gated by +# OPENOBSERVE_ALLOW_EXEC=true (default false). +# +# Required for steps 2-4: CONTEXT, NAMESPACE, and kubectl on PATH. Step 3/4 +# also need a way to find the backing Pod (OPENOBSERVE_SERVICE_NAME or +# OBJECT_NAME). +# +# The credential values are never printed; only the derivation source is. +# On success the helper exports OPENOBSERVE_USERNAME and OPENOBSERVE_PASSWORD. +# ----------------------------------------------------------------------------- + +OPENOBSERVE_CREDENTIALS_CACHE_FILE="${OPENOBSERVE_CREDENTIALS_CACHE_FILE:-./.openobserve_credentials}" + +# Common Secret data keys probed when a specific key is not provided. +_OPENOBSERVE_USER_KEYS=(ROOT_USER_EMAIL ZO_ROOT_USER_EMAIL ROOT_USER_NAME username user email emailId) +_OPENOBSERVE_PASS_KEYS=(ROOT_USER_PASSWORD ZO_ROOT_USER_PASSWORD password pass) + +# Env-var names we recognize on the OpenObserve container when scanning pod spec. +_OPENOBSERVE_USER_ENV_NAMES=(ROOT_USER_EMAIL ZO_ROOT_USER_EMAIL ROOT_USER_NAME) +_OPENOBSERVE_PASS_ENV_NAMES=(ROOT_USER_PASSWORD ZO_ROOT_USER_PASSWORD) + +_openobserve_sanitize() { + local raw="$1" + raw="${raw#"${raw%%[![:space:]]*}"}" + raw="${raw%"${raw##*[![:space:]]}"}" + if [[ -z "$raw" || "$raw" == '${EMPTY}' || "$raw" == 'None' ]]; then + return 1 + fi + printf '%s' "$raw" +} + +_openobserve_read_secret_key() { + local kbin="$1" ctx="$2" ns="$3" sname="$4" skey="$5" + local raw + raw=$("$kbin" --context "$ctx" -n "$ns" get secret "$sname" -o "jsonpath={.data.${skey}}" 2>/dev/null || echo "") + if [[ -z "$raw" ]]; then + return 1 + fi + if command -v base64 >/dev/null 2>&1; then + echo "$raw" | base64 -d 2>/dev/null + else + echo "" + fi +} + +_openobserve_try_secret() { + local kbin="$1" ctx="$2" ns="$3" sname="$4" + local ukeys=() pkeys=() + if [[ -n "${OPENOBSERVE_CREDENTIAL_USER_KEY:-}" ]]; then + ukeys=("$OPENOBSERVE_CREDENTIAL_USER_KEY") + else + ukeys=("${_OPENOBSERVE_USER_KEYS[@]}") + fi + if [[ -n "${OPENOBSERVE_CREDENTIAL_PASSWORD_KEY:-}" ]]; then + pkeys=("$OPENOBSERVE_CREDENTIAL_PASSWORD_KEY") + else + pkeys=("${_OPENOBSERVE_PASS_KEYS[@]}") + fi + local ukey pkey uval pval + for ukey in "${ukeys[@]}"; do + uval=$(_openobserve_read_secret_key "$kbin" "$ctx" "$ns" "$sname" "$ukey" || true) + if [[ -n "$uval" ]]; then + for pkey in "${pkeys[@]}"; do + pval=$(_openobserve_read_secret_key "$kbin" "$ctx" "$ns" "$sname" "$pkey" || true) + if [[ -n "$pval" ]]; then + export OPENOBSERVE_USERNAME="$uval" + export OPENOBSERVE_PASSWORD="$pval" + echo "Derived OpenObserve credentials from secret ${sname} keys ${ukey}/${pkey} in namespace ${ns}." + return 0 + fi + done + echo "WARN: Secret ${sname} has username key ${ukey} but no matching password key could be read." + return 1 + fi + done + echo "WARN: Secret ${sname} did not expose a recognized username/password key pair." + return 1 +} + +# Find Pods backing a workload (OBJECT_NAME) or Service (OPENOBSERVE_SERVICE_NAME). +_openobserve_find_pods() { + local kbin="$1" ctx="$2" ns="$3" + local selector="" + local name_filter="" + + if [[ -n "${OPENOBSERVE_SERVICE_NAME:-}" ]]; then + selector=$("$kbin" --context "$ctx" -n "$ns" get svc "$OPENOBSERVE_SERVICE_NAME" -o json 2>/dev/null \ + | jq -r '.spec.selector // {} | to_entries | map("\(.key)=\(.value)") | join(",")' 2>/dev/null || true) + if [[ -z "$selector" ]]; then + name_filter="$OPENOBSERVE_SERVICE_NAME" + fi + elif [[ -n "${OBJECT_NAME:-}" ]]; then + for kind in statefulset deployment; do + selector=$("$kbin" --context "$ctx" -n "$ns" get "$kind" "$OBJECT_NAME" -o json 2>/dev/null \ + | jq -r '.spec.selector.matchLabels // {} | to_entries | map("\(.key)=\(.value)") | join(",")' 2>/dev/null || true) + if [[ -n "$selector" ]]; then + break + fi + done + if [[ -z "$selector" ]]; then + name_filter="^${OBJECT_NAME}" + fi + else + return 1 + fi + + if [[ -n "$selector" && "$selector" != "{}" ]]; then + "$kbin" --context "$ctx" -n "$ns" get pods -l "$selector" \ + -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null || true + elif [[ -n "$name_filter" ]]; then + "$kbin" --context "$ctx" -n "$ns" get pods -o json 2>/dev/null \ + | jq -r --arg re "$name_filter" '.items[].metadata.name | select(test($re))' 2>/dev/null || true + fi +} + +# Scans a pod's containers[].env[] for a matching env name and resolves the +# value via literal `.value` or `valueFrom.secretKeyRef`. +_openobserve_try_pod_env() { + local kbin="$1" ctx="$2" ns="$3" pod="$4" + local want_name="$5" out_var="$6" + if ! command -v jq >/dev/null 2>&1; then + return 1 + fi + local pod_json + pod_json=$("$kbin" --context "$ctx" -n "$ns" get pod "$pod" -o json 2>/dev/null || echo "") + [[ -z "$pod_json" ]] && return 1 + local entry + entry=$(echo "$pod_json" | jq -c --arg name "$want_name" ' + [ .spec.containers[]?.env[]? | select(.name == $name) ][0] // empty + ') + if [[ -z "$entry" || "$entry" == "null" ]]; then + return 1 + fi + local literal sref_name sref_key val + literal=$(echo "$entry" | jq -r '.value // ""') + sref_name=$(echo "$entry" | jq -r '.valueFrom.secretKeyRef.name // ""') + sref_key=$(echo "$entry" | jq -r '.valueFrom.secretKeyRef.key // ""') + if [[ -n "$literal" ]]; then + printf -v "$out_var" '%s' "$literal" + echo "Derived OpenObserve ${want_name} from pod ${pod} env (literal value)." + return 0 + fi + if [[ -n "$sref_name" && -n "$sref_key" ]]; then + val=$(_openobserve_read_secret_key "$kbin" "$ctx" "$ns" "$sref_name" "$sref_key" || true) + if [[ -n "$val" ]]; then + printf -v "$out_var" '%s' "$val" + echo "Derived OpenObserve ${want_name} from pod ${pod} env ${want_name} -> secret ${sref_name}[${sref_key}]." + return 0 + fi + echo "WARN: Pod ${pod} env ${want_name} references secret ${sref_name}[${sref_key}] but the Secret could not be read." + fi + return 1 +} + +_openobserve_try_pod_exec() { + local kbin="$1" ctx="$2" ns="$3" pod="$4" + local want_name="$5" out_var="$6" + local val + val=$("$kbin" --context "$ctx" -n "$ns" exec "$pod" -- printenv "$want_name" 2>/dev/null \ + | tr -d '\r\n' || true) + if [[ -z "$val" ]]; then + val=$("$kbin" --context "$ctx" -n "$ns" exec "$pod" -- sh -c "printf %s \"\${${want_name}:-}\"" 2>/dev/null \ + | tr -d '\r\n' || true) + fi + if [[ -n "$val" ]]; then + printf -v "$out_var" '%s' "$val" + echo "Derived OpenObserve ${want_name} by exec-ing pod ${pod}." + return 0 + fi + return 1 +} + +# Resolves a single credential (username or password) across pod-credential +# strategies. Invokes the callback with (kbin ctx ns pod env_name out_var). +_openobserve_resolve_from_pods() { + local type="$1" # "user" or "pass" + local kbin="${KUBERNETES_DISTRIBUTION_BINARY:-kubectl}" + local ctx="${CONTEXT:-}" + local ns="${NAMESPACE:-}" + local out_var="$2" + local env_names + if [[ "$type" == "user" ]]; then + env_names=("${_OPENOBSERVE_USER_ENV_NAMES[@]}") + else + env_names=("${_OPENOBSERVE_PASS_ENV_NAMES[@]}") + fi + local pods p env_name val + pods=$(_openobserve_find_pods "$kbin" "$ctx" "$ns" || true) + [[ -z "$pods" ]] && return 1 + while IFS= read -r p; do + [[ -z "$p" ]] && continue + for env_name in "${env_names[@]}"; do + if _openobserve_try_pod_env "$kbin" "$ctx" "$ns" "$p" "$env_name" "$out_var"; then + return 0 + fi + done + done <<<"$pods" + + if [[ "${OPENOBSERVE_ALLOW_EXEC:-false}" == "true" || "${OPENOBSERVE_ALLOW_EXEC:-false}" == "True" ]]; then + while IFS= read -r p; do + [[ -z "$p" ]] && continue + for env_name in "${env_names[@]}"; do + if _openobserve_try_pod_exec "$kbin" "$ctx" "$ns" "$p" "$env_name" "$out_var"; then + local tmp="" + printf -v tmp '%s' "${!out_var}" + echo "...resolved ${type} credential by exec fallback of pod ${p} env ${env_name}." + return 0 + fi + done + done <<<"$pods" + fi + return 1 +} + +resolve_openobserve_credentials() { + # Step 0: cache fast path. + if [[ -s "$OPENOBSERVE_CREDENTIALS_CACHE_FILE" ]]; then + local cuser cpass + cuser=$(sed -n '1p' "$OPENOBSERVE_CREDENTIALS_CACHE_FILE") + cpass=$(sed -n '2p' "$OPENOBSERVE_CREDENTIALS_CACHE_FILE") + if [[ -n "$cuser" && -n "$cpass" ]]; then + export OPENOBSERVE_USERNAME="$cuser" + export OPENOBSERVE_PASSWORD="$cpass" + echo "Using OpenObserve credentials from cache file ${OPENOBSERVE_CREDENTIALS_CACHE_FILE}." + return 0 + fi + fi + + # Step 1: RW.CLI secret injection (env var `openobserve_credentials`). + local sanitized + if [[ -n "${openobserve_credentials:-}" ]]; then + local creds_json="${openobserve_credentials}" + local u p + u=$(echo "$creds_json" | jq -r '.username // .email // .emailId // empty' 2>/dev/null || true) + p=$(echo "$creds_json" | jq -r '.password // empty' 2>/dev/null || true) + if [[ -n "$u" && -n "$p" ]]; then + export OPENOBSERVE_USERNAME="$u" + export OPENOBSERVE_PASSWORD="$p" + echo "Using OpenObserve credentials from imported openobserve_credentials secret." + return 0 + fi + echo "WARN: openobserve_credentials secret provided but not in expected JSON {username|email, password} format." + fi + + # If one of the values is already provided explicitly via env (e.g. from the + # taskset config), honor it. This is rarely the case for this bundle. + if [[ -n "${OPENOBSERVE_USERNAME:-}" && -n "${OPENOBSERVE_PASSWORD:-}" ]]; then + echo "Using OpenObserve credentials from environment variables." + return 0 + fi + + local kbin="${KUBERNETES_DISTRIBUTION_BINARY:-kubectl}" + local ctx="${CONTEXT:-}" + local ns="${NAMESPACE:-}" + if [[ -z "$ctx" || -z "$ns" ]] || ! command -v "$kbin" >/dev/null 2>&1; then + echo "Cannot derive OpenObserve credentials: CONTEXT/NAMESPACE unset or ${kbin} not on PATH." + export OPENOBSERVE_USERNAME="" + export OPENOBSERVE_PASSWORD="" + return 0 + fi + + # Step 2: explicit Secret name. + if [[ -n "${OPENOBSERVE_CREDENTIAL_SECRET_NAME:-}" ]]; then + echo "Attempting to read OpenObserve credentials from Secret ${OPENOBSERVE_CREDENTIAL_SECRET_NAME} in ${ns}..." + if _openobserve_try_secret "$kbin" "$ctx" "$ns" "$OPENOBSERVE_CREDENTIAL_SECRET_NAME"; then + return 0 + fi + echo "WARN: Secret ${OPENOBSERVE_CREDENTIAL_SECRET_NAME} in ${ns} did not expose a recognized username/password pair." + fi + + # Step 3: Pod env inference. + local step3_user="" step3_pass="" + if _openobserve_resolve_from_pods user step3_user; then + if _openobserve_resolve_from_pods pass step3_pass; then + export OPENOBSERVE_USERNAME="$step3_user" + export OPENOBSERVE_PASSWORD="$step3_pass" + echo "Resolved OpenObserve credentials from the workload Pod environment." + return 0 + fi + fi + echo "Pod spec/env inference did not yield a resolvable username/password pair." + + export OPENOBSERVE_USERNAME="" + export OPENOBSERVE_PASSWORD="" + echo "No OpenObserve credentials supplied and none could be auto-derived. API calls will likely return HTTP 401." + return 0 +} + +# Wrapper used by Suite Setup: resolves the credentials and persists them +# (mode 600) for per-task scripts to reuse via the cache fast path. +resolve_and_cache_openobserve_credentials() { + resolve_openobserve_credentials + if [[ -n "${OPENOBSERVE_USERNAME:-}" && -n "${OPENOBSERVE_PASSWORD:-}" ]]; then + ( umask 077; printf '%s\n%s\n' "$OPENOBSERVE_USERNAME" "$OPENOBSERVE_PASSWORD" > "$OPENOBSERVE_CREDENTIALS_CACHE_FILE" ) + echo "Cached OpenObserve credentials to ${OPENOBSERVE_CREDENTIALS_CACHE_FILE}." + else + rm -f "$OPENOBSERVE_CREDENTIALS_CACHE_FILE" 2>/dev/null || true + echo "No OpenObserve credentials resolved; cache file not written." + fi + return 0 +} \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/_portforward_helper.sh b/codebundles/k8s-openobserve-healthcheck/_portforward_helper.sh new file mode 100755 index 000000000..3a71f28bf --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/_portforward_helper.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# Shared helper to ensure OPENOBSERVE_BASE_URL is reachable. +# +# Behavior: +# - If OPENOBSERVE_BASE_URL is already set and non-empty, use it as-is. +# - Otherwise, auto-discover the OpenObserve Service (name containing +# "openobserve" in NAMESPACE) and start a kubectl port-forward to +# svc/${OPENOBSERVE_SERVICE_NAME} on ${OPENOBSERVE_HTTP_PORT}. A trap is +# registered to stop the port-forward on EXIT. +# +# Required when starting a port-forward: +# CONTEXT, NAMESPACE +# Optional: +# OPENOBSERVE_SERVICE_NAME (default: auto-discovered from the namespace) +# OPENOBSERVE_HTTP_PORT (default: 5080) +# OPENOBSERVE_LOCAL_PORT (default: pick an ephemeral free port) +# KUBERNETES_DISTRIBUTION_BINARY (default: kubectl) +# OPENOBSERVE_PF_WAIT_SECS (default: 15 - max seconds to wait for pf) +# +# Exports: +# OPENOBSERVE_BASE_URL +# OPENOBSERVE_SERVICE_NAME +# OPENOBSERVE_PF_PID (pid of the background port-forward, if started) +# ----------------------------------------------------------------------------- + +_openobserve_pick_free_port() { + python3 - <<'PY' 2>/dev/null || echo "" +import socket +s = socket.socket() +s.bind(("127.0.0.1", 0)) +print(s.getsockname()[1]) +s.close() +PY +} + +_openobserve_wait_for_port() { + local host="$1" port="$2" max="${3:-15}" + local i=0 + while (( i < max )); do + if (exec 3<>"/dev/tcp/${host}/${port}") 2>/dev/null; then + exec 3>&- 3<&- 2>/dev/null || true + return 0 + fi + sleep 1 + i=$((i+1)) + done + return 1 +} + +_openobserve_discover_service() { + # Find a Service whose name contains "openobserve", preferring one that + # exposes OPENOBSERVE_HTTP_PORT. Emits the best candidate or nothing. + local kbin="${KUBERNETES_DISTRIBUTION_BINARY:-kubectl}" + local ctx="${CONTEXT:-}" + local ns="${NAMESPACE:-}" + local http_port="${OPENOBSERVE_HTTP_PORT:-5080}" + local svc="" + svc=$("$kbin" --context "$ctx" -n "$ns" get services -o json 2>/dev/null \ + | jq -r --arg port "$http_port" ' + ([.items[] | select((.metadata.name | test("openobserve"; "i")) and ([.spec.ports[].port | tostring] | index($port)))] | sort_by(.metadata.creationTimestamp) | reverse | .[0].metadata.name) // empty + ' 2>/dev/null || true) + if [[ -z "$svc" ]]; then + svc=$("$kbin" --context "$ctx" -n "$ns" get services -o json 2>/dev/null \ + | jq -r '[.items[] | select(.metadata.name | test("openobserve"; "i"))][0].metadata.name // empty' 2>/dev/null || true) + fi + if [[ -n "$svc" ]]; then + echo "$svc" + return 0 + fi + return 1 +} + +ensure_openobserve_base_url() { + if [[ -n "${OPENOBSERVE_BASE_URL:-}" ]]; then + export OPENOBSERVE_BASE_URL + return 0 + fi + + : "${CONTEXT:?OPENOBSERVE_BASE_URL empty and CONTEXT not set - cannot start port-forward}" + : "${NAMESPACE:?OPENOBSERVE_BASE_URL empty and NAMESPACE not set - cannot start port-forward}" + + local kbin="${KUBERNETES_DISTRIBUTION_BINARY:-kubectl}" + local remote_port="${OPENOBSERVE_HTTP_PORT:-5080}" + local local_port="${OPENOBSERVE_LOCAL_PORT:-}" + local wait_secs="${OPENOBSERVE_PF_WAIT_SECS:-15}" + + if ! command -v "$kbin" >/dev/null 2>&1; then + echo "WARN: OPENOBSERVE_BASE_URL not set and ${kbin} not found on PATH; cannot establish port-forward." >&2 + return 1 + fi + + local svc="${OPENOBSERVE_SERVICE_NAME:-}" + if [[ -z "$svc" ]]; then + svc=$(_openobserve_discover_service) || true + if [[ -n "$svc" ]]; then + export OPENOBSERVE_SERVICE_NAME="$svc" + echo "Auto-discovered OpenObserve Service ${svc} in namespace ${NAMESPACE}." + else + echo "WARN: No Service whose name contains 'openobserve' found in namespace ${NAMESPACE}; cannot port-forward." >&2 + return 1 + fi + fi + + if [[ -z "$local_port" ]]; then + local_port="$(_openobserve_pick_free_port)" + if [[ -z "$local_port" ]]; then + local_port="$remote_port" + fi + fi + + echo "OPENOBSERVE_BASE_URL not provided; starting kubectl port-forward to svc/${OPENOBSERVE_SERVICE_NAME} ${local_port}:${remote_port} in ns ${NAMESPACE} (context ${CONTEXT})." + + local pf_log + pf_log="$(mktemp)" + "$kbin" --context "$CONTEXT" -n "$NAMESPACE" port-forward "svc/${OPENOBSERVE_SERVICE_NAME}" "${local_port}:${remote_port}" \ + >"$pf_log" 2>&1 & + OPENOBSERVE_PF_PID=$! + export OPENOBSERVE_PF_PID + + trap '_openobserve_cleanup_portforward' EXIT INT TERM + + if ! _openobserve_wait_for_port "127.0.0.1" "$local_port" "$wait_secs"; then + echo "ERROR: kubectl port-forward did not become ready within ${wait_secs}s." >&2 + echo "port-forward log (truncated):" >&2 + head -c 2000 "$pf_log" >&2 || true + rm -f "$pf_log" || true + _openobserve_cleanup_portforward + return 1 + fi + + rm -f "$pf_log" || true + + export OPENOBSERVE_BASE_URL="http://127.0.0.1:${local_port}" + echo "OPENOBSERVE_BASE_URL=${OPENOBSERVE_BASE_URL} (via port-forward pid ${OPENOBSERVE_PF_PID})" + return 0 +} + +_openobserve_cleanup_portforward() { + if [[ -n "${OPENOBSERVE_PF_PID:-}" ]] && kill -0 "$OPENOBSERVE_PF_PID" 2>/dev/null; then + kill "$OPENOBSERVE_PF_PID" 2>/dev/null || true + wait "$OPENOBSERVE_PF_PID" 2>/dev/null || true + fi + OPENOBSERVE_PF_PID="" +} \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/check-openobserve-api-health.sh b/codebundles/k8s-openobserve-healthcheck/check-openobserve-api-health.sh new file mode 100755 index 000000000..a9e72949f --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/check-openobserve-api-health.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -uo pipefail +# ----------------------------------------------------------------------------- +# Checks OpenObserve API health: resolves the admin credentials and the base +# URL (auto port-forward to the Service when OPENOBSERVE_BASE_URL is empty) and +# GETs the health endpoint(s). Flags non-200 responses and authentication +# failures. +# +# Health endpoint candidates (probed in order, first 200 wins): +# /api/{org}/health (OpenObserve <= v0.x era) +# /healthz (modern OpenObserve, root-level, may not need auth) +# /health (generic fallback) +# Also probes organisation status endpoints for context (not mandatory). +# +# Required env: CONTEXT, NAMESPACE +# ----------------------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/_portforward_helper.sh" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/_openobserve_api_common.sh" + +: "${CONTEXT:?Must set CONTEXT}" +: "${NAMESPACE:?Must set NAMESPACE}" + +OUTPUT_FILE="${OUTPUT_FILE:-openobserve_api_health_issues.json}" +issues_json='[]' + +ensure_openobserve_base_url || { + echo "Result: Could not establish an API base URL for OpenObserve." + issues_json=$(echo "$issues_json" | jq \ + --arg title "OpenObserve API base URL unavailable for namespace \`${NAMESPACE}\`" \ + --arg details "OPENOBSERVE_BASE_URL is empty and the automatic kubectl port-forward could not be established (Service not found or port-forward failed)." \ + --argjson severity 4 \ + --arg next_steps "Confirm the OpenObserve Service exists in NAMESPACE (name containing 'openobserve'), set OPENOBSERVE_SERVICE_NAME explicitly, or provide OPENOBSERVE_BASE_URL." \ + '. += [{ + "title": $title, + "details": $details, + "severity": $severity, + "next_steps": $next_steps + }]') + echo "$issues_json" | jq . >"$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +} + +load_openobserve_credentials || true +BASE_URL="${OPENOBSERVE_BASE_URL%/}" +ORG="${OPENOBSERVE_ORGANIZATION:-default}" +echo "OPENOBSERVE_BASE_URL=${BASE_URL} organization=${ORG}" +if openobserve_creds_present; then + echo "Credentials: resolved (source cached/file)." +else + echo "Credentials: NOT resolved - API will likely return HTTP 401." +fi + +# --- Primary health probe --------------------------------------------------- +probe_paths=("/api/${ORG}/health" "/healthz" "/health") +http_code="000" +last_path="" +last_body="" +for path in "${probe_paths[@]}"; do + out=$(openobserve_api_get "$path") + code="${out%%$'\t'*}" + body="${out#*$'\t'}" + echo "GET ${BASE_URL}${path} -> HTTP ${code}" + if [[ "$code" == "200" ]]; then + http_code="200" + last_path="$path" + last_body="$body" + break + fi + # a 401 means the endpoint exists but authentication failed; keep probing to + # find one that works but remember the auth failure. + if [[ "$code" == "401" ]]; then + http_code="401" + last_path="$path" + last_body="$body" + fi + last_code="$code" + last_path="$path" + last_body="$body" +done + +if [[ "$http_code" != "200" ]]; then + base_err="GET ${BASE_URL}${last_path} returned HTTP ${http_code}." + echo "Result: OpenObserve API health check FAILED (HTTP ${http_code})." + if [[ "$http_code" == "401" ]]; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "OpenObserve API authentication failed for \`${BASE_URL}\`" \ + --arg details "OpenObserve API returned HTTP 401 (Unauthorized). Credentials could not be validated. ${base_err}" \ + --argjson severity 3 \ + --arg next_steps "Resolve the admin credentials (openobserve_credentials secret, OPENOBSERVE_CREDENTIAL_SECRET_NAME, or ensure ROOT_USER_EMAIL/ROOT_USER_PASSWORD are readable from the workload environment) and re-run." \ + '. += [{ + "title": $title, + "details": $details, + "severity": $severity, + "next_steps": $next_steps + }]') + else + issues_json=$(echo "$issues_json" | jq \ + --arg title "OpenObserve API health endpoint unreachable for \`${BASE_URL}\`" \ + --arg details "Expected HTTP 200 from the OpenObserve health endpoint. Got HTTP ${http_code}. Response (truncated): $(echo "$last_body" | head -c 400)" \ + --argjson severity 4 \ + --arg next_steps "Confirm the process is listening on the port, OPENOBSERVE_BASE_URL/svc port-forward is correct, and the API pod is reachable from the runner." \ + '. += [{ + "title": $title, + "details": $details, + "severity": $severity, + "next_steps": $next_steps + }]') + fi +else + echo "Result: OpenObserve API health check PASSED (HTTP 200 from ${last_path})." +fi + +# --- Optional status probe (informational) ---------------------------------- +status_code="000" +for path in "/api/${ORG}/status" "/node/status"; do + out=$(openobserve_api_get "$path") + code="${out%%$'\t'*}" + echo "GET ${BASE_URL}${path} -> HTTP ${code}" + if [[ "$code" == "200" ]]; then + status_code="200" + break + fi + if [[ "$code" == "401" && "$http_code" == "200" ]]; then + echo "Note: Status endpoint required auth which could not be verified; this is informational only." + fi +done + +echo "$issues_json" | jq . >"$OUTPUT_FILE" +echo "OpenObserve API health check complete. Issues written to $OUTPUT_FILE" +cat "$OUTPUT_FILE" \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/check-openobserve-cluster-health.sh b/codebundles/k8s-openobserve-healthcheck/check-openobserve-cluster-health.sh new file mode 100755 index 000000000..9a8a3b2dd --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/check-openobserve-cluster-health.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -uo pipefail +# ----------------------------------------------------------------------------- +# Checks OpenObserve cluster processing health: queries cluster_info (per-node +# pending compaction jobs) and flags nodes with a compaction backlog above +# threshold, indicating storage lifecycle work is not keeping up. +# +# Endpoint candidates (probed in order): +# /api/{org}/cluster/info (modern OpenObserve) +# /api/{org}/cluster_info (older OpenObserve) +# +# Required env: CONTEXT, NAMESPACE +# Optional: CLUSTER_PENDING_JOBS_THRESHOLD (default 100) +# ----------------------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/_portforward_helper.sh" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/_openobserve_api_common.sh" + +: "${CONTEXT:?Must set CONTEXT}" +: "${NAMESPACE:?Must set NAMESPACE}" + +OUTPUT_FILE="${OUTPUT_FILE:-openobserve_cluster_issues.json}" +issues_json='[]' +THRESHOLD="${CLUSTER_PENDING_JOBS_THRESHOLD:-100}" +: >"$OUTPUT_FILE.tmp" + +ensure_openobserve_base_url || { + echo "Result: Could not establish an API base URL for OpenObserve." + issues_json=$(echo "$issues_json" | jq \ + --arg title "OpenObserve API base URL unavailable for namespace \`${NAMESPACE}\`" \ + --arg details "OPENOBSERVE_BASE_URL is empty and the automatic kubectl port-forward could not be established for the cluster processing check." \ + --argjson severity 3 \ + --arg next_steps "Confirm the OpenObserve Service exists in NAMESPACE, set OPENOBSERVE_SERVICE_NAME explicitly, or provide OPENOBSERVE_BASE_URL." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" | jq . >"$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +} + +load_openobserve_credentials || true +BASE_URL="${OPENOBSERVE_BASE_URL%/}" +ORG="${OPENOBSERVE_ORGANIZATION:-default}" + +echo "--- OpenObserve cluster processing health via API (${BASE_URL}/api/${ORG}/... ) ---" + +http_code="000" +payload="" +last_path="" +for path in "/api/${ORG}/cluster/info" "/api/${ORG}/cluster_info"; do + out=$(openobserve_api_get "$path") + code="${out%%$'\t'*}" + body="${out#*$'\t'}" + last_path="$path" + echo "GET ${BASE_URL}${path} -> HTTP ${code}" + if [[ "$code" == "200" ]]; then + http_code="200" + payload="$body" + break + fi + http_code="$code" + payload="$body" +done + +if [[ "$http_code" != "200" ]]; then + if [[ "$http_code" == "401" ]]; then + echo "Result: cluster_info endpoint returned HTTP 401 - authentication failed." + issues_json=$(echo "$issues_json" | jq \ + --arg title "OpenObserve API authentication failed for cluster info" \ + --arg details "GET ${BASE_URL}${last_path} returned HTTP 401. Admin credentials could not be validated." \ + --argjson severity 3 \ + --arg next_steps "Resolve the admin credentials and re-run." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + else + echo "Result: cluster_info endpoint not available in this OpenObserve version (HTTP ${http_code}); cluster compaction check skipped." + fi + echo "$issues_json" | jq . >"$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +fi + +# --- Defensive parsing ------------------------------------------------------- +# Expected shape: {"regions": {"": {"": {"pending_jobs": N}}}} +node_summary=$(echo "$payload" | jq -c '[.regions // .data.regions // {} | to_entries[] | {region: .key, nodes: .value}]' 2>/dev/null || echo "[]") +echo "Cluster info nodes:" +echo "$node_summary" | jq -c '.[]' 2>/dev/null | while IFS= read -r region; do + region_name=$(echo "$region" | jq -r '.region // "unknown"') + echo "$region" | jq -c --argjson thresh "$THRESHOLD" --arg region_name "$region_name" \ + '.nodes | to_entries[] | {node: .key, region: $region_name, pending_jobs: (.value.pending_jobs // 0)}' 2>/dev/null | while IFS= read -r over; do + node=$(echo "$over" | jq -r '.node') + reg=$(echo "$over" | jq -r '.region') + pj=$(echo "$over" | jq -r '.pending_jobs') + echo " Node ${node} (region ${reg}): pending_jobs=${pj}" + if [[ "$pj" -gt "$THRESHOLD" ]]; then + echo "Result: Node ${node} has a pending compaction backlog (${pj} jobs)." + jq -n --arg node "$node" --arg reg "$reg" --arg pj "$pj" \ + --arg title "OpenObserve node \`${node}\` has a pending compaction backlog (${pj} jobs)" \ + --arg details "cluster_info reports ${pj} pending compaction jobs on node ${node} (region ${reg}), above the threshold of ${THRESHOLD}. Compaction/parquet lifecycle work is not keeping up." \ + --argjson severity 2 \ + --arg next_steps "Check OpenObserve compaction scheduler health and CPU/IO on the node. Increase compaction resources or investigate why jobs are queuing." \ + '{title: $title, details: $details, severity: $severity, next_steps: $next_steps}' >>"$OUTPUT_FILE.tmp" + fi + done +done + +if [[ -s "$OUTPUT_FILE.tmp" ]]; then + issues_json=$(jq -s . "$OUTPUT_FILE.tmp" 2>/dev/null || echo '[]') + rm -f "$OUTPUT_FILE.tmp" +else + issues_json='[]' + rm -f "$OUTPUT_FILE.tmp" +fi + +if [[ "$(echo "$issues_json" | jq -r 'length')" -eq 0 ]]; then + echo "Result: No pending compaction backlog above threshold (${THRESHOLD})." +fi + +echo "$issues_json" | jq . >"$OUTPUT_FILE" +echo "OpenObserve cluster processing check complete. Issues written to $OUTPUT_FILE" +cat "$OUTPUT_FILE" \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/check-openobserve-pod-health.sh b/codebundles/k8s-openobserve-healthcheck/check-openobserve-pod-health.sh new file mode 100755 index 000000000..4327c67e0 --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/check-openobserve-pod-health.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -uo pipefail +# ----------------------------------------------------------------------------- +# Inspects OpenObserve Pods for instability: non-Running phases, +# CrashLoopBackOff, failed readiness, and high container restart counts. +# +# Required env: CONTEXT, NAMESPACE +# Optional: OBJECT_NAME, KUBERNETES_DISTRIBUTION_BINARY, OPENOBSERVE_POD_RESTART_THRESHOLD +# ----------------------------------------------------------------------------- + +: "${CONTEXT:?Must set CONTEXT}" +: "${NAMESPACE:?Must set NAMESPACE}" + +KUBERNETES_DISTRIBUTION_BINARY="${KUBERNETES_DISTRIBUTION_BINARY:-kubectl}" +KBIN="$KUBERNETES_DISTRIBUTION_BINARY" +RESTART_THRESHOLD="${OPENOBSERVE_POD_RESTART_THRESHOLD:-5}" +OUTPUT_FILE="${OUTPUT_FILE:-openobserve_pod_issues.json}" +issues_json='[]' +: >"$OUTPUT_FILE.tmp" + +echo "--- OpenObserve pod stability in namespace ${NAMESPACE} (context ${CONTEXT}) ---" + +pods=$("$KBIN" --context "$CONTEXT" -n "$NAMESPACE" get pods -o json 2>/dev/null \ + | jq -c '[.items[] | select(.metadata.name | test("openobserve"; "i"))]' 2>/dev/null || echo "[]") + +count=$(echo "$pods" | jq -r 'length' 2>/dev/null || echo 0) +echo "OpenObserve pods found: ${count}" + +echo "$pods" | jq -c '.[]' 2>/dev/null | while IFS= read -r pod; do + name=$(echo "$pod" | jq -r '.metadata.name') + phase=$(echo "$pod" | jq -r '.status.phase // "Unknown"') + node=$(echo "$pod" | jq -r '.spec.nodeName // "unscheduled"') + container_statuses=$(echo "$pod" | jq -c '[.status.containerStatuses[]?]' 2>/dev/null || echo "[]") + echo "Pod ${name}: phase=${phase} node=${node}" + + if [[ "$phase" != "Running" ]]; then + echo "Result: Pod ${name} is in phase ${phase}." + jq -n --arg name "$name" --arg phase "$phase" --arg node "$node" \ + --arg title "OpenObserve pod \`${name}\` is not Running (phase: ${phase})" \ + --arg details "Pod ${name} in namespace ${NAMESPACE} is in phase ${phase} (node: ${node}). It is not serving traffic." \ + --argjson severity 3 \ + --arg next_steps "Run 'kubectl describe pod ${name} -n ${NAMESPACE}' and 'kubectl get events -n ${NAMESPACE} --sort-by=.lastTimestamp' to find why the pod is stuck." \ + '{title: $title, details: $details, severity: $severity, next_steps: $next_steps}' >>"$OUTPUT_FILE.tmp" + fi + + echo "$container_statuses" | jq -c '.[]' 2>/dev/null | while IFS= read -r st; do + cname=$(echo "$st" | jq -r '.name // ""') + restarts=$(echo "$st" | jq -r '.restartCount // 0') + ready=$(echo "$st" | jq -r '.ready') + waiting_reason=$(echo "$st" | jq -r '.state.waiting.reason // ""') + terminated_reason=$(echo "$st" | jq -r '.state.terminated.reason // ""') + + if [[ "$waiting_reason" == "CrashLoopBackOff" || "$terminated_reason" == "CrashLoopBackOff" ]]; then + echo "Result: Container ${cname} in pod ${name} is in CrashLoopBackOff." + jq -n --arg name "$name" --arg cname "$cname" --arg restarts "$restarts" \ + --arg title "OpenObserve container \`${cname}\` in pod \`${name}\` is in CrashLoopBackOff" \ + --arg details "Container ${cname} in pod ${name} is crash-looping (restartCount=${restarts}). The process is failing to start repeatedly." \ + --argjson severity 3 \ + --arg next_steps "Check the container logs ('kubectl logs ${name} -c ${cname} -n ${NAMESPACE} --previous') for the startup error and validate config via the OpenObserve storage-config task." \ + '{title: $title, details: $details, severity: $severity, next_steps: $next_steps}' >>"$OUTPUT_FILE.tmp" + fi + + if [[ "$ready" == "false" && "$phase" == "Running" ]]; then + echo "Result: Container readiness probe failing for pod ${name}." + jq -n --arg name "$name" --arg cname "$cname" \ + --arg title "OpenObserve container \`${cname}\` in pod \`${name}\` is not ready" \ + --arg details "Container ${cname} (pod ${name}) is Running but its readiness probe is failing; traffic is not being routed to it." \ + --argjson severity 3 \ + --arg next_steps "Inspect readiness probe configuration and container logs. API reads may still work while workload is degraded." \ + '{title: $title, details: $details, severity: $severity, next_steps: $next_steps}' >>"$OUTPUT_FILE.tmp" + fi + + if [[ "${restarts:-0}" -ge "$RESTART_THRESHOLD" ]]; then + echo "Result: Container ${cname} in pod ${name} has high restart count (${restarts})." + jq -n --arg name "$name" --arg cname "$cname" --arg restarts "$restarts" \ + --arg title "OpenObserve container \`${cname}\` in pod \`${name}\` has ${restarts} restarts" \ + --arg details "Container ${cname} in pod ${name} has restarted ${restarts} times (threshold: ${RESTART_THRESHOLD}). Repeated restarts indicate instability." \ + --argjson severity 2 \ + --arg next_steps "Check container logs for OOMKills, config errors, or failing health checks. Consider bumping memory limits or fixing the underlying error." \ + '{title: $title, details: $details, severity: $severity, next_steps: $next_steps}' >>"$OUTPUT_FILE.tmp" + fi + done +done + +if [[ -s "$OUTPUT_FILE.tmp" ]]; then + issues_json=$(jq -s . "$OUTPUT_FILE.tmp" 2>/dev/null || echo '[]') +elif [[ -f "$OUTPUT_FILE.tmp" ]]; then + issues_json='[]' +fi +rm -f "$OUTPUT_FILE.tmp" + +echo "$issues_json" | jq . >"$OUTPUT_FILE" +echo "OpenObserve pod stability check complete. Issues written to $OUTPUT_FILE" +cat "$OUTPUT_FILE" \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/check-openobserve-pvc-capacity.sh b/codebundles/k8s-openobserve-healthcheck/check-openobserve-pvc-capacity.sh new file mode 100755 index 000000000..7f4fa6956 --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/check-openobserve-pvc-capacity.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -uo pipefail +# ----------------------------------------------------------------------------- +# Lists the PVCs backing OpenObserve (capacity, status, reclaim policy) and +# flags unbound volumes or volumes approaching capacity using kubectl-level +# data only. Never execs 'df' or inspects storage from inside a container. +# +# Required env: CONTEXT, NAMESPACE +# Optional: PVC_USAGE_THRESHOLD_PERCENT (default 80) +# ----------------------------------------------------------------------------- + +: "${CONTEXT:?Must set CONTEXT}" +: "${NAMESPACE:?Must set NAMESPACE}" +KUBERNETES_DISTRIBUTION_BINARY="${KUBERNETES_DISTRIBUTION_BINARY:-kubectl}" +KBIN="$KUBERNETES_DISTRIBUTION_BINARY" +THRESHOLD="${PVC_USAGE_THRESHOLD_PERCENT:-80}" +OUTPUT_FILE="${OUTPUT_FILE:-openobserve_pvc_issues.json}" +issues_json='[]' +: >"$OUTPUT_FILE.tmp" + +echo "--- OpenObserve PVC capacity report in namespace ${NAMESPACE} (context ${CONTEXT}) ---" + +pvcs=$("$KBIN" --context "$CONTEXT" -n "$NAMESPACE" get pvc -o json 2>/dev/null \ + | jq -c '[.items[] | select(.metadata.name | test("openobserve"; "i"))]' 2>/dev/null || echo "[]") + +count=$(echo "$pvcs" | jq -r 'length' 2>/dev/null || echo 0) +echo "OpenObserve PVCs found: ${count}" +if [[ "$count" -eq 0 ]]; then + echo "No OpenObserve-named PVCs found. If OpenObserve uses S3/object storage (ZO_LOCAL_MODE=false), no PVCs are expected." +fi + +echo "$pvcs" | jq -c '.[]' 2>/dev/null | while IFS= read -r pvc; do + name=$(echo "$pvc" | jq -r '.metadata.name') + phase=$(echo "$pvc" | jq -r '.status.phase // "Pending"') + capacity=$(echo "$pvc" | jq -r '.status.capacity.storage // ""') + requested=$(echo "$pvc" | jq -r '.spec.resources.requests.storage // ""') + access_modes=$(echo "$pvc" | jq -r '(.spec.accessModes // []) | join(",")' 2>/dev/null) + pv_name=$(echo "$pvc" | jq -r '.spec.volumeName // ""') + reclaim_policy="n/a" + if [[ -n "$pv_name" && "$pv_name" != "null" ]]; then + reclaim_policy=$("$KBIN" --context "$CONTEXT" get pv "$pv_name" -o jsonpath='{.spec.persistentVolumeReclaimPolicy}' 2>/dev/null || true) + fi + echo "PVC ${name}: phase=${phase} capacity=${capacity:-unset} requested=${requested:-unset} accessModes=${access_modes:-none} reclaimPolicy=${reclaim_policy}" + + if [[ "$phase" != "Bound" ]]; then + echo "Result: PVC ${name} is not bound (phase: ${phase})." + jq -n --arg name "$name" --arg phase "$phase" --arg requested "$requested" \ + --arg title "OpenObserve PVC \`${name}\` is not bound (phase: ${phase})" \ + --arg details "PVC ${name} in namespace ${NAMESPACE} is in phase ${phase} (requested storage: ${requested:-unset}). Unbound volumes can block pod scheduling and data writes." \ + --argjson severity 3 \ + --arg next_steps "Check StorageClass, quota, and PV availability: 'kubectl describe pvc ${name} -n ${NAMESPACE}'. Ensure a matching PersistentVolume can be provisioned." \ + '{title: $title, details: $details, severity: $severity, next_steps: $next_steps}' >>"$OUTPUT_FILE.tmp" + fi + + # Capacity-approaching signal (kubectl-level only): when the requested size + # reaches THRESHOLD percent of the provisioned capacity, flag a warning. + # True usage is measured by the API storage_metrics task. + cap_unit=$(echo "$capacity" | grep -oE '[A-Za-z]+' || true) + req_unit=$(echo "$requested" | grep -oE '[A-Za-z]+' || true) + cap_val=$(echo "$capacity" | grep -oE '^[0-9]+' || echo 0) + req_val=$(echo "$requested" | grep -oE '^[0-9]+' || echo 0) + if [[ -n "$cap_unit" && -n "$req_unit" && "$cap_unit" == "$req_unit" && "$req_val" -gt 0 && "$cap_val" -gt 0 ]]; then + usage_pct=$(( req_val * 100 / cap_val )) + echo " PVC ${name}: request/capacity ratio ~${usage_pct}% (request=${requested}, capacity=${capacity})." + if [[ "$usage_pct" -ge "$THRESHOLD" ]]; then + echo "Result: PVC ${name} requested size is ${usage_pct}% of capacity (>= ${THRESHOLD}%)." + jq -n --arg name "$name" --arg usage "$usage_pct" --arg thresh "$THRESHOLD" \ + --arg title "OpenObserve PVC \`${name}\` approaching capacity (~${usage_pct}%)" \ + --arg details "PVC ${name} has a request of ${requested} against provisioned capacity of ${capacity} (~${usage_pct}%). When data reaches this level, writes to the local store may slow or fail." \ + --argjson severity 2 \ + --arg next_steps "Review OpenObserve storage usage via the storage_metrics task, and consider moving to S3/object storage (ZO_LOCAL_MODE=false) or expanding the volume." \ + '{title: $title, details: $details, severity: $severity, next_steps: $next_steps}' >>"$OUTPUT_FILE.tmp" + fi + fi +done + +if [[ -s "$OUTPUT_FILE.tmp" ]]; then + issues_json=$(jq -s . "$OUTPUT_FILE.tmp" 2>/dev/null || echo '[]') + rm -f "$OUTPUT_FILE.tmp" +else + issues_json='[]' + rm -f "$OUTPUT_FILE.tmp" +fi + +echo "$issues_json" | jq . >"$OUTPUT_FILE" +echo "OpenObserve PVC capacity check complete. Issues written to $OUTPUT_FILE" +cat "$OUTPUT_FILE" \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/check-openobserve-queue-health.sh b/codebundles/k8s-openobserve-healthcheck/check-openobserve-queue-health.sh new file mode 100755 index 000000000..16093d752 --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/check-openobserve-queue-health.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -uo pipefail +# ----------------------------------------------------------------------------- +# Checks OpenObserve ingestion queue health: queries the queues endpoint and +# flags memory/disk queue key growth that signals ingestion backing up. +# +# Endpoint candidates (probed in order): +# /api/{org}/queues (OpenObserve <= v0.x era) +# /api/{org}/internal_metrics/queues (fallback) +# +# Raised when total (mem + disk) queue keys exceed INGESTION_QUEUE_KEY_THRESHOLD. +# +# Required env: CONTEXT, NAMESPACE +# Optional: INGESTION_QUEUE_KEY_THRESHOLD (default 100000) +# ----------------------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/_portforward_helper.sh" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/_openobserve_api_common.sh" + +: "${CONTEXT:?Must set CONTEXT}" +: "${NAMESPACE:?Must set NAMESPACE}" + +OUTPUT_FILE="${OUTPUT_FILE:-openobserve_queue_issues.json}" +issues_json='[]' +THRESHOLD="${INGESTION_QUEUE_KEY_THRESHOLD:-100000}" + +ensure_openobserve_base_url || { + echo "Result: Could not establish an API base URL for OpenObserve." + issues_json=$(echo "$issues_json" | jq \ + --arg title "OpenObserve API base URL unavailable for namespace \`${NAMESPACE}\`" \ + --arg details "OPENOBSERVE_BASE_URL is empty and the automatic kubectl port-forward could not be established for the ingestion queue check." \ + --argjson severity 3 \ + --arg next_steps "Confirm the OpenObserve Service exists in NAMESPACE, set OPENOBSERVE_SERVICE_NAME explicitly, or provide OPENOBSERVE_BASE_URL." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" | jq . >"$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +} + +load_openobserve_credentials || true +BASE_URL="${OPENOBSERVE_BASE_URL%/}" +ORG="${OPENOBSERVE_ORGANIZATION:-default}" + +echo "--- OpenObserve ingestion queue health via API (${BASE_URL}/api/${ORG}/... ) ---" + +http_code="000" +payload="" +last_path="" +for path in "/api/${ORG}/queues" "/api/${ORG}/internal_metrics/queues"; do + out=$(openobserve_api_get "$path") + code="${out%%$'\t'*}" + body="${out#*$'\t'}" + last_path="$path" + echo "GET ${BASE_URL}${path} -> HTTP ${code}" + if [[ "$code" == "200" ]]; then + http_code="200" + payload="$body" + break + fi + http_code="$code" + payload="$body" +done + +if [[ "$http_code" != "200" ]]; then + if [[ "$http_code" == "401" ]]; then + echo "Result: queues endpoint returned HTTP 401 - authentication failed." + issues_json=$(echo "$issues_json" | jq \ + --arg title "OpenObserve API authentication failed for ingestion queues" \ + --arg details "GET ${BASE_URL}${last_path} returned HTTP 401. Admin credentials could not be validated." \ + --argjson severity 3 \ + --arg next_steps "Resolve the admin credentials and re-run." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + else + echo "Result: queues endpoint not available in this OpenObserve version (HTTP ${http_code}); ingestion queue check skipped." + fi + echo "$issues_json" | jq . >"$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +fi + +# --- Defensive parsing ------------------------------------------------------- +# Expected shape variants: +# {"mem_queue": {"key": N, ...}, "disk_queue": {...}} (map of queue keys) +# {"mem_queue": N, "disk_queue": N} (flat key-count payload) +# {"queues": {"mem_queue": {...}, "disk_queue": {...}}} +mem_keys=$(echo "$payload" | jq -r '(.mem_queue // .queues.mem_queue // .data.mem_queue // {}) as $q | if ($q | type) == "number" then $q else ($q | length) end' 2>/dev/null | awk '{print int($1)}') +disk_keys=$(echo "$payload" | jq -r '(.disk_queue // .queues.disk_queue // .data.disk_queue // {}) as $q | if ($q | type) == "number" then $q else ($q | length) end' 2>/dev/null | awk '{print int($1)}') + +mem_keys="${mem_keys:-0}" +disk_keys="${disk_keys:-0}" +total=$(( mem_keys + disk_keys )) +echo "Parsed queue keys: mem=${mem_keys} disk=${disk_keys} total=${total} (threshold: ${THRESHOLD})" + +if [[ "$total" -gt "$THRESHOLD" ]]; then + echo "Result: ingestion queue total keys (${total}) exceed threshold (${THRESHOLD})." + issues_json=$(echo "$issues_json" | jq \ + --arg title "OpenObserve ingestion queue backing up (${total} keys)" \ + --arg details "The queues endpoint reports ${total} total queue keys (mem=${mem_keys}, disk=${disk_keys}), above the threshold of ${THRESHOLD}. Ingestion may be delayed or data may be dropped." \ + --argjson severity 2 \ + --arg next_steps "Investigate ingestion throughput vs processing capacity, disk write speed, and whether WAL files are growing. Consider scaling ingestion nodes or restarting queue worker tasks." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') +else + echo "Result: ingestion queues are within threshold." +fi + +echo "$issues_json" | jq . >"$OUTPUT_FILE" +echo "OpenObserve ingestion queue check complete. Issues written to $OUTPUT_FILE" +cat "$OUTPUT_FILE" \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/check-openobserve-replica-health.sh b/codebundles/k8s-openobserve-healthcheck/check-openobserve-replica-health.sh new file mode 100755 index 000000000..32ae0f884 --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/check-openobserve-replica-health.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -uo pipefail +# ----------------------------------------------------------------------------- +# Compares ready vs desired replicas for each OpenObserve Deployment/StatefulSet +# in the namespace. Flags workloads that are not fully ready or scaled to zero. +# +# Required env: CONTEXT, NAMESPACE +# Optional: KUBERNETES_DISTRIBUTION_BINARY +# ----------------------------------------------------------------------------- + +: "${CONTEXT:?Must set CONTEXT}" +: "${NAMESPACE:?Must set NAMESPACE}" + +KUBERNETES_DISTRIBUTION_BINARY="${KUBERNETES_DISTRIBUTION_BINARY:-kubectl}" +KBIN="$KUBERNETES_DISTRIBUTION_BINARY" +OUTPUT_FILE="${OUTPUT_FILE:-openobserve_replica_issues.json}" +issues_json='[]' +: >"$OUTPUT_FILE.tmp" + +echo "--- OpenObserve workload replica health in namespace ${NAMESPACE} (context ${CONTEXT}) ---" + +items=$("$KBIN" --context "$CONTEXT" -n "$NAMESPACE" get deploy,sts -o json 2>/dev/null \ + | jq -c '[.items[] | select(.metadata.name | test("openobserve"; "i"))]' 2>/dev/null || echo "[]") + +count=$(echo "$items" | jq -r 'length' 2>/dev/null || echo 0) +if [[ "$count" -eq 0 ]]; then + echo "No OpenObserve workloads found; nothing to evaluate." +fi + +echo "$items" | jq -c '.[]' 2>/dev/null | while IFS= read -r wl; do + kind=$(echo "$wl" | jq -r '.kind') + name=$(echo "$wl" | jq -r '.metadata.name') + desired=$(echo "$wl" | jq -r '((.spec.replicas // 0))') + ready=$(echo "$wl" | jq -r '((.status.readyReplicas // 0))') + updated=$(echo "$wl" | jq -r '((.status.updatedReplicas // 0))') + available=$(echo "$wl" | jq -r '((.status.availableReplicas // 0))') + echo "$kind ${name}: desired=${desired} ready=${ready} updated=${updated} available=${available}" + + # ready >= desired and desired >= 1 is fully healthy. desired == 0 is a + # scaled-to-zero workload which the spec treats as a flag (severity 3). + if [[ "$desired" -eq 0 ]]; then + if [[ "$ready" -eq 0 ]]; then + echo "Result: ${kind} ${name} is scaled to zero replicas." + jq -n --arg kind "$kind" --arg name "$name" \ + --arg title "${kind} \`${name}\` is scaled to zero replicas" \ + --arg details "${kind} ${name} has 0 desired replicas (ready=${ready}). This is often intentional but leaves the service unavailable; confirm it matches expectations." \ + --argjson severity 3 \ + --arg next_steps "If this is unexpected, scale the workload back up (kubectl scale). If intentional, acknowledge the scaled-to-zero state to avoid alert spam." \ + '{title: $title, details: $details, severity: $severity, next_steps: $next_steps}' >>"$OUTPUT_FILE.tmp" + fi + elif [[ "$ready" -lt "$desired" ]]; then + echo "Result: ${kind} ${name} has ${ready}/${desired} replicas ready." + jq -n --arg kind "$kind" --arg name "$name" --arg ready "$ready" --arg desired "$desired" --arg updated "$updated" \ + --arg title "${kind} \`${name}\` is not fully ready (${ready}/${desired})" \ + --arg details "${kind} ${name} has ${ready}/${desired} ready replicas (updated=${updated}). Not all replicas are accepting traffic." \ + --argjson severity 3 \ + --arg next_steps "Check pod status with 'kubectl get pods -n ${NAMESPACE}' and inspect events for the unready pods. Look for image pull, resource, or probe failures." \ + '{title: $title, details: $details, severity: $severity, next_steps: $next_steps}' >>"$OUTPUT_FILE.tmp" + else + echo "Result: ${kind} ${name} is fully ready (${ready}/${desired})." + fi +done + +if [[ -s "$OUTPUT_FILE.tmp" ]]; then + issues_json=$(jq -s . "$OUTPUT_FILE.tmp" 2>/dev/null || echo '[]') + rm -f "$OUTPUT_FILE.tmp" +else + issues_json='[]' + rm -f "$OUTPUT_FILE.tmp" +fi + +echo "$issues_json" | jq . >"$OUTPUT_FILE" +echo "OpenObserve replica health check complete. Issues written to $OUTPUT_FILE" +cat "$OUTPUT_FILE" \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/check-openobserve-storage-config.sh b/codebundles/k8s-openobserve-healthcheck/check-openobserve-storage-config.sh new file mode 100755 index 000000000..b8f807fad --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/check-openobserve-storage-config.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -uo pipefail +# ----------------------------------------------------------------------------- +# Reads the OpenObserve workload env/config for storage topology +# (ZO_LOCAL_MODE, ZO_DATA_DIR, ZO_S3_*) and validates that S3 settings exist +# when local mode is disabled. Purely kubectl-level; never inspects storage +# from inside a container. +# +# Required env: CONTEXT, NAMESPACE +# Optional: OBJECT_NAME, KUBERNETES_DISTRIBUTION_BINARY +# ----------------------------------------------------------------------------- + +: "${CONTEXT:?Must set CONTEXT}" +: "${NAMESPACE:?Must set NAMESPACE}" +KUBERNETES_DISTRIBUTION_BINARY="${KUBERNETES_DISTRIBUTION_BINARY:-kubectl}" +KBIN="$KUBERNETES_DISTRIBUTION_BINARY" +OUTPUT_FILE="${OUTPUT_FILE:-openobserve_storage_config_issues.json}" +issues_json='[]' +: >"$OUTPUT_FILE.tmp" + +echo "--- OpenObserve storage configuration audit in namespace ${NAMESPACE} (context ${CONTEXT}) ---" + +workloads=$("$KBIN" --context "$CONTEXT" -n "$NAMESPACE" get deploy,sts -o json 2>/dev/null \ + | jq -c '[.items[] | select(.metadata.name | test("openobserve"; "i"))]' 2>/dev/null || echo "[]") + +count=$(echo "$workloads" | jq -r 'length' 2>/dev/null || echo 0) +if [[ "$count" -eq 0 ]]; then + echo "No OpenObserve workloads found; nothing to audit." + jq -n \ + --arg title "No OpenObserve workloads found for storage config audit" \ + --arg details "No Deployment or StatefulSet whose name contains 'openobserve' exists in namespace ${NAMESPACE}." \ + --argjson severity 3 \ + --arg next_steps "Verify the namespace and confirm the OpenObserve workload is deployed." \ + '{title: $title, details: $details, severity: $severity, next_steps: $next_steps}' >>"$OUTPUT_FILE.tmp" +else + # Extract merged container env (first container) for informational output. + echo "$workloads" | jq -c '.[]' | while IFS= read -r wl; do + kind=$(echo "$wl" | jq -r '.kind') + name=$(echo "$wl" | jq -r '.metadata.name') + container=$(echo "$wl" | jq -r '.spec.template.spec.containers[0] // empty') + if [[ -z "$container" ]]; then + echo "$kind ${name}: no containers found." + continue + fi + echo "$kind ${name}:" + + env_list=$(echo "$container" | jq -c '[.env[]?]' 2>/dev/null || echo '[]') + local_mode=$(echo "$env_list" | jq -r '[.[] | select(.name == "ZO_LOCAL_MODE")][0].value // ""' 2>/dev/null || true) + data_dir=$(echo "$env_list" | jq -r '[.[] | select(.name == "ZO_DATA_DIR")][0].value // ""' 2>/dev/null || true) + s3_names=$(echo "$env_list" | jq -r '[.[] | select(.name | startswith("ZO_S3_")) | .name] | join(", ")' 2>/dev/null || true) + s3_keys=0 + if [[ -n "$s3_names" ]]; then + s3_keys=$(echo "$s3_names" | awk -F', ' '{print NF}') + fi + echo " ZO_LOCAL_MODE=${local_mode:-} ZO_DATA_DIR=${data_dir:-} S3 env vars (${s3_names:-})" + + if [[ -z "$local_mode" ]]; then + echo "Result: ${kind} ${name} does not set ZO_LOCAL_MODE (defaults to local mode in most OpenObserve versions)." + jq -n --arg kind "$kind" --arg name "$name" --arg data_dir "${data_dir:-}" \ + --arg title "${kind} \`${name}\` does not set ZO_LOCAL_MODE" \ + --arg details "ZO_LOCAL_MODE is not present in the workload env (data dir: ${data_dir}). If the deployment intends to use S3 object storage, set ZO_LOCAL_MODE=false and configure ZO_S3_* settings." \ + --argjson severity 3 \ + --arg next_steps "Set ZO_LOCAL_MODE explicitly (true for local volume storage, false for S3) and add the corresponding ZO_DATA_DIR / ZO_S3_* configuration to the Helm values." \ + '{title: $title, details: $details, severity: $severity, next_steps: $next_steps}' >>"$OUTPUT_FILE.tmp" + elif [[ "$local_mode" == "false" || "$local_mode" == "False" || "$local_mode" == "FALSE" ]]; then + if [[ "$s3_keys" -eq 0 ]]; then + echo "Result: ${kind} ${name} runs with ZO_LOCAL_MODE=false but no ZO_S3_* settings." + jq -n --arg kind "$kind" --arg name "$name" \ + --arg title "${kind} \`${name}\` runs with ZO_LOCAL_MODE=false but no ZO_S3_* settings" \ + --arg details "ZO_LOCAL_MODE=false but no ZO_S3_* environment variables are configured on the container. OpenObserve may fail to connect to object storage." \ + --argjson severity 3 \ + --arg next_steps "Add ZO_S3_BUCKET_NAME, ZO_S3_REGION_NAME, ZO_S3_ACCESS_KEY, ZO_S3_SECRET_KEY (or a proper S3 provider config) to the OpenObserve configuration." \ + '{title: $title, details: $details, severity: $severity, next_steps: $next_steps}' >>"$OUTPUT_FILE.tmp" + else + echo "Result: ${kind} ${name} storage config OK (ZO_LOCAL_MODE=false with ${s3_keys} S3 settings)." + fi + else + echo "Result: ${kind} ${name} runs in local storage mode (ZO_LOCAL_MODE=${local_mode})." + fi + done +fi + +if [[ -s "$OUTPUT_FILE.tmp" ]]; then + issues_json=$(jq -s . "$OUTPUT_FILE.tmp" 2>/dev/null || echo '[]') + rm -f "$OUTPUT_FILE.tmp" +else + issues_json='[]' + rm -f "$OUTPUT_FILE.tmp" +fi + +echo "$issues_json" | jq . >"$OUTPUT_FILE" +echo "OpenObserve storage configuration audit complete. Issues written to $OUTPUT_FILE" +cat "$OUTPUT_FILE" \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/check-openobserve-storage-metrics.sh b/codebundles/k8s-openobserve-healthcheck/check-openobserve-storage-metrics.sh new file mode 100755 index 000000000..e569887e3 --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/check-openobserve-storage-metrics.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -uo pipefail +# ----------------------------------------------------------------------------- +# Checks OpenObserve storage health via the API storage_metrics endpoint. +# This is the source of truth for storage health since storage is not +# observable from inside the container. +# +# Endpoint candidates (probed in order): +# /api/{org}/storage_metrics (OpenObserve <= v0.x era) +# /api/{org}/internal_metrics (some versions rename it) +# +# Flags s3_access_failures above STORAGE_S3_FAILURE_THRESHOLD and WAL/parquet +# growth. Parsed defensively: all numeric fields are optional across versions. +# +# Required env: CONTEXT, NAMESPACE +# Optional: STORAGE_S3_FAILURE_THRESHOLD (default 10) +# ----------------------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/_portforward_helper.sh" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/_openobserve_api_common.sh" + +: "${CONTEXT:?Must set CONTEXT}" +: "${NAMESPACE:?Must set NAMESPACE}" + +OUTPUT_FILE="${OUTPUT_FILE:-openobserve_storage_issues.json}" +issues_json='[]' +THRESHOLD="${STORAGE_S3_FAILURE_THRESHOLD:-10}" + +ensure_openobserve_base_url || { + echo "Result: Could not establish an API base URL for OpenObserve." + issues_json=$(echo "$issues_json" | jq \ + --arg title "OpenObserve API base URL unavailable for namespace \`${NAMESPACE}\`" \ + --arg details "OPENOBSERVE_BASE_URL is empty and the automatic kubectl port-forward could not be established for the storage metrics check." \ + --argjson severity 3 \ + --arg next_steps "Confirm the OpenObserve Service exists in NAMESPACE, set OPENOBSERVE_SERVICE_NAME explicitly, or provide OPENOBSERVE_BASE_URL." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" | jq . >"$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +} + +load_openobserve_credentials || true +BASE_URL="${OPENOBSERVE_BASE_URL%/}" +ORG="${OPENOBSERVE_ORGANIZATION:-default}" + +echo "--- OpenObserve storage metrics via API (${BASE_URL}/api/${ORG}/... ) ---" + +endpoint="" +last_path="" +payload="" +http_code="000" +for path in "/api/${ORG}/storage_metrics" "/api/${ORG}/internal_metrics"; do + out=$(openobserve_api_get "$path") + code="${out%%$'\t'*}" + body="${out#*$'\t'}" + last_path="$path" + echo "GET ${BASE_URL}${path} -> HTTP ${code}" + if [[ "$code" == "200" ]]; then + endpoint="$path" + payload="$body" + http_code="200" + break + fi + http_code="$code" + payload="$body" +done + +if [[ "$http_code" != "200" ]]; then + if [[ "$http_code" == "401" ]]; then + echo "Result: storage_metrics endpoint returned HTTP 401 - authentication failed." + issues_json=$(echo "$issues_json" | jq \ + --arg title "OpenObserve API authentication failed for storage metrics" \ + --arg details "GET ${BASE_URL}${last_path} returned HTTP 401. Admin credentials could not be validated." \ + --argjson severity 3 \ + --arg next_steps "Resolve the admin credentials (see OPENOBSERVE_CREDENTIAL_SECRET_NAME or openobserve_credentials secret) and re-run." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + else + echo "Result: storage_metrics endpoint not available in this OpenObserve version (HTTP ${http_code}). Storage API view not supported; no storage issue raised." + fi + echo "$issues_json" | jq . >"$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +fi + +# --- Defensive parsing of the storage metrics payload ------------------------ +# The shape changed across versions. Numeric fields are optional; missing +# fields are not raised as issues. +echo "storage_metrics payload available (${#payload} bytes)." + +s3_failures=$(echo "$payload" | jq -r '(.s3_access_failures // .s3_failures // .data.s3_access_failures // .data.s3_failures // 0)' 2>/dev/null | awk '{print int($1)}') +events=$(echo "$payload" | jq -r '(.events // .data.events // 0)' 2>/dev/null | awk '{print int($1)}') +wal_files=$(echo "$payload" | jq -r '(.wal_files // .data.wal_files // 0)' 2>/dev/null | awk '{print int($1)}') +wal_bytes=$(echo "$payload" | jq -r '(.wal_bytes // .data.wal_bytes // 0)' 2>/dev/null | awk '{print int($1)}') +parquet_files=$(echo "$payload" | jq -r '(.parquet_files // .data.parquet_files // 0)' 2>/dev/null | awk '{print int($1)}') +parquet_bytes=$(echo "$payload" | jq -r '(.parquet_bytes // .data.parquet_bytes // 0)' 2>/dev/null | awk '{print int($1)}') + +s3_failures="${s3_failures:-0}" +echo "Parsed storage metrics: s3_access_failures=${s3_failures} events=$(echo "${events:-0}") wal_files=$(echo "${wal_files:-0}") parquet_files=$(echo "${parquet_files:-0}")" + +if [[ "$s3_failures" -gt "$THRESHOLD" ]]; then + echo "Result: s3_access_failures (${s3_failures}) exceed threshold (${THRESHOLD})." + issues_json=$(echo "$issues_json" | jq \ + --arg title "OpenObserve S3 access failures above threshold (${s3_failures})" \ + --arg details "storage_metrics reports ${s3_failures} S3 access failures (threshold: ${THRESHOLD}). This indicates the storage backend is failing or inaccessible, so queries/writes over S3 may be failing." \ + --argjson severity 3 \ + --arg next_steps "Verify the S3 bucket/object store credentials and reachability (ingress rules, network policy, TLS), check the OpenObserve logs for S3 errors, and confirm the backing storage (e.g. SeaweedFS S3 gateway) is healthy." \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') +fi + +echo "$issues_json" | jq . >"$OUTPUT_FILE" +echo "OpenObserve storage metrics check complete. Issues written to $OUTPUT_FILE" +cat "$OUTPUT_FILE" \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/list-openobserve-resources.sh b/codebundles/k8s-openobserve-healthcheck/list-openobserve-resources.sh new file mode 100755 index 000000000..d618783f1 --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/list-openobserve-resources.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -uo pipefail +# ----------------------------------------------------------------------------- +# Discovers OpenObserve resources in the namespace: workloads (Deployment / +# StatefulSet whose name contains "openobserve"), Services, PVCs, Secrets, and +# ConfigMaps. Adds a topology summary to the report and raises an issue when no +# OpenObserve resources are found (wrong namespace or name). +# +# Required env: CONTEXT, NAMESPACE +# Optional: OBJECT_NAME (name of the generated SLX's matched workload), KUBERNETES_DISTRIBUTION_BINARY +# ----------------------------------------------------------------------------- + +: "${CONTEXT:?Must set CONTEXT}" +: "${NAMESPACE:?Must set NAMESPACE}" + +KUBERNETES_DISTRIBUTION_BINARY="${KUBERNETES_DISTRIBUTION_BINARY:-kubectl}" +KBIN="$KUBERNETES_DISTRIBUTION_BINARY" +OUTPUT_FILE="${OUTPUT_FILE:-openobserve_resources_issues.json}" +issues_json='[]' + +echo "--- OpenObserve resource discovery in namespace ${NAMESPACE} (context ${CONTEXT}) ---" + +# Workloads (Deployment/StatefulSet) whose name contains "openobserve" +workloads=$("$KBIN" --context "$CONTEXT" -n "$NAMESPACE" get deploy,sts -o json 2>/dev/null \ + | jq -r '[.items[] | select(.metadata.name | test("openobserve"; "i")) | {kind: .kind, name: .metadata.name, replicas: (.spec.replicas // .spec.replicas // 0), ready: (.status.readyReplicas // 0)}]' 2>/dev/null || echo "[]") + +workload_count=$(echo "$workloads" | jq -r 'length' 2>/dev/null || echo 0) +svc_count=0; pvc_count=0; secret_count=0; cm_count=0 +svc_names=""; pvc_names=""; secret_names=""; cm_names="" + +svc_names=$("$KBIN" --context "$CONTEXT" -n "$NAMESPACE" get svc -o json 2>/dev/null \ + | jq -r '[.items[] | select(.metadata.name | test("openobserve"; "i")) | .metadata.name] | join(", ")' 2>/dev/null || true) +[ -n "$svc_names" ] && svc_count=$(echo "$svc_names" | awk -F', ' '{print NF}') + +pvc_names=$("$KBIN" --context "$CONTEXT" -n "$NAMESPACE" get pvc -o json 2>/dev/null \ + | jq -r '[.items[] | select(.metadata.name | test("openobserve"; "i")) | .metadata.name] | join(", ")' 2>/dev/null || true) +[ -n "$pvc_names" ] && pvc_count=$(echo "$pvc_names" | awk -F', ' '{print NF}') + +secret_names=$("$KBIN" --context "$CONTEXT" -n "$NAMESPACE" get secrets -o json 2>/dev/null \ + | jq -r '[.items[] | select(.metadata.name | test("openobserve"; "i")) | .metadata.name] | join(", ")' 2>/dev/null || true) +[ -n "$secret_names" ] && secret_count=$(echo "$secret_names" | awk -F', ' '{print NF}') + +cm_names=$("$KBIN" --context "$CONTEXT" -n "$NAMESPACE" get cm -o json 2>/dev/null \ + | jq -r '[.items[] | select(.metadata.name | test("openobserve"; "i")) | .metadata.name] | join(", ")' 2>/dev/null || true) +[ -n "$cm_names" ] && cm_count=$(echo "$cm_names" | awk -F', ' '{print NF}') + +echo "Workloads (containing 'openobserve'):" +echo "$workloads" | jq -r '.[] | " \(.kind) \(.name) ready=\(.ready)/\(.replicas)"' +echo "Services: ${svc_names:-}" +echo "PersistentVolumeClaims: ${pvc_names:-}" +echo "Secrets: ${secret_names:-}" +echo "ConfigMaps: ${cm_names:-}" + +if [[ "$workload_count" -eq 0 ]]; then + echo "Result: No OpenObserve workloads found in namespace '${NAMESPACE}'." + issues_json=$(echo "$issues_json" | jq \ + --arg title "No OpenObserve workloads found in namespace \`${NAMESPACE}\`" \ + --arg details "No Deployment or StatefulSet whose name contains 'openobserve' exists in namespace ${NAMESPACE} (context ${CONTEXT}). Check the namespace/name or confirm the workload was deployed." \ + --argjson severity 3 \ + --arg next_steps "Verify NAMESPACE is correct, confirm the OpenObserve Helm release was installed into this namespace, or adjust the generation-rule name match (pattern 'openobserve')." \ + '. += [{ + "title": $title, + "details": $details, + "severity": $severity, + "next_steps": $next_steps + }]') +fi + +if [[ -n "${OBJECT_NAME:-}" ]]; then + object_found=$(echo "$workloads" | jq -r --arg n "$OBJECT_NAME" '[.[] | select(.name == $n)] | length' 2>/dev/null || echo 0) + if [[ "$object_found" -eq 0 ]]; then + echo "Result: Matched workload '${OBJECT_NAME}' was not found in namespace '${NAMESPACE}'." + issues_json=$(echo "$issues_json" | jq \ + --arg title "OpenObserve workload \`${OBJECT_NAME}\` not found in namespace \`${NAMESPACE}\`" \ + --arg details "Discovery for this SLX targeted workload ${OBJECT_NAME}, but no Deployment/StatefulSet with that name exists (or it was scaled/renamed)." \ + --argjson severity 3 \ + --arg next_steps "Confirm the workload name, re-run discovery, or delete the stale SLX if the workload was permanently removed." \ + '. += [{ + "title": $title, + "details": $details, + "severity": $severity, + "next_steps": $next_steps + }]') + fi +fi + +echo "$issues_json" | jq . >"$OUTPUT_FILE" +echo "OpenObserve resource discovery complete. Issues written to $OUTPUT_FILE" +cat "$OUTPUT_FILE" \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/resolve-openobserve-credentials.sh b/codebundles/k8s-openobserve-healthcheck/resolve-openobserve-credentials.sh new file mode 100755 index 000000000..4788e198e --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/resolve-openobserve-credentials.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -uo pipefail +# ----------------------------------------------------------------------------- +# Runs once from Suite Setup. Resolves the OpenObserve admin credentials using +# every strategy available (RW secret, explicit Secret name, Pod env inference, +# optional kubectl exec fallback) and caches them to ./.openobserve_credentials +# so the per-task scripts can pick them up via _openobserve_credentials_helper.sh. +# +# The cache file and this script never print the credential values themselves - +# only the origin (secret / pod / exec) is logged. +# ----------------------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/_openobserve_credentials_helper.sh" + +CACHE_FILE="${OPENOBSERVE_CREDENTIALS_CACHE_FILE:-./.openobserve_credentials}" +export OPENOBSERVE_CREDENTIALS_CACHE_FILE="$CACHE_FILE" + +rm -f "$CACHE_FILE" 2>/dev/null || true + +echo "--- OpenObserve credential resolution ---" +echo "CONTEXT=${CONTEXT:-} NAMESPACE=${NAMESPACE:-} OBJECT_NAME=${OBJECT_NAME:-} OPENOBSERVE_SERVICE_NAME=${OPENOBSERVE_SERVICE_NAME:-}" +echo "OPENOBSERVE_CREDENTIAL_SECRET_NAME=${OPENOBSERVE_CREDENTIAL_SECRET_NAME:-} OPENOBSERVE_CREDENTIAL_USER_KEY=${OPENOBSERVE_CREDENTIAL_USER_KEY:-} OPENOBSERVE_CREDENTIAL_PASSWORD_KEY=${OPENOBSERVE_CREDENTIAL_PASSWORD_KEY:-} OPENOBSERVE_ALLOW_EXEC=${OPENOBSERVE_ALLOW_EXEC:-false}" + +resolve_and_cache_openobserve_credentials + +if [[ -s "$CACHE_FILE" ]]; then + echo "Result: credentials resolved (cache written to ${CACHE_FILE})." + exit 0 +fi + +echo "Result: credentials NOT RESOLVED. API tasks will likely return HTTP 401." +echo "Provide them via the openobserve_credentials secret, set OPENOBSERVE_CREDENTIAL_SECRET_NAME, ensure the workload exposes ROOT_USER_EMAIL/ROOT_USER_PASSWORD (or ZO_ROOT_USER_*) via a readable Secret, or set OPENOBSERVE_ALLOW_EXEC=true to allow the exec fallback." +exit 0 \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/runbook.robot b/codebundles/k8s-openobserve-healthcheck/runbook.robot new file mode 100644 index 000000000..ce4a2f25d --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/runbook.robot @@ -0,0 +1,530 @@ +*** Settings *** +Documentation Checks the overall health of an OpenObserve deployment running in Kubernetes: workload/pod health, API reachability, and - because storage cannot be inspected from inside the container - storage/ingestion/cluster state queried through the OpenObserve HTTP API using credentials resolved from Kubernetes secrets. +Metadata Author rw-codebundle-agent +Metadata Display Name Kubernetes OpenObserve Health Check +Metadata Supports Kubernetes AKS EKS GKE OpenShift OpenObserve HTTP + +Force Tags Kubernetes OpenObserve HTTP health availability + +Library String +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform + +Suite Setup Suite Initialization + + +*** Tasks *** +List OpenObserve Resources in Namespace `${NAMESPACE}` + [Documentation] Discovers the OpenObserve workload(s), Service, PVCs, Secret(s) and ConfigMap(s) in the namespace and adds a topology summary to the report. Raises an issue when no OpenObserve resources are found. + [Tags] Kubernetes OpenObserve discovery access:read-only data:logs-config + + ${result}= RW.CLI.Run Bash File + ... bash_file=list-openobserve-resources.sh + ... env=${env} + ... secret_file__kubeconfig=${kubeconfig} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=CONTEXT="${CONTEXT}" NAMESPACE="${NAMESPACE}" OBJECT_NAME="${OBJECT_NAME}" ./list-openobserve-resources.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat openobserve_resources_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for resource discovery task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=OpenObserve workloads should be discoverable in namespace `${NAMESPACE}` + ... actual=Resource discovery found no OpenObserve workloads in namespace `${NAMESPACE}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report OpenObserve resource discovery results:\n${result.stdout} + +Check OpenObserve Workload Replica Health in Namespace `${NAMESPACE}` + [Documentation] Compares ready vs. desired replicas for each OpenObserve Deployment/StatefulSet and flags workloads that are not fully ready or scaled to zero. + [Tags] Kubernetes OpenObserve replicas access:read-only data:logs-config + + ${result}= RW.CLI.Run Bash File + ... bash_file=check-openobserve-replica-health.sh + ... env=${env} + ... secret_file__kubeconfig=${kubeconfig} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=CONTEXT="${CONTEXT}" NAMESPACE="${NAMESPACE}" ./check-openobserve-replica-health.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat openobserve_replica_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for replica health task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=OpenObserve workloads should have all desired replicas ready + ... actual=One or more OpenObserve workloads are not fully ready or scaled to zero + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report OpenObserve workload replica health:\n${result.stdout} + +Check OpenObserve Pod Stability in Namespace `${NAMESPACE}` + [Documentation] Inspects OpenObserve Pods for non-Running phases, CrashLoopBackOff, failed readiness, and high restart counts to surface instability before it becomes an outage. + [Tags] Kubernetes OpenObserve pod crashloop access:read-only data:logs-config + + ${result}= RW.CLI.Run Bash File + ... bash_file=check-openobserve-pod-health.sh + ... env=${env} + ... secret_file__kubeconfig=${kubeconfig} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=CONTEXT="${CONTEXT}" NAMESPACE="${NAMESPACE}" OBJECT_NAME="${OBJECT_NAME}" ./check-openobserve-pod-health.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat openobserve_pod_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for pod stability task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=OpenObserve Pods should be Running and stable with low restart counts + ... actual=OpenObserve Pods are unstable (CrashLoopBackOff, not ready, or high restarts) + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report OpenObserve pod stability analysis:\n${result.stdout} + +Check OpenObserve API Health in Namespace `${NAMESPACE}` + [Documentation] Resolves the admin credentials and OpenObserve Base URL (auto port-forward to svc/${OPENOBSERVE_SERVICE_NAME} when not provided) and GETs the health/status endpoints, flagging non-200 responses and authentication failures. + [Tags] Kubernetes OpenObserve api health access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=check-openobserve-api-health.sh + ... env=${env} + ... secret_file__kubeconfig=${kubeconfig} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=CONTEXT="${CONTEXT}" NAMESPACE="${NAMESPACE}" OPENOBSERVE_BASE_URL="${OPENOBSERVE_BASE_URL}" ./check-openobserve-api-health.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat openobserve_api_health_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for API health task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=OpenObserve API health endpoint should return HTTP 200 with valid authentication + ... actual=OpenObserve API health check failed (non-200 or authentication failure) + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report OpenObserve API health results:\n${result.stdout} + +Check OpenObserve Storage Metrics via API in Namespace `${NAMESPACE}` + [Documentation] Calls the OpenObserve storage_metrics API to detect S3 storage access failures, WAL/parquet file growth, and upload issues. This is the source of truth for storage health since storage is not observable from inside the container. + [Tags] Kubernetes OpenObserve storage metrics access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=check-openobserve-storage-metrics.sh + ... env=${env} + ... secret_file__kubeconfig=${kubeconfig} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=CONTEXT="${CONTEXT}" NAMESPACE="${NAMESPACE}" STORAGE_S3_FAILURE_THRESHOLD="${STORAGE_S3_FAILURE_THRESHOLD}" ./check-openobserve-storage-metrics.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat openobserve_storage_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for storage metrics task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=OpenObserve storage should report no S3 access failures above threshold + ... actual=OpenObserve storage_metrics reports S3 access failures above threshold + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report OpenObserve storage metrics results:\n${result.stdout} + +Check OpenObserve Cluster Processing Health in Namespace `${NAMESPACE}` + [Documentation] Queries the OpenObserve cluster_info endpoint for per-node pending compaction jobs and flags nodes with a compaction backlog above threshold, indicating storage lifecycle work is not keeping up. + [Tags] Kubernetes OpenObserve cluster compaction access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=check-openobserve-cluster-health.sh + ... env=${env} + ... secret_file__kubeconfig=${kubeconfig} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=CONTEXT="${CONTEXT}" NAMESPACE="${NAMESPACE}" CLUSTER_PENDING_JOBS_THRESHOLD="${CLUSTER_PENDING_JOBS_THRESHOLD}" ./check-openobserve-cluster-health.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat openobserve_cluster_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for cluster health task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=OpenObserve nodes should have no pending compaction backlog above threshold + ... actual=One or more OpenObserve nodes have a pending compaction backlog + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report OpenObserve cluster processing results:\n${result.stdout} + +Check OpenObserve Ingestion Queue Health in Namespace `${NAMESPACE}` + [Documentation] Queries the OpenObserve queues endpoint and flags memory/disk queue key growth that signals ingestion is backing up and data may be delayed or dropped. + [Tags] Kubernetes OpenObserve ingestion queue access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=check-openobserve-queue-health.sh + ... env=${env} + ... secret_file__kubeconfig=${kubeconfig} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=CONTEXT="${CONTEXT}" NAMESPACE="${NAMESPACE}" INGESTION_QUEUE_KEY_THRESHOLD="${INGESTION_QUEUE_KEY_THRESHOLD}" ./check-openobserve-queue-health.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat openobserve_queue_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for ingestion queue task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=OpenObserve ingestion queues should stay below the key-count threshold + ... actual=OpenObserve ingestion queues are backing up above the threshold + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report OpenObserve ingestion queue results:\n${result.stdout} + +Check OpenObserve Storage Configuration in Namespace `${NAMESPACE}` + [Documentation] Reads the OpenObserve workload env for storage topology (ZO_LOCAL_MODE, ZO_DATA_DIR, ZO_S3_*) and validates that S3 settings exist when local mode is disabled. + [Tags] Kubernetes OpenObserve storage config access:read-only data:config + + ${result}= RW.CLI.Run Bash File + ... bash_file=check-openobserve-storage-config.sh + ... env=${env} + ... secret_file__kubeconfig=${kubeconfig} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=CONTEXT="${CONTEXT}" NAMESPACE="${NAMESPACE}" OBJECT_NAME="${OBJECT_NAME}" ./check-openobserve-storage-config.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat openobserve_storage_config_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for storage config task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=OpenObserve storage configuration should match the deployment mode (local vs S3) + ... actual=OpenObserve storage configuration is incomplete or inconsistent + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report OpenObserve storage configuration audit:\n${result.stdout} + +Check OpenObserve PVC Capacity in Namespace `${NAMESPACE}` + [Documentation] Lists the PVCs backing OpenObserve (capacity, status, reclaim policy) and flags unbound volumes or volumes approaching capacity using kubectl-level data only, keeping the container-access constraint. + [Tags] Kubernetes OpenObserve pvc capacity access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=check-openobserve-pvc-capacity.sh + ... env=${env} + ... secret_file__kubeconfig=${kubeconfig} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=CONTEXT="${CONTEXT}" NAMESPACE="${NAMESPACE}" PVC_USAGE_THRESHOLD_PERCENT="${PVC_USAGE_THRESHOLD_PERCENT}" ./check-openobserve-pvc-capacity.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat openobserve_pvc_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for PVC capacity task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=OpenObserve PVCs should be bound and have adequate capacity headroom + ... actual=One or more OpenObserve PVCs are unbound or approaching capacity + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report OpenObserve PVC capacity report:\n${result.stdout} + + +*** Keywords *** +Suite Initialization + ${kubeconfig}= RW.Core.Import Secret + ... kubeconfig + ... type=string + ... description=The kubernetes kubeconfig yaml containing connection configuration used to connect to cluster(s). + ... pattern=\w* + ${openobserve_credentials_provided}= Set Variable ${FALSE} + TRY + ${openobserve_credentials}= RW.Core.Import Secret + ... openobserve_credentials + ... type=string + ... description=Optional RunWhen secret with the OpenObserve admin credentials as JSON {username|email, password}. When omitted the codebundle tries to derive them from a Kubernetes Secret in NAMESPACE or from the workload Pod env. + ... pattern=.* + Set Suite Variable ${openobserve_credentials} ${openobserve_credentials} + ${openobserve_credentials_provided}= Set Variable ${TRUE} + EXCEPT + Log openobserve_credentials secret not provided; will try to derive from a Kubernetes Secret or workload env. INFO + Set Suite Variable ${openobserve_credentials} ${EMPTY} + END + Set Suite Variable ${openobserve_credentials_provided} ${openobserve_credentials_provided} + ${CONTEXT}= RW.Core.Import User Variable CONTEXT + ... type=string + ... description=Kubernetes context to use for kubectl-backed checks. + ... pattern=\w* + ${NAMESPACE}= RW.Core.Import User Variable NAMESPACE + ... type=string + ... description=Namespace where OpenObserve runs. + ... pattern=\w* + ${OBJECT_NAME}= RW.Core.Import User Variable OBJECT_NAME + ... type=string + ... description=OpenObserve workload name (Deployment or StatefulSet) this SLX monitors. Used to scope pod and credential discovery. + ... pattern=\w* + ${KUBERNETES_DISTRIBUTION_BINARY}= RW.Core.Import User Variable KUBERNETES_DISTRIBUTION_BINARY + ... type=string + ... description=Kubernetes CLI binary to use. + ... enum=[kubectl,oc] + ... default=kubectl + ${OPENOBSERVE_SERVICE_NAME}= RW.Core.Import User Variable OPENOBSERVE_SERVICE_NAME + ... type=string + ... description=Kubernetes Service name exposing the OpenObserve HTTP port. Auto-discovered from a Service whose name contains 'openobserve' when empty. + ... pattern=.* + ... default= + ${OPENOBSERVE_HTTP_PORT}= RW.Core.Import User Variable OPENOBSERVE_HTTP_PORT + ... type=string + ... description=OpenObserve HTTP port on the Service. + ... pattern=^\d+$ + ... default=5080 + ${OPENOBSERVE_LOCAL_PORT}= RW.Core.Import User Variable OPENOBSERVE_LOCAL_PORT + ... type=string + ... description=Local port used when auto port-forwarding. + ... pattern=^\d+$ + ... default=5080 + ${OPENOBSERVE_ORGANIZATION}= RW.Core.Import User Variable OPENOBSERVE_ORGANIZATION + ... type=string + ... description=OpenObserve organization used in the API path (/api/{org}/...). + ... pattern=\w* + ... default=default + ${OPENOBSERVE_BASE_URL}= RW.Core.Import User Variable OPENOBSERVE_BASE_URL + ... type=string + ... description=Optional direct Base URL (e.g. http://openobserve..svc.cluster.local:5080). Leave empty to auto start kubectl port-forward to svc/${OPENOBSERVE_SERVICE_NAME} on ${OPENOBSERVE_HTTP_PORT}. + ... pattern=.* + ... default= + ${OPENOBSERVE_API_TIMEOUT}= RW.Core.Import User Variable OPENOBSERVE_API_TIMEOUT + ... type=string + ... description=Timeout in seconds for API curl calls. + ... pattern=^\d+$ + ... default=25 + ${OPENOBSERVE_PF_WAIT_SECS}= RW.Core.Import User Variable OPENOBSERVE_PF_WAIT_SECS + ... type=string + ... description=Max seconds to wait for the automatic kubectl port-forward to become ready. + ... pattern=^\d+$ + ... default=15 + ${OPENOBSERVE_CREDENTIAL_SECRET_NAME}= RW.Core.Import User Variable OPENOBSERVE_CREDENTIAL_SECRET_NAME + ... type=string + ... description=Optional Kubernetes Secret name holding the OpenObserve credentials. When empty, infers from the workload Pod env (ROOT_USER_EMAIL/ROOT_USER_PASSWORD or ZO_ROOT_USER_*), following secretKeyRef. + ... pattern=.* + ... default= + ${OPENOBSERVE_CREDENTIAL_USER_KEY}= RW.Core.Import User Variable OPENOBSERVE_CREDENTIAL_USER_KEY + ... type=string + ... description=Secret data key for the API username; auto-probes ROOT_USER_EMAIL, ZO_ROOT_USER_EMAIL, ROOT_USER_NAME when empty. + ... pattern=.* + ... default= + ${OPENOBSERVE_CREDENTIAL_PASSWORD_KEY}= RW.Core.Import User Variable OPENOBSERVE_CREDENTIAL_PASSWORD_KEY + ... type=string + ... description=Secret data key for the API password; auto-probes ROOT_USER_PASSWORD, ZO_ROOT_USER_PASSWORD when empty. + ... pattern=.* + ... default= + ${OPENOBSERVE_ALLOW_EXEC}= RW.Core.Import User Variable OPENOBSERVE_ALLOW_EXEC + ... type=string + ... description=Set to 'true' to allow kubectl exec -- printenv as a last-resort credential fallback. + ... pattern=\w* + ... default=false + ${OPENOBSERVE_POD_RESTART_THRESHOLD}= RW.Core.Import User Variable OPENOBSERVE_POD_RESTART_THRESHOLD + ... type=string + ... description=Minimum container restart count that flags an unstable OpenObserve Pod. + ... pattern=^\d+$ + ... default=5 + ${STORAGE_S3_FAILURE_THRESHOLD}= RW.Core.Import User Variable STORAGE_S3_FAILURE_THRESHOLD + ... type=string + ... description=Max s3_access_failures per storage_metrics sample before raising an issue. + ... pattern=^\d+$ + ... default=10 + ${INGESTION_QUEUE_KEY_THRESHOLD}= RW.Core.Import User Variable INGESTION_QUEUE_KEY_THRESHOLD + ... type=string + ... description=Max total (mem + disk) queue keys before flagging an ingestion backlog. + ... pattern=^\d+$ + ... default=100000 + ${CLUSTER_PENDING_JOBS_THRESHOLD}= RW.Core.Import User Variable CLUSTER_PENDING_JOBS_THRESHOLD + ... type=string + ... description=Max pending compaction jobs per node before flagging cluster processing backlog. + ... pattern=^\d+$ + ... default=100 + ${PVC_USAGE_THRESHOLD_PERCENT}= RW.Core.Import User Variable PVC_USAGE_THRESHOLD_PERCENT + ... type=string + ... description=PVC capacity warning threshold (percent). + ... pattern=^\d+$ + ... default=80 + Set Suite Variable ${kubeconfig} ${kubeconfig} + Set Suite Variable ${CONTEXT} ${CONTEXT} + Set Suite Variable ${NAMESPACE} ${NAMESPACE} + Set Suite Variable ${OBJECT_NAME} ${OBJECT_NAME} + Set Suite Variable ${KUBERNETES_DISTRIBUTION_BINARY} ${KUBERNETES_DISTRIBUTION_BINARY} + Set Suite Variable ${OPENOBSERVE_SERVICE_NAME} ${OPENOBSERVE_SERVICE_NAME} + Set Suite Variable ${OPENOBSERVE_HTTP_PORT} ${OPENOBSERVE_HTTP_PORT} + Set Suite Variable ${OPENOBSERVE_LOCAL_PORT} ${OPENOBSERVE_LOCAL_PORT} + Set Suite Variable ${OPENOBSERVE_ORGANIZATION} ${OPENOBSERVE_ORGANIZATION} + Set Suite Variable ${OPENOBSERVE_BASE_URL} ${OPENOBSERVE_BASE_URL} + Set Suite Variable ${OPENOBSERVE_API_TIMEOUT} ${OPENOBSERVE_API_TIMEOUT} + Set Suite Variable ${OPENOBSERVE_PF_WAIT_SECS} ${OPENOBSERVE_PF_WAIT_SECS} + Set Suite Variable ${OPENOBSERVE_CREDENTIAL_SECRET_NAME} ${OPENOBSERVE_CREDENTIAL_SECRET_NAME} + Set Suite Variable ${OPENOBSERVE_CREDENTIAL_USER_KEY} ${OPENOBSERVE_CREDENTIAL_USER_KEY} + Set Suite Variable ${OPENOBSERVE_CREDENTIAL_PASSWORD_KEY} ${OPENOBSERVE_CREDENTIAL_PASSWORD_KEY} + Set Suite Variable ${OPENOBSERVE_ALLOW_EXEC} ${OPENOBSERVE_ALLOW_EXEC} + Set Suite Variable ${OPENOBSERVE_POD_RESTART_THRESHOLD} ${OPENOBSERVE_POD_RESTART_THRESHOLD} + Set Suite Variable ${STORAGE_S3_FAILURE_THRESHOLD} ${STORAGE_S3_FAILURE_THRESHOLD} + Set Suite Variable ${INGESTION_QUEUE_KEY_THRESHOLD} ${INGESTION_QUEUE_KEY_THRESHOLD} + Set Suite Variable ${CLUSTER_PENDING_JOBS_THRESHOLD} ${CLUSTER_PENDING_JOBS_THRESHOLD} + Set Suite Variable ${PVC_USAGE_THRESHOLD_PERCENT} ${PVC_USAGE_THRESHOLD_PERCENT} + ${env}= Create Dictionary + ... CONTEXT=${CONTEXT} + ... NAMESPACE=${NAMESPACE} + ... OBJECT_NAME=${OBJECT_NAME} + ... KUBERNETES_DISTRIBUTION_BINARY=${KUBERNETES_DISTRIBUTION_BINARY} + ... OPENOBSERVE_SERVICE_NAME=${OPENOBSERVE_SERVICE_NAME} + ... OPENOBSERVE_HTTP_PORT=${OPENOBSERVE_HTTP_PORT} + ... OPENOBSERVE_LOCAL_PORT=${OPENOBSERVE_LOCAL_PORT} + ... OPENOBSERVE_ORGANIZATION=${OPENOBSERVE_ORGANIZATION} + ... OPENOBSERVE_BASE_URL=${OPENOBSERVE_BASE_URL} + ... OPENOBSERVE_API_TIMEOUT=${OPENOBSERVE_API_TIMEOUT} + ... OPENOBSERVE_PF_WAIT_SECS=${OPENOBSERVE_PF_WAIT_SECS} + ... OPENOBSERVE_CREDENTIAL_SECRET_NAME=${OPENOBSERVE_CREDENTIAL_SECRET_NAME} + ... OPENOBSERVE_CREDENTIAL_USER_KEY=${OPENOBSERVE_CREDENTIAL_USER_KEY} + ... OPENOBSERVE_CREDENTIAL_PASSWORD_KEY=${OPENOBSERVE_CREDENTIAL_PASSWORD_KEY} + ... OPENOBSERVE_ALLOW_EXEC=${OPENOBSERVE_ALLOW_EXEC} + ... OPENOBSERVE_POD_RESTART_THRESHOLD=${OPENOBSERVE_POD_RESTART_THRESHOLD} + ... STORAGE_S3_FAILURE_THRESHOLD=${STORAGE_S3_FAILURE_THRESHOLD} + ... INGESTION_QUEUE_KEY_THRESHOLD=${INGESTION_QUEUE_KEY_THRESHOLD} + ... CLUSTER_PENDING_JOBS_THRESHOLD=${CLUSTER_PENDING_JOBS_THRESHOLD} + ... PVC_USAGE_THRESHOLD_PERCENT=${PVC_USAGE_THRESHOLD_PERCENT} + ... KUBECONFIG=./${kubeconfig.key} + Set Suite Variable ${env} ${env} + Resolve OpenObserve Credentials + +Resolve OpenObserve Credentials + [Documentation] Runs the credential discovery script once so every task can reuse the cached value. Output is surfaced in the runbook report so operators can see where the credentials came from (or why they could not be derived). + IF ${openobserve_credentials_provided} + ${resolve_result}= RW.CLI.Run Bash File + ... bash_file=resolve-openobserve-credentials.sh + ... env=${env} + ... secret_file__kubeconfig=${kubeconfig} + ... secret__openobserve_credentials=${openobserve_credentials} + ... timeout_seconds=120 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./resolve-openobserve-credentials.sh + ELSE + ${resolve_result}= RW.CLI.Run Bash File + ... bash_file=resolve-openobserve-credentials.sh + ... env=${env} + ... secret_file__kubeconfig=${kubeconfig} + ... timeout_seconds=120 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./resolve-openobserve-credentials.sh + END + RW.Core.Add Pre To Report OpenObserve credential resolution:\n${resolve_result.stdout} \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/sli-openobserve-score.sh b/codebundles/k8s-openobserve-healthcheck/sli-openobserve-score.sh new file mode 100755 index 000000000..374b55f71 --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/sli-openobserve-score.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -uo pipefail +# ----------------------------------------------------------------------------- +# Lightweight JSON scores for sli.robot: API health, replica readiness, and +# storage health. Prints EXACTLY one line of JSON to stdout so sli.robot can +# json.loads it. All diagnostics go to stderr (except the JSON line). +# +# Dimensions: +# api_health - 1 when the OpenObserve health endpoint returns HTTP 200 +# replicas_ready - 1 when the matched OpenObserve workload has all desired +# replicas ready (or is scaled to >0); 0 when degraded. +# storage_healthy - 1 when storage_metrics is unreported/clean, or when +# s3_access_failures stays at/below STORAGE_S3_FAILURE_THRESHOLD. +# +# Required env: CONTEXT, NAMESPACE +# ----------------------------------------------------------------------------- + +: "${CONTEXT:?Must set CONTEXT}" +: "${NAMESPACE:?Must set NAMESPACE}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/_portforward_helper.sh" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/_openobserve_api_common.sh" + +KUBERNETES_DISTRIBUTION_BINARY="${KUBERNETES_DISTRIBUTION_BINARY:-kubectl}" +KBIN="$KUBERNETES_DISTRIBUTION_BINARY" +MAX_TIME="${OPENOBSERVE_API_TIMEOUT:-15}" +S3_THRESHOLD="${STORAGE_S3_FAILURE_THRESHOLD:-10}" +ORG="${OPENOBSERVE_ORGANIZATION:-default}" + +# -- Dimension: replica readiness ------------------------------------------- +replica_score=0 +if [[ -n "${OBJECT_NAME:-}" ]]; then + desired=$("$KBIN" --context "$CONTEXT" -n "$NAMESPACE" get deploy,sts "$OBJECT_NAME" -o json 2>/dev/null \ + | jq -r '[.items[]? | (.spec.replicas // 0)] | max // 0' 2>/dev/null || echo "0") + ready=$("$KBIN" --context "$CONTEXT" -n "$NAMESPACE" get deploy,sts "$OBJECT_NAME" -o json 2>/dev/null \ + | jq -r '[.items[]? | (.status.readyReplicas // 0)] | add // 0' 2>/dev/null || echo "0") + if [[ "${desired:-0}" -eq 0 ]]; then + replica_score=0 + elif [[ "${ready:-0}" -ge "${desired:-0}" ]]; then + replica_score=1 + else + replica_score=0 + fi +else + # No OBJECT_NAME: sum across all openobserve-named workloads. + desired=0; ready=0 + while IFS= read -r wl; do + [[ -z "$wl" ]] && continue + dd=$(echo "$wl" | jq -r '.spec.replicas // 0') + rr=$(echo "$wl" | jq -r '.status.readyReplicas // 0') + desired=$((desired + dd)) + ready=$((ready + rr)) + done < <("$KBIN" --context "$CONTEXT" -n "$NAMESPACE" get deploy,sts -o json 2>/dev/null \ + | jq -c '.items[] | select(.metadata.name | test("openobserve"; "i"))' 2>/dev/null || true) + replica_score=0 + if [[ "$desired" -eq 0 ]]; then + replica_score=0 + elif [[ "$ready" -ge "$desired" ]]; then + replica_score=1 + fi +fi + +# -- Dimension: API health ---------------------------------------------------- +api_score=0 +if ensure_openobserve_base_url 1>&2; then + load_openobserve_credentials 1>&2 || true + BASE="${OPENOBSERVE_BASE_URL%/}" + for path in "/api/${ORG}/health" "/healthz" "/health"; do + code=$(curl -sS --max-time "$MAX_TIME" -o /dev/null -w "%{http_code}" "${BASE}${path}" 2>/dev/null || echo "000") + if [[ "$code" == "200" ]]; then + api_score=1 + break + fi + done +fi + +# -- Dimension: storage health ------------------------------------------------ +storage_score=1 +if [[ "$api_score" -eq 1 ]]; then + for path in "/api/${ORG}/storage_metrics" "/api/${ORG}/internal_metrics"; do + out=$(openobserve_api_get "$path") + code="${out%%$'\t'*}" + body="${out#*$'\t'}" + if [[ "$code" == "200" ]]; then + s3f=$(echo "$body" | jq -r '(.s3_access_failures // .s3_failures // .data.s3_access_failures // .data.s3_failures // 0)' 2>/dev/null | awk '{print int($1)}') + s3f="${s3f:-0}" + if [[ "$s3f" -gt "$S3_THRESHOLD" ]]; then + storage_score=0 + fi + break + fi + done +fi + +jq -cn \ + --argjson a "$api_score" \ + --argjson r "$replica_score" \ + --argjson s "$storage_score" \ + '{api_health: $a, replicas_ready: $r, storage_healthy: $s}' \ No newline at end of file diff --git a/codebundles/k8s-openobserve-healthcheck/sli.robot b/codebundles/k8s-openobserve-healthcheck/sli.robot new file mode 100644 index 000000000..76b1e7633 --- /dev/null +++ b/codebundles/k8s-openobserve-healthcheck/sli.robot @@ -0,0 +1,143 @@ +*** Settings *** +Documentation Measures OpenObserve health using API reachability, workload replica readiness, and storage_metrics (S3 access failures). Produces a value between 0 (failing) and 1 (healthy). +Metadata Author rw-codebundle-agent +Metadata Display Name Kubernetes OpenObserve Health Check SLI +Metadata Supports Kubernetes AKS EKS GKE OpenShift OpenObserve + +Suite Setup Suite Initialization +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform +Library Collections + + +*** Keywords *** +Suite Initialization + ${kubeconfig}= RW.Core.Import Secret + ... kubeconfig + ... type=string + ... description=Kubernetes kubeconfig for cluster access and the automatic port-forward. + ... pattern=\w* + ${CONTEXT}= RW.Core.Import User Variable CONTEXT + ... type=string + ... description=Kubernetes context for the target cluster. + ... pattern=\w* + ${NAMESPACE}= RW.Core.Import User Variable NAMESPACE + ... type=string + ... description=Namespace where OpenObserve is deployed. + ... pattern=\w* + ${OBJECT_NAME}= RW.Core.Import User Variable OBJECT_NAME + ... type=string + ... description=OpenObserve workload name (Deployment or StatefulSet) this SLX monitors. + ... pattern=\w* + ${OPENOBSERVE_SERVICE_NAME}= RW.Core.Import User Variable OPENOBSERVE_SERVICE_NAME + ... type=string + ... description=Kubernetes Service name exposing the OpenObserve HTTP port. Auto-discovered from a Service whose name contains 'openobserve' when empty. + ... pattern=.* + ... default= + ${OPENOBSERVE_HTTP_PORT}= RW.Core.Import User Variable OPENOBSERVE_HTTP_PORT + ... type=string + ... description=OpenObserve HTTP port on the Service. + ... pattern=^\d+$ + ... default=5080 + ${OPENOBSERVE_LOCAL_PORT}= RW.Core.Import User Variable OPENOBSERVE_LOCAL_PORT + ... type=string + ... description=Local port used when auto port-forwarding. + ... pattern=^\d+$ + ... default=5080 + ${OPENOBSERVE_ORGANIZATION}= RW.Core.Import User Variable OPENOBSERVE_ORGANIZATION + ... type=string + ... description=OpenObserve organization used in the API path (/api/{org}/...). + ... pattern=\w* + ... default=default + ${OPENOBSERVE_BASE_URL}= RW.Core.Import User Variable OPENOBSERVE_BASE_URL + ... type=string + ... description=Optional direct Base URL. Leave empty to auto start kubectl port-forward to svc/${OPENOBSERVE_SERVICE_NAME}. + ... pattern=.* + ... default= + ${OPENOBSERVE_API_TIMEOUT}= RW.Core.Import User Variable OPENOBSERVE_API_TIMEOUT + ... type=string + ... description=Timeout in seconds for API curl calls. + ... pattern=^\d+$ + ... default=25 + ${STORAGE_S3_FAILURE_THRESHOLD}= RW.Core.Import User Variable STORAGE_S3_FAILURE_THRESHOLD + ... type=string + ... description=Max s3_access_failures per storage_metrics sample before the storage dimension fails. + ... pattern=^\d+$ + ... default=10 + ${KUBERNETES_DISTRIBUTION_BINARY}= RW.Core.Import User Variable KUBERNETES_DISTRIBUTION_BINARY + ... type=string + ... description=Kubernetes CLI binary to use. + ... enum=[kubectl,oc] + ... default=kubectl + Set Suite Variable ${kubeconfig} ${kubeconfig} + Set Suite Variable ${CONTEXT} ${CONTEXT} + Set Suite Variable ${NAMESPACE} ${NAMESPACE} + Set Suite Variable ${OBJECT_NAME} ${OBJECT_NAME} + Set Suite Variable ${OPENOBSERVE_SERVICE_NAME} ${OPENOBSERVE_SERVICE_NAME} + Set Suite Variable ${OPENOBSERVE_HTTP_PORT} ${OPENOBSERVE_HTTP_PORT} + Set Suite Variable ${OPENOBSERVE_LOCAL_PORT} ${OPENOBSERVE_LOCAL_PORT} + Set Suite Variable ${OPENOBSERVE_ORGANIZATION} ${OPENOBSERVE_ORGANIZATION} + Set Suite Variable ${OPENOBSERVE_BASE_URL} ${OPENOBSERVE_BASE_URL} + Set Suite Variable ${OPENOBSERVE_API_TIMEOUT} ${OPENOBSERVE_API_TIMEOUT} + Set Suite Variable ${STORAGE_S3_FAILURE_THRESHOLD} ${STORAGE_S3_FAILURE_THRESHOLD} + Set Suite Variable ${KUBERNETES_DISTRIBUTION_BINARY} ${KUBERNETES_DISTRIBUTION_BINARY} + ${env}= Create Dictionary + ... CONTEXT=${CONTEXT} + ... NAMESPACE=${NAMESPACE} + ... OBJECT_NAME=${OBJECT_NAME} + ... OPENOBSERVE_SERVICE_NAME=${OPENOBSERVE_SERVICE_NAME} + ... OPENOBSERVE_HTTP_PORT=${OPENOBSERVE_HTTP_PORT} + ... OPENOBSERVE_LOCAL_PORT=${OPENOBSERVE_LOCAL_PORT} + ... OPENOBSERVE_ORGANIZATION=${OPENOBSERVE_ORGANIZATION} + ... OPENOBSERVE_BASE_URL=${OPENOBSERVE_BASE_URL} + ... OPENOBSERVE_API_TIMEOUT=${OPENOBSERVE_API_TIMEOUT} + ... STORAGE_S3_FAILURE_THRESHOLD=${STORAGE_S3_FAILURE_THRESHOLD} + ... KUBERNETES_DISTRIBUTION_BINARY=${KUBERNETES_DISTRIBUTION_BINARY} + ... KUBECONFIG=./${kubeconfig.key} + Set Suite Variable ${env} ${env} + + +*** Tasks *** +Collect OpenObserve Health Sub-Scores for Workload `${OBJECT_NAME}` + [Documentation] Fetches API reachability, replica readiness, and storage health as binary 0/1 scores. + [Tags] access:read-only data:metrics + ${raw}= RW.CLI.Run Bash File + ... bash_file=sli-openobserve-score.sh + ... env=${env} + ... secret_file__kubeconfig=${kubeconfig} + ... timeout_seconds=120 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./sli-openobserve-score.sh + TRY + ${scores}= Evaluate + ... next((json.loads(l) for l in reversed((r'''${raw.stdout}''').splitlines()) if l.strip().startswith('{') and l.strip().endswith('}'))) + ... json + EXCEPT + Log SLI score JSON parse failed; scoring all dimensions as 0. Raw stdout follows. WARN + Log ${raw.stdout} WARN + ${scores}= Create Dictionary api_health=0 replicas_ready=0 storage_healthy=0 + END + ${ap}= Get From Dictionary ${scores} api_health + ${rp}= Get From Dictionary ${scores} replicas_ready + ${sp}= Get From Dictionary ${scores} storage_healthy + ${ap}= Convert To Number ${ap} + ${rp}= Convert To Number ${rp} + ${sp}= Convert To Number ${sp} + Set Suite Variable ${api_health_score} ${ap} + Set Suite Variable ${replicas_ready_score} ${rp} + Set Suite Variable ${storage_healthy_score} ${sp} + RW.Core.Push Metric ${ap} sub_name=api_health + RW.Core.Push Metric ${rp} sub_name=replicas_ready + RW.Core.Push Metric ${sp} sub_name=storage_healthy + +Generate Aggregate OpenObserve Health Score for Workload `${OBJECT_NAME}` + [Documentation] Averages sub-scores into the final 0-1 health metric used for alerting. + [Tags] access:read-only data:metrics + ${health_score}= Evaluate (${api_health_score} + ${replicas_ready_score} + ${storage_healthy_score}) / 3 + ${health_score}= Convert To Number ${health_score} 2 + ${report_msg}= Set Variable OpenObserve health score: ${health_score} (api_health=${api_health_score}, replicas_ready=${replicas_ready_score}, storage_healthy=${storage_healthy_score}) + RW.Core.Add To Report ${report_msg} + RW.Core.Push Metric ${health_score} \ No newline at end of file