#!/bin/bash # Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one # or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at # https://securityonion.net/license; you may not use this file except in compliance with the # Elastic License 2.0. # Put Telegraf metrics storage back in service on a grid where pg_partman # maintenance stalled: raises premake, discards the rows stranded in default # partitions, restarts so-postgres if pg_cron's launcher is dead, and runs # maintenance once. Healthy grids are reported and left alone. # # Usage: so-telegraf-repair [--check] [--yes] [--no-restart] # --check Report health and change nothing. # --yes Skip the confirmation prompt (for soup and other automation). # --no-restart Never restart so-postgres, even if pg_cron's launcher is dead. # # Exit status: # 0 healthy, or repair completed # 1 repair is needed (--check only) # 2 cannot run here: so-postgres, so_telegraf or pg_partman is missing set -e # Matches p_premake in telegraf.conf's create_parent template. PREMAKE=7 JOB_NAME=telegraf-partman-maintenance CHECK_ONLY=false ASSUME_YES=false NO_RESTART=false usage() { sed -n '/^# Usage:/,/^# 2 /p' "$0" | sed 's/^# \?//'; } while [[ $# -gt 0 ]]; do case "$1" in --check|--dry-run) CHECK_ONLY=true ;; --yes|-y) ASSUME_YES=true ;; --no-restart) NO_RESTART=true ;; -h|--help) usage; exit 0 ;; *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; esac shift done skip() { echo "$*"; exit 2; } psql_tg() { docker exec -i so-postgres psql -U postgres -d so_telegraf "$@"; } psql_pg() { docker exec -i so-postgres psql -U postgres -d postgres "$@"; } # query_to_xml so the per-table row counts need no helper function installed. REPORT=" WITH parents AS ( SELECT pc.parent_table, pc.premake, split_part(pc.parent_table, '.', 1) AS sch, split_part(pc.parent_table, '.', 2) AS tbl FROM partman.part_config pc WHERE pc.parent_table LIKE 'telegraf.%' ), children AS ( SELECT p.parent_table, max(to_date(substring(c.relname FROM '_p(\d{8})\$'), 'YYYYMMDD')) AS newest_child FROM parents p JOIN pg_class pt ON pt.oid = p.parent_table::regclass JOIN pg_inherits i ON i.inhparent = pt.oid JOIN pg_class c ON c.oid = i.inhrelid WHERE pg_get_expr(c.relpartbound, c.oid) <> 'DEFAULT' GROUP BY p.parent_table ), defaults AS ( SELECT p.parent_table, p.premake, format('%I.%I', p.sch, p.tbl || '_default') AS default_table, to_regclass(format('%I.%I', p.sch, p.tbl || '_default')) AS default_oid FROM parents p ) SELECT d.parent_table, c.newest_child, (c.newest_child - current_date) AS days_ahead, d.premake, CASE WHEN d.default_oid IS NULL THEN NULL ELSE (xpath('/row/cnt/text()', query_to_xml(format('SELECT count(*) AS cnt FROM %s', d.default_table), false, true, '')))[1]::text::bigint END AS default_rows, CASE WHEN d.default_oid IS NULL THEN NULL ELSE pg_size_pretty(pg_total_relation_size(d.default_oid)) END AS default_size FROM defaults d LEFT JOIN children c ON c.parent_table = d.parent_table ORDER BY 1 " docker ps --format '{{.Names}}' | grep -qx so-postgres \ || skip "so-postgres is not running; nothing to repair." docker exec so-postgres psql -U postgres -tAc \ "SELECT 1 FROM pg_database WHERE datname='so_telegraf'" | grep -q 1 \ || skip "The so_telegraf database does not exist; Telegraf is not writing to Postgres." psql_tg -tAc "SELECT 1 FROM pg_extension WHERE extname='pg_partman'" | grep -q 1 \ || skip "pg_partman is not installed in so_telegraf; nothing to repair." parents=$(psql_tg -tAc \ "SELECT count(*) FROM partman.part_config WHERE parent_table LIKE 'telegraf.%'") stranded=$(psql_tg -tAc "SELECT coalesce(sum(default_rows), 0) FROM ( $REPORT ) t") behind=$(psql_tg -tAc \ "SELECT count(*) FROM ( $REPORT ) t WHERE coalesce(days_ahead, -1) < 1") # premake < 7, or infinite_time_partitions off: without the latter partman # refuses to premake forward across the gap the stall left behind. misconfigured=$(psql_tg -tAc \ "SELECT count(*) FROM partman.part_config WHERE parent_table LIKE 'telegraf.%' AND (premake < $PREMAKE OR NOT infinite_time_partitions)") # Both columns are matched because which one carries the launcher's name varies # with the pg_cron version. launcher=$(psql_pg -tAc \ "SELECT count(*) FROM pg_stat_activity WHERE backend_type ILIKE '%pg_cron%' OR application_name ILIKE '%pg_cron%'") # so_telegraf before the postgres state lands, postgres after. cron_db=$(docker exec so-postgres psql -U postgres -tAc \ "SELECT current_setting('cron.database_name', true)" | tr -d '[:space:]') last_run=never if [[ -n "$cron_db" ]]; then last_run=$(docker exec so-postgres psql -U postgres -d "$cron_db" -tAc \ "SELECT coalesce(max(d.start_time)::text, 'never') FROM cron.job_run_details d JOIN cron.job j USING (jobid) WHERE j.jobname = '$JOB_NAME'" 2>/dev/null | tr -d '[:space:]' || echo unknown) [[ -n "$last_run" ]] || last_run=never fi # A grid that has never written a metric has nothing to recover, and an empty # cron_db means pg_cron is not loaded at all, which no restart fixes. restart_needed=false [[ "$launcher" -eq 0 && "$parents" -gt 0 && -n "$cron_db" ]] && restart_needed=true repair_needed=false [[ "$stranded" -gt 0 ]] && repair_needed=true [[ "$behind" -gt 0 ]] && repair_needed=true [[ "$misconfigured" -gt 0 ]] && repair_needed=true $restart_needed && repair_needed=true echo "Telegraf partition status:" psql_tg -c "$REPORT" echo "Rows stranded in default partitions: $stranded" echo "pg_cron metadata database: ${cron_db:-unset}" echo "pg_cron launcher running: $([[ "$launcher" -gt 0 ]] && echo yes || echo no)" echo "Last $JOB_NAME run: $last_run" echo if ! $repair_needed; then echo "Telegraf partitions are healthy. Nothing to do." exit 0 fi if $CHECK_ONLY; then echo "Repair is needed:" [[ "$stranded" -gt 0 ]] && echo " * $stranded row(s) stranded in default partitions" [[ "$behind" -gt 0 ]] && echo " * $behind parent(s) with no partition for the current window" [[ "$misconfigured" -gt 0 ]] && echo " * $misconfigured parent(s) with stale partman settings" $restart_needed && echo " * pg_cron's launcher is dead; maintenance is not running at all" echo echo "Re-run without --check to repair." exit 1 fi if [[ "$stranded" -gt 0 ]] && ! $ASSUME_YES; then echo "This will permanently discard the $stranded stranded row(s) above." $restart_needed && ! $NO_RESTART && \ echo "so-postgres will also be restarted, which briefly interrupts SOC." [[ -t 0 ]] || { echo "Not a terminal; re-run with --yes to confirm." >&2; exit 2; } read -r -p "Continue? [y/N] " answer [[ "$answer" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } fi if [[ "$misconfigured" -gt 0 ]]; then echo "Reconciling partman settings on $misconfigured parent(s)." # GREATEST so an operator who raised premake further keeps their value. psql_tg -v ON_ERROR_STOP=1 -c \ "UPDATE partman.part_config SET premake = GREATEST(premake, $PREMAKE), infinite_time_partitions = true WHERE parent_table LIKE 'telegraf.%'" fi if [[ "$stranded" -gt 0 ]]; then echo "Clearing default partitions." # One transaction: Telegraf is still writing, so a default emptied without # its partition in place is refilled before maintenance can attach one. psql_tg -v ON_ERROR_STOP=1 <<'EOSQL' DO $$ DECLARE r record; BEGIN FOR r IN SELECT pc.parent_table, format('%I.%I', n.nspname, c.relname) AS default_table FROM partman.part_config pc JOIN pg_class p ON p.oid = pc.parent_table::regclass JOIN pg_inherits i ON i.inhparent = p.oid JOIN pg_class c ON c.oid = i.inhrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE pc.parent_table LIKE 'telegraf.%' AND pg_get_expr(c.relpartbound, c.oid) = 'DEFAULT' LOOP EXECUTE format('TRUNCATE TABLE %s', r.default_table); PERFORM partman.create_partition_time( r.parent_table, ARRAY[date_trunc('day', now())]::timestamptz[]); END LOOP; END $$; EOSQL fi if $restart_needed; then if $NO_RESTART; then echo "WARNING: pg_cron's launcher is dead and --no-restart was given." echo " Maintenance will not run on its own until so-postgres is restarted." else echo "Restarting so-postgres to revive pg_cron's launcher." docker restart so-postgres >/dev/null for _ in $(seq 1 60); do docker exec so-postgres pg_isready -U postgres -q 2>/dev/null && break sleep 2 done docker exec so-postgres pg_isready -U postgres -q \ || { echo "so-postgres did not come back; check 'docker logs so-postgres'." >&2; exit 1; } fi fi echo "Running partition maintenance." # so_admin.telegraf_maintenance() only exists once the postgres state has landed. psql_tg -v ON_ERROR_STOP=1 <<'EOSQL' SELECT CASE WHEN to_regproc('so_admin.telegraf_maintenance') IS NOT NULL THEN 'true' ELSE 'false' END AS has_proc \gset \if :has_proc CALL so_admin.telegraf_maintenance(); \else CALL partman.run_maintenance_proc(); \endif EOSQL echo echo "Telegraf partition status after repair:" psql_tg -c "$REPORT" echo "The $JOB_NAME job runs hourly at :17. Confirm it fired with:" echo " so-telegraf-repair --check"