diff --git a/codebundles/elasticsearch-generic-log-query/.runwhen/generation-rules/elasticsearch-generic-log-query.yaml b/codebundles/elasticsearch-generic-log-query/.runwhen/generation-rules/elasticsearch-generic-log-query.yaml new file mode 100644 index 00000000..a516e4b6 --- /dev/null +++ b/codebundles/elasticsearch-generic-log-query/.runwhen/generation-rules/elasticsearch-generic-log-query.yaml @@ -0,0 +1,24 @@ +apiVersion: runwhen.com/v1 +kind: GenerationRules +spec: + platform: elasticsearch + generationRules: + - resourceTypes: + - elasticsearch_cluster + matchRules: + - type: pattern + pattern: ".+" + properties: ["name"] + mode: substring + slxs: + - baseName: elasticsearch-generic-log-query + qualifiers: + - ELASTICSEARCH_BASE_URL + - ELASTICSEARCH_INDEX_PATTERN + baseTemplateName: elasticsearch-generic-log-query + levelOfDetail: basic + outputItems: + - type: slx + - type: sli + - type: runbook + templateName: elasticsearch-generic-log-query-taskset.yaml diff --git a/codebundles/elasticsearch-generic-log-query/.runwhen/templates/elasticsearch-generic-log-query-sli.yaml b/codebundles/elasticsearch-generic-log-query/.runwhen/templates/elasticsearch-generic-log-query-sli.yaml new file mode 100644 index 00000000..c53789bb --- /dev/null +++ b/codebundles/elasticsearch-generic-log-query/.runwhen/templates/elasticsearch-generic-log-query-sli.yaml @@ -0,0 +1,44 @@ +apiVersion: runwhen.com/v1 +kind: ServiceLevelIndicator +metadata: + name: {{slx_name}} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + displayUnitsLong: Health Score + displayUnitsShort: score + locations: + - {{default_location}} + description: Probes Elasticsearch _cluster/health for reachability and maps a 2xx response to a 0-1 score. + 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/elasticsearch-generic-log-query/sli.robot + intervalStrategy: intermezzo + intervalSeconds: 300 + configProvided: + - name: ELASTICSEARCH_BASE_URL + value: "{{ custom.elasticsearch_base_url | default(match_resource.elasticsearch_base_url | default('')) }}" + - name: REQUEST_TIMEOUT_SECONDS + value: "{{ custom.sli_request_timeout_seconds | default('15') }}" + secretsProvided: + {% if wb_version %} + {% include "elasticsearch-auth.yaml" ignore missing %} + {% else %} + - name: elasticsearch_credentials + workspaceKey: AUTH DETAILS NOT FOUND + {% endif %} + alertConfig: + tasks: + persona: eager-edgar + sessionTTL: 10m diff --git a/codebundles/elasticsearch-generic-log-query/.runwhen/templates/elasticsearch-generic-log-query-slx.yaml b/codebundles/elasticsearch-generic-log-query/.runwhen/templates/elasticsearch-generic-log-query-slx.yaml new file mode 100644 index 00000000..ff19079f --- /dev/null +++ b/codebundles/elasticsearch-generic-log-query/.runwhen/templates/elasticsearch-generic-log-query-slx.yaml @@ -0,0 +1,25 @@ +apiVersion: runwhen.com/v1 +kind: ServiceLevelX +metadata: + name: {{ slx_name }} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + imageURL: https://www.vectorlogo.zone/logos/elastic_elasticsearch/elastic_elasticsearch-icon.svg + alias: Elasticsearch Generic Log Search + asMeasuredBy: Successful HTTP access to the Elasticsearch API and search results within optional hit thresholds. + configProvided: + - name: SLX_PLACEHOLDER + value: SLX_PLACEHOLDER + owners: + - {{ workspace.owner_email }} + statement: Elasticsearch should accept searches against the configured index pattern with the supplied query body. + additionalContext: + qualified_name: "{{ match_resource.qualified_name | default('elasticsearch') }}" + tags: + - name: access + value: read-only + - name: service + value: elasticsearch diff --git a/codebundles/elasticsearch-generic-log-query/.runwhen/templates/elasticsearch-generic-log-query-taskset.yaml b/codebundles/elasticsearch-generic-log-query/.runwhen/templates/elasticsearch-generic-log-query-taskset.yaml new file mode 100644 index 00000000..75c1fb17 --- /dev/null +++ b/codebundles/elasticsearch-generic-log-query/.runwhen/templates/elasticsearch-generic-log-query-taskset.yaml @@ -0,0 +1,43 @@ +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: Runs configurable Elasticsearch _search requests with the base URL separated from the query JSON body. + 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/elasticsearch-generic-log-query/runbook.robot + configProvided: + - name: ELASTICSEARCH_BASE_URL + value: "{{ custom.elasticsearch_base_url | default(match_resource.elasticsearch_base_url | default('')) }}" + - name: ELASTICSEARCH_INDEX_PATTERN + value: "{{ custom.elasticsearch_index_pattern | default(match_resource.elasticsearch_index_pattern | default('logs-*')) }}" + - name: ELASTICSEARCH_QUERY_BODY + value: "{{ custom.elasticsearch_query_body | default('{\"size\": 0, \"query\": {\"match_all\": {}}}') }}" + - name: SEARCH_THRESHOLD_MAX_HITS + value: "{{ custom.search_threshold_max_hits | default('') }}" + - name: SEARCH_THRESHOLD_MIN_HITS + value: "{{ custom.search_threshold_min_hits | default('') }}" + - name: REQUEST_TIMEOUT_SECONDS + value: "{{ custom.request_timeout_seconds | default('60') }}" + secretsProvided: + {% if wb_version %} + {% include "elasticsearch-auth.yaml" ignore missing %} + {% else %} + - name: elasticsearch_credentials + workspaceKey: AUTH DETAILS NOT FOUND + {% endif %} diff --git a/codebundles/elasticsearch-generic-log-query/.test/Taskfile.yaml b/codebundles/elasticsearch-generic-log-query/.test/Taskfile.yaml new file mode 100644 index 00000000..37ab705b --- /dev/null +++ b/codebundles/elasticsearch-generic-log-query/.test/Taskfile.yaml @@ -0,0 +1,23 @@ +version: "3" + +tasks: + default: + desc: "Validate CodeBundle scripts and structure" + cmds: + - task: validate-bundle + + clean: + desc: "Remove local test artifacts" + cmds: + - rm -rf output workspaceInfo.yaml || true + + build-infra: + desc: "Static validation only (Elasticsearch cluster is user-supplied)" + cmds: + - task: validate-bundle + + validate-bundle: + desc: "Run bash syntax checks on CodeBundle shell scripts" + dir: . + cmds: + - ./validate-bundle.sh diff --git a/codebundles/elasticsearch-generic-log-query/.test/validate-bundle.sh b/codebundles/elasticsearch-generic-log-query/.test/validate-bundle.sh new file mode 100755 index 00000000..be5fff98 --- /dev/null +++ b/codebundles/elasticsearch-generic-log-query/.test/validate-bundle.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +# Static validation for the CodeBundle (no live Elasticsearch required). +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +echo "Validating shell scripts under ${ROOT}" +for f in "${ROOT}"/*.sh; do + if [[ -f "${f}" ]]; then + bash -n "${f}" + echo "OK ${f}" + fi +done +echo "All shell scripts passed bash -n." diff --git a/codebundles/elasticsearch-generic-log-query/README.md b/codebundles/elasticsearch-generic-log-query/README.md new file mode 100644 index 00000000..b86879aa --- /dev/null +++ b/codebundles/elasticsearch-generic-log-query/README.md @@ -0,0 +1,66 @@ +# Elasticsearch Generic Log Search + +This CodeBundle runs configurable Elasticsearch log searches using the HTTP Search API. The cluster base URL (`ELASTICSEARCH_BASE_URL`) is kept separate from the JSON query body (`ELASTICSEARCH_QUERY_BODY`) so you can reuse the same query across environments or swap endpoints without duplicating bundle logic. + +## Overview + +- **Endpoint reachability**: GET the configured base URL with optional basic auth or API key to confirm the HTTP API responds before running searches. +- **Generic search**: POST `ELASTICSEARCH_QUERY_BODY` to `/${ELASTICSEARCH_INDEX_PATTERN}/_search` and report total hits plus a bounded sample of hits. +- **Thresholds**: Optionally compare total hits to `SEARCH_THRESHOLD_MAX_HITS` and `SEARCH_THRESHOLD_MIN_HITS` and raise issues when out of range. +- **SLI**: Lightweight `GET /_cluster/health` probe producing a 0–1 score for periodic monitoring. + +## Configuration + +### Required Variables + +- `ELASTICSEARCH_BASE_URL`: Base URL for the Elasticsearch HTTP API, without path (for example `https://es.example.com:9200` or `http://localhost:9200`). +- `ELASTICSEARCH_INDEX_PATTERN`: Index name or pattern used in the Search path (for example `logs-*`, `filebeat-*`, or a concrete index name). +- `ELASTICSEARCH_QUERY_BODY`: JSON body for `POST .../_search` (query, `size`, `sort`, aggregations). Do not embed the cluster base URL in this JSON. + +### Optional Variables + +- `SEARCH_THRESHOLD_MAX_HITS`: When set to a non-empty integer string, an issue is raised if total hits exceed this value. +- `SEARCH_THRESHOLD_MIN_HITS`: When set to a non-empty integer string, an issue is raised if total hits are below this value. +- `REQUEST_TIMEOUT_SECONDS`: HTTP timeout in seconds for probes and search (default: `60`). + +### Secrets + +- `elasticsearch_credentials` (optional): JSON or key-value material containing any of: + - `ELASTICSEARCH_USERNAME` / `ELASTICSEARCH_PASSWORD` for HTTP Basic auth, and/or + - `ELASTICSEARCH_API_KEY` for `Authorization: ApiKey ...` (Elasticsearch API key header). + +Unauthenticated clusters are supported for lab use only. + +## Tasks Overview + +### Check Elasticsearch Endpoint Reachability + +Verifies the base URL is reachable over HTTP(S) and returns a 2xx response from a lightweight GET. Issues typically indicate connection failures (severity 2) or non-success HTTP (severity 3). + +### Run Generic Log Search and Summarize Results + +Executes `POST ${ELASTICSEARCH_BASE_URL}/${ELASTICSEARCH_INDEX_PATTERN}/_search` with `Content-Type: application/json` and surfaces total hits plus a sample of up to 20 hits in the report. Issues surface invalid query JSON, transport errors, or non-2xx HTTP from Elasticsearch. + +### Evaluate Search Result Thresholds + +Reads `search_summary.json` from the previous task and compares `total_hits` to optional min/max thresholds when configured. + +## Examples + +Reuse the same `ELASTICSEARCH_QUERY_BODY` against two clusters by changing only the base URL: + +```text +# Staging +ELASTICSEARCH_BASE_URL=https://staging-es.example.com:9200 +ELASTICSEARCH_INDEX_PATTERN=logs-* +ELASTICSEARCH_QUERY_BODY={"size":5,"query":{"match_all":{}},"sort":[{"@timestamp":"desc"}]} + +# Production (same query body) +ELASTICSEARCH_BASE_URL=https://prod-es.example.com:9200 +ELASTICSEARCH_INDEX_PATTERN=logs-* +ELASTICSEARCH_QUERY_BODY={"size":5,"query":{"match_all":{}},"sort":[{"@timestamp":"desc"}]} +``` + +## API Reference + +Search API: [Elasticsearch search](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html). diff --git a/codebundles/elasticsearch-generic-log-query/check-elasticsearch-endpoint.sh b/codebundles/elasticsearch-generic-log-query/check-elasticsearch-endpoint.sh new file mode 100755 index 00000000..ba489b92 --- /dev/null +++ b/codebundles/elasticsearch-generic-log-query/check-elasticsearch-endpoint.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Verifies HTTP(S) reachability of ELASTICSEARCH_BASE_URL (optional auth). +# Writes JSON array of issues to endpoint_check_issues.json +# ----------------------------------------------------------------------------- +: "${ELASTICSEARCH_BASE_URL:?Must set ELASTICSEARCH_BASE_URL}" +: "${REQUEST_TIMEOUT_SECONDS:=60}" + +OUTPUT_FILE="endpoint_check_issues.json" +issues_json='[]' + +# shellcheck source=/dev/null +load_credentials() { + if [[ -n "${elasticsearch_credentials:-}" ]]; then + local creds_raw + if [[ -f "${elasticsearch_credentials}" ]]; then + creds_raw=$(cat "${elasticsearch_credentials}") + else + creds_raw="${elasticsearch_credentials}" + fi + if echo "${creds_raw}" | jq -e . >/dev/null 2>&1; then + export ELASTICSEARCH_USERNAME + ELASTICSEARCH_USERNAME=$(echo "${creds_raw}" | jq -r '.ELASTICSEARCH_USERNAME // empty') + export ELASTICSEARCH_PASSWORD + ELASTICSEARCH_PASSWORD=$(echo "${creds_raw}" | jq -r '.ELASTICSEARCH_PASSWORD // empty') + export ELASTICSEARCH_API_KEY + ELASTICSEARCH_API_KEY=$(echo "${creds_raw}" | jq -r '.ELASTICSEARCH_API_KEY // empty') + fi + fi +} + +load_credentials + +BASE="${ELASTICSEARCH_BASE_URL%/}" +PROBE_URL="${BASE}/" + +curl_args=( + -sS + --max-time "${REQUEST_TIMEOUT_SECONDS}" + -o /tmp/es_probe_body.$$ + -w "%{http_code}" +) + +if [[ -n "${ELASTICSEARCH_API_KEY:-}" ]]; then + curl_args+=(-H "Authorization: ApiKey ${ELASTICSEARCH_API_KEY}") +elif [[ -n "${ELASTICSEARCH_USERNAME:-}" && -n "${ELASTICSEARCH_PASSWORD:-}" ]]; then + curl_args+=(-u "${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD}") +fi + +http_code="000" +curl_err="" +if ! http_code=$(curl "${curl_args[@]}" "${PROBE_URL}" 2>/tmp/es_probe_err.$$); then + curl_err=$(cat /tmp/es_probe_err.$$ || true) +fi +rm -f /tmp/es_probe_err.$$ + +body_preview="" +if [[ -f /tmp/es_probe_body.$$ ]]; then + body_preview=$(head -c 2048 /tmp/es_probe_body.$$ | tr -d '\0' || true) +fi +rm -f /tmp/es_probe_body.$$ + +echo "Elasticsearch endpoint probe: ${PROBE_URL}" +echo "HTTP status: ${http_code}" +if [[ -n "${body_preview}" ]]; then + echo "Response preview (first 2KB):" + echo "${body_preview}" +fi + +if [[ "${http_code}" == "000" ]]; then + issues_json=$(echo "${issues_json}" | jq \ + --arg title "Elasticsearch Endpoint Unreachable at \`${BASE}\`" \ + --arg hc "${http_code}" \ + --arg cerr "${curl_err}" \ + --arg severity "2" \ + --arg next_steps "Verify ELASTICSEARCH_BASE_URL, network paths, TLS, and firewall rules. Test with curl -v against the base URL." \ + '. += [{ + "title": $title, + "details": ("curl failed to complete (HTTP " + $hc + "). " + $cerr), + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') +elif [[ "${http_code}" -lt 200 || "${http_code}" -ge 300 ]]; then + issues_json=$(echo "${issues_json}" | jq \ + --arg title "Elasticsearch Endpoint Returned Non-Success HTTP ${http_code} for \`${BASE}\`" \ + --arg url "${PROBE_URL}" \ + --arg preview "${body_preview}" \ + --arg severity "3" \ + --arg next_steps "Confirm credentials, Elasticsearch is running, and the base URL targets the HTTP API (port 9200 for OSS/Elastic Cloud)." \ + '. += [{ + "title": $title, + "details": ("Expected 2xx from GET " + $url + ". Body preview: " + $preview), + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') +fi + +echo "${issues_json}" > "${OUTPUT_FILE}" +echo "Wrote ${OUTPUT_FILE}" +exit 0 diff --git a/codebundles/elasticsearch-generic-log-query/evaluate-search-thresholds.sh b/codebundles/elasticsearch-generic-log-query/evaluate-search-thresholds.sh new file mode 100755 index 00000000..c3d1b08a --- /dev/null +++ b/codebundles/elasticsearch-generic-log-query/evaluate-search-thresholds.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Compares total_hits in search_summary.json to SEARCH_THRESHOLD_MAX_HITS / +# SEARCH_THRESHOLD_MIN_HITS when set. Writes threshold_issues.json +# ----------------------------------------------------------------------------- +: "${SEARCH_THRESHOLD_MAX_HITS:=}" +: "${SEARCH_THRESHOLD_MIN_HITS:=}" + +SUMMARY_FILE="search_summary.json" +OUTPUT_FILE="threshold_issues.json" +issues_json='[]' + +if [[ ! -f "${SUMMARY_FILE}" ]]; then + issues_json=$(echo "${issues_json}" | jq \ + --arg title "Missing search_summary.json for threshold evaluation" \ + --arg details "Run Run Generic Log Search before evaluating thresholds." \ + --arg severity "3" \ + --arg next_steps "Execute the search task first so search_summary.json exists." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + echo "${issues_json}" > "${OUTPUT_FILE}" + exit 0 +fi + +total_hits=$(jq -r '(.total_hits // 0) | tonumber | floor' "${SUMMARY_FILE}") + +if [[ -n "${SEARCH_THRESHOLD_MAX_HITS}" ]]; then + if [[ "${total_hits}" -gt "${SEARCH_THRESHOLD_MAX_HITS}" ]]; then + issues_json=$(echo "${issues_json}" | jq \ + --arg title "Search Hit Count Exceeds Maximum Threshold" \ + --arg details "total_hits=${total_hits} max=${SEARCH_THRESHOLD_MAX_HITS}" \ + --arg severity "3" \ + --arg next_steps "Tighten the query, reduce matching documents, or raise SEARCH_THRESHOLD_MAX_HITS if expected." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + fi +fi + +if [[ -n "${SEARCH_THRESHOLD_MIN_HITS}" ]]; then + if [[ "${total_hits}" -lt "${SEARCH_THRESHOLD_MIN_HITS}" ]]; then + issues_json=$(echo "${issues_json}" | jq \ + --arg title "Search Hit Count Below Minimum Threshold" \ + --arg details "total_hits=${total_hits} min=${SEARCH_THRESHOLD_MIN_HITS}" \ + --arg severity "4" \ + --arg next_steps "Verify log ingestion, index pattern, time range in the query, or lower SEARCH_THRESHOLD_MIN_HITS." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + fi +fi + +echo "${issues_json}" > "${OUTPUT_FILE}" +echo "Threshold evaluation: total_hits=${total_hits} wrote ${OUTPUT_FILE}" +exit 0 diff --git a/codebundles/elasticsearch-generic-log-query/run-generic-log-search.sh b/codebundles/elasticsearch-generic-log-query/run-generic-log-search.sh new file mode 100755 index 00000000..bd2ece75 --- /dev/null +++ b/codebundles/elasticsearch-generic-log-query/run-generic-log-search.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# POST ELASTICSEARCH_QUERY_BODY to ${BASE}/${INDEX}/_search. Writes: +# search_summary.json — total_hits, sample_hits, http_code, error_message +# search_issues.json — issues JSON array for runbook +# ----------------------------------------------------------------------------- +: "${ELASTICSEARCH_BASE_URL:?Must set ELASTICSEARCH_BASE_URL}" +: "${ELASTICSEARCH_INDEX_PATTERN:?Must set ELASTICSEARCH_INDEX_PATTERN}" +: "${ELASTICSEARCH_QUERY_BODY:?Must set ELASTICSEARCH_QUERY_BODY}" +: "${REQUEST_TIMEOUT_SECONDS:=60}" + +SUMMARY_FILE="search_summary.json" +ISSUES_FILE="search_issues.json" +issues_json='[]' + +# shellcheck source=/dev/null +load_credentials() { + if [[ -n "${elasticsearch_credentials:-}" ]]; then + local creds_raw + if [[ -f "${elasticsearch_credentials}" ]]; then + creds_raw=$(cat "${elasticsearch_credentials}") + else + creds_raw="${elasticsearch_credentials}" + fi + if echo "${creds_raw}" | jq -e . >/dev/null 2>&1; then + export ELASTICSEARCH_USERNAME + ELASTICSEARCH_USERNAME=$(echo "${creds_raw}" | jq -r '.ELASTICSEARCH_USERNAME // empty') + export ELASTICSEARCH_PASSWORD + ELASTICSEARCH_PASSWORD=$(echo "${creds_raw}" | jq -r '.ELASTICSEARCH_PASSWORD // empty') + export ELASTICSEARCH_API_KEY + ELASTICSEARCH_API_KEY=$(echo "${creds_raw}" | jq -r '.ELASTICSEARCH_API_KEY // empty') + fi + fi +} + +load_credentials + +if ! echo "${ELASTICSEARCH_QUERY_BODY}" | jq -e . >/dev/null 2>&1; then + issues_json=$(echo "${issues_json}" | jq \ + --arg title "Invalid JSON in ELASTICSEARCH_QUERY_BODY" \ + --arg details "Body must be valid JSON for Content-Type: application/json." \ + --arg severity "2" \ + --arg next_steps "Fix ELASTICSEARCH_QUERY_BODY to be valid JSON (jq . validates)." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + echo "${issues_json}" > "${ISSUES_FILE}" + echo '{"total_hits":0,"sample_hits":[],"http_code":0,"error_message":"invalid query JSON"}' > "${SUMMARY_FILE}" + echo "Invalid query JSON" + exit 0 +fi + +BASE="${ELASTICSEARCH_BASE_URL%/}" +SEARCH_URL="${BASE}/${ELASTICSEARCH_INDEX_PATTERN}/_search" + +curl_args=( + -sS + --max-time "${REQUEST_TIMEOUT_SECONDS}" + -H "Content-Type: application/json" + -w "\n%{http_code}" +) + +if [[ -n "${ELASTICSEARCH_API_KEY:-}" ]]; then + curl_args+=(-H "Authorization: ApiKey ${ELASTICSEARCH_API_KEY}") +elif [[ -n "${ELASTICSEARCH_USERNAME:-}" && -n "${ELASTICSEARCH_PASSWORD:-}" ]]; then + curl_args+=(-u "${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD}") +fi + +if ! curl_out=$(echo "${ELASTICSEARCH_QUERY_BODY}" | curl "${curl_args[@]}" -X POST --data-binary @- "${SEARCH_URL}" 2>/tmp/es_search_err.$$); then + err=$(cat /tmp/es_search_err.$$ || true) + rm -f /tmp/es_search_err.$$ + issues_json=$(echo "${issues_json}" | jq \ + --arg title "Elasticsearch Search Request Failed for \`${SEARCH_URL}\`" \ + --arg details "curl error: ${err}" \ + --arg severity "2" \ + --arg next_steps "Verify URL, index pattern, auth, and cluster health." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + echo "${issues_json}" > "${ISSUES_FILE}" + jq -n \ + --arg err "${err}" \ + '{total_hits: 0, sample_hits: [], http_code: 0, error_message: $err}' > "${SUMMARY_FILE}" + echo "Search request failed" + exit 0 +fi +rm -f /tmp/es_search_err.$$ + +http_code=$(echo "${curl_out}" | tail -n1) +body=$(echo "${curl_out}" | sed '$d') + +if [[ ! "${http_code}" =~ ^[0-9]+$ ]]; then + http_code="000" + body="${curl_out}" +fi + +if [[ "${http_code}" -ge 200 && "${http_code}" -lt 300 ]]; then + echo "${body}" | jq -c \ + --arg hc "${http_code}" \ + '{ + total_hits: ((.hits.total | if type == "object" then .value else . end) // 0), + sample_hits: ([.hits.hits[]? | {_id: ._id, _index: ._index, _source: ._source}] | .[0:20]), + http_code: ($hc | tonumber), + error_message: "" + }' > "${SUMMARY_FILE}" +else + err_msg=$(echo "${body}" | head -c 4096 | tr -d '\0' || true) + issues_json=$(echo "${issues_json}" | jq \ + --arg title "Elasticsearch Search Returned HTTP ${http_code}" \ + --arg details "POST ${SEARCH_URL} failed. Body: ${err_msg}" \ + --arg severity "2" \ + --arg next_steps "Check index pattern, mappings, query syntax, and permissions." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + jq -n \ + --arg hc "${http_code}" \ + --arg em "${err_msg}" \ + '{total_hits: 0, sample_hits: [], http_code: ($hc | tonumber), error_message: $em}' > "${SUMMARY_FILE}" +fi + +echo "${issues_json}" > "${ISSUES_FILE}" + +echo "Search complete. Wrote ${SUMMARY_FILE} and ${ISSUES_FILE}" +exit 0 diff --git a/codebundles/elasticsearch-generic-log-query/runbook.robot b/codebundles/elasticsearch-generic-log-query/runbook.robot new file mode 100644 index 00000000..b315471a --- /dev/null +++ b/codebundles/elasticsearch-generic-log-query/runbook.robot @@ -0,0 +1,180 @@ +*** Settings *** +Documentation Runs configurable Elasticsearch log searches with the cluster base URL separated from the JSON query body so the same query can be reused across environments. +Metadata Author rw-codebundle-agent +Metadata Display Name Elasticsearch Generic Log Search +Metadata Supports Elasticsearch logs HTTP search + +Force Tags Elasticsearch logs search HTTP + +Library String +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform + +Suite Setup Suite Initialization + +*** Tasks *** +Check Elasticsearch Endpoint Reachability for `${ELASTICSEARCH_BASE_URL}` + [Documentation] Verifies HTTP(S) reachability and optional authentication to the configured Elasticsearch base URL before running searches. + [Tags] Elasticsearch logs access:read-only data:logs-config + + ${result}= RW.CLI.Run Bash File + ... bash_file=check-elasticsearch-endpoint.sh + ... env=${env} + ... secret__elasticsearch_credentials=${elasticsearch_credentials} + ... include_in_history=false + ... timeout_seconds=180 + ... show_in_rwl_cheatsheet=true + ... cmd_override=./check-elasticsearch-endpoint.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat endpoint_check_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for endpoint check 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=Elasticsearch HTTP API should be reachable with a 2xx response from the base URL + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report Endpoint check:\n${result.stdout} + +Run Generic Log Search and Summarize Results for `${ELASTICSEARCH_INDEX_PATTERN}` + [Documentation] POSTs ELASTICSEARCH_QUERY_BODY to the Search API for the configured index pattern and records hit counts plus a bounded sample for the report. + [Tags] Elasticsearch logs access:read-only data:logs + + ${result}= RW.CLI.Run Bash File + ... bash_file=run-generic-log-search.sh + ... env=${env} + ... secret__elasticsearch_credentials=${elasticsearch_credentials} + ... include_in_history=false + ... timeout_seconds=180 + ... show_in_rwl_cheatsheet=true + ... cmd_override=./run-generic-log-search.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat search_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for search 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=Search request should return HTTP 2xx with valid JSON for the configured index and query + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report Search summary:\n${result.stdout} + +Evaluate Search Result Thresholds for `${ELASTICSEARCH_INDEX_PATTERN}` + [Documentation] Optionally compares total hit counts from the last search against configured min and max thresholds and raises issues when breached. + [Tags] Elasticsearch logs access:read-only data:logs + + ${result}= RW.CLI.Run Bash File + ... bash_file=evaluate-search-thresholds.sh + ... env=${env} + ... include_in_history=false + ... timeout_seconds=120 + ... show_in_rwl_cheatsheet=false + ... cmd_override=./evaluate-search-thresholds.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat threshold_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for threshold 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=Hit count should remain within optional SEARCH_THRESHOLD_MIN_HITS and SEARCH_THRESHOLD_MAX_HITS when those are set + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + RW.Core.Add Pre To Report Threshold evaluation:\n${result.stdout} + +*** Keywords *** +Suite Initialization + TRY + ${elasticsearch_credentials}= RW.Core.Import Secret elasticsearch_credentials + ... type=string + ... description=Optional JSON with ELASTICSEARCH_USERNAME, ELASTICSEARCH_PASSWORD, and/or ELASTICSEARCH_API_KEY + ... pattern=\w* + Set Suite Variable ${elasticsearch_credentials} ${elasticsearch_credentials} + EXCEPT + Log elasticsearch_credentials secret not configured; searches may fail if the cluster requires auth. WARN + Set Suite Variable ${elasticsearch_credentials} ${EMPTY} + END + + ${ELASTICSEARCH_BASE_URL}= RW.Core.Import User Variable ELASTICSEARCH_BASE_URL + ... type=string + ... description=Base URL for the Elasticsearch HTTP API without path (e.g. https://es.example.com:9200). + ... pattern=\w* + ${ELASTICSEARCH_INDEX_PATTERN}= RW.Core.Import User Variable ELASTICSEARCH_INDEX_PATTERN + ... type=string + ... description=Index name or pattern for the Search API path (e.g. logs-*, filebeat-*). + ... pattern=\w* + ${ELASTICSEARCH_QUERY_BODY}= RW.Core.Import User Variable ELASTICSEARCH_QUERY_BODY + ... type=string + ... description=JSON body for POST _search (query, size, sort, aggregations). Must not include the cluster base URL. + ... pattern=\w* + ${SEARCH_THRESHOLD_MAX_HITS}= RW.Core.Import User Variable SEARCH_THRESHOLD_MAX_HITS + ... type=string + ... description=Optional maximum total hits; exceeding raises an issue when set. + ... pattern=\w* + ... default= + ${SEARCH_THRESHOLD_MIN_HITS}= RW.Core.Import User Variable SEARCH_THRESHOLD_MIN_HITS + ... type=string + ... description=Optional minimum total hits; falling below raises an issue when set. + ... pattern=\w* + ... default= + ${REQUEST_TIMEOUT_SECONDS}= RW.Core.Import User Variable REQUEST_TIMEOUT_SECONDS + ... type=string + ... description=HTTP client timeout in seconds for search and endpoint probes. + ... pattern=^\d+$ + ... default=60 + + Set Suite Variable ${ELASTICSEARCH_BASE_URL} ${ELASTICSEARCH_BASE_URL} + Set Suite Variable ${ELASTICSEARCH_INDEX_PATTERN} ${ELASTICSEARCH_INDEX_PATTERN} + Set Suite Variable ${ELASTICSEARCH_QUERY_BODY} ${ELASTICSEARCH_QUERY_BODY} + Set Suite Variable ${SEARCH_THRESHOLD_MAX_HITS} ${SEARCH_THRESHOLD_MAX_HITS} + Set Suite Variable ${SEARCH_THRESHOLD_MIN_HITS} ${SEARCH_THRESHOLD_MIN_HITS} + Set Suite Variable ${REQUEST_TIMEOUT_SECONDS} ${REQUEST_TIMEOUT_SECONDS} + + ${env}= Create Dictionary + ... ELASTICSEARCH_BASE_URL=${ELASTICSEARCH_BASE_URL} + ... ELASTICSEARCH_INDEX_PATTERN=${ELASTICSEARCH_INDEX_PATTERN} + ... ELASTICSEARCH_QUERY_BODY=${ELASTICSEARCH_QUERY_BODY} + ... SEARCH_THRESHOLD_MAX_HITS=${SEARCH_THRESHOLD_MAX_HITS} + ... SEARCH_THRESHOLD_MIN_HITS=${SEARCH_THRESHOLD_MIN_HITS} + ... REQUEST_TIMEOUT_SECONDS=${REQUEST_TIMEOUT_SECONDS} + Set Suite Variable ${env} ${env} diff --git a/codebundles/elasticsearch-generic-log-query/sli-elasticsearch-health.sh b/codebundles/elasticsearch-generic-log-query/sli-elasticsearch-health.sh new file mode 100755 index 00000000..6ca5df33 --- /dev/null +++ b/codebundles/elasticsearch-generic-log-query/sli-elasticsearch-health.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail +# Lightweight probe for SLI: prints 1 if GET _cluster/health returns 2xx, else 0. +: "${ELASTICSEARCH_BASE_URL:?Must set ELASTICSEARCH_BASE_URL}" +: "${REQUEST_TIMEOUT_SECONDS:=15}" + +load_credentials() { + if [[ -n "${elasticsearch_credentials:-}" ]]; then + local creds_raw + if [[ -f "${elasticsearch_credentials}" ]]; then + creds_raw=$(cat "${elasticsearch_credentials}") + else + creds_raw="${elasticsearch_credentials}" + fi + if echo "${creds_raw}" | jq -e . >/dev/null 2>&1; then + export ELASTICSEARCH_USERNAME + ELASTICSEARCH_USERNAME=$(echo "${creds_raw}" | jq -r '.ELASTICSEARCH_USERNAME // empty') + export ELASTICSEARCH_PASSWORD + ELASTICSEARCH_PASSWORD=$(echo "${creds_raw}" | jq -r '.ELASTICSEARCH_PASSWORD // empty') + export ELASTICSEARCH_API_KEY + ELASTICSEARCH_API_KEY=$(echo "${creds_raw}" | jq -r '.ELASTICSEARCH_API_KEY // empty') + fi + fi +} + +load_credentials + +BASE="${ELASTICSEARCH_BASE_URL%/}" +URL="${BASE}/_cluster/health?wait_for_status=yellow&timeout=5s" + +curl_args=( + -sS + --max-time "${REQUEST_TIMEOUT_SECONDS}" + -o /dev/null + -w "%{http_code}" +) + +if [[ -n "${ELASTICSEARCH_API_KEY:-}" ]]; then + curl_args+=(-H "Authorization: ApiKey ${ELASTICSEARCH_API_KEY}") +elif [[ -n "${ELASTICSEARCH_USERNAME:-}" && -n "${ELASTICSEARCH_PASSWORD:-}" ]]; then + curl_args+=(-u "${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD}") +fi + +http_code=$(curl "${curl_args[@]}" "${URL}" 2>/dev/null || echo "000") + +if [[ "${http_code}" =~ ^2[0-9][0-9]$ ]]; then + echo "1" +else + echo "0" +fi +exit 0 diff --git a/codebundles/elasticsearch-generic-log-query/sli.robot b/codebundles/elasticsearch-generic-log-query/sli.robot new file mode 100644 index 00000000..c464fb53 --- /dev/null +++ b/codebundles/elasticsearch-generic-log-query/sli.robot @@ -0,0 +1,62 @@ +*** Settings *** +Documentation Measures Elasticsearch cluster reachability via GET _cluster/health and produces a score between 0 (unreachable or non-2xx) and 1 (healthy response). +Metadata Author rw-codebundle-agent +Metadata Display Name Elasticsearch Generic Log Search +Metadata Supports Elasticsearch logs HTTP + +Suite Setup Suite Initialization + +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform + +*** Keywords *** +Suite Initialization + TRY + ${elasticsearch_credentials}= RW.Core.Import Secret elasticsearch_credentials + ... type=string + ... description=Optional JSON with ELASTICSEARCH_USERNAME, ELASTICSEARCH_PASSWORD, and/or ELASTICSEARCH_API_KEY + ... pattern=\w* + Set Suite Variable ${elasticsearch_credentials} ${elasticsearch_credentials} + EXCEPT + Set Suite Variable ${elasticsearch_credentials} ${EMPTY} + END + + ${ELASTICSEARCH_BASE_URL}= RW.Core.Import User Variable ELASTICSEARCH_BASE_URL + ... type=string + ... description=Base URL for the Elasticsearch HTTP API without path (e.g. https://es.example.com:9200). + ... pattern=\w* + ${REQUEST_TIMEOUT_SECONDS}= RW.Core.Import User Variable REQUEST_TIMEOUT_SECONDS + ... type=string + ... description=HTTP timeout in seconds for the SLI probe (keep low for fast SLI). + ... pattern=^\d+$ + ... default=15 + + Set Suite Variable ${ELASTICSEARCH_BASE_URL} ${ELASTICSEARCH_BASE_URL} + Set Suite Variable ${REQUEST_TIMEOUT_SECONDS} ${REQUEST_TIMEOUT_SECONDS} + + ${env}= Create Dictionary + ... ELASTICSEARCH_BASE_URL=${ELASTICSEARCH_BASE_URL} + ... REQUEST_TIMEOUT_SECONDS=${REQUEST_TIMEOUT_SECONDS} + Set Suite Variable ${env} ${env} + +*** Tasks *** +Probe Elasticsearch Cluster Health for `${ELASTICSEARCH_BASE_URL}` + [Documentation] Performs a lightweight GET _cluster/health request and maps a 2xx response to score 1, otherwise 0. + [Tags] Elasticsearch logs access:read-only data:logs-config + + ${result}= RW.CLI.Run Bash File + ... bash_file=sli-elasticsearch-health.sh + ... env=${env} + ... secret__elasticsearch_credentials=${elasticsearch_credentials} + ... include_in_history=false + ... timeout_seconds=30 + ... show_in_rwl_cheatsheet=false + ... cmd_override=./sli-elasticsearch-health.sh + + ${score}= Evaluate '''${result.stdout}'''.strip() + ${health_score}= Evaluate 1 if """${score}""" == "1" else 0 + RW.Core.Push Metric ${health_score} sub_name=cluster_reachable + RW.Core.Add to Report Elasticsearch SLI health score: ${health_score} + RW.Core.Push Metric ${health_score}