mirror of
https://github.com/Security-Onion-Solutions/securityonion.git
synced 2026-11-15 17:04:11 +01:00
Two defects surfaced testing BRANCH=asasoup soup on a manager+heavynode grid:
1. The tiered-pass actions JSON was built from a multi-line heredoc. salt parses
'pillar=<value>' kwargs with a non-DOTALL regex, so the embedded newlines made
salt-run treat the whole token as a positional saltenv -- 'No matching salt
environment for environment pillar={...}' -- and the highstate never ran, leaving
the heavynode on the old version. Emit the actions JSON on a single line (matching
how so-push-drainer's json.dumps payload already works).
2. orch.push_batch's salt.state step reports 'No minions returned' (a failure) for a
tier whose compound target matches nothing, so any grid lacking a role (no receiver,
small grids) always logged a warning and returned rc=1 even when every present node
converged. Pre-check each tier with 'salt -C <tgt> --preview-target' and include only
tiers that match >=1 minion; exit cleanly if none match.
Verified live: heavynode highstated 3.2.0 -> 3.3.0 (427 states, 0 failed) and a
follow-up run skips the empty receiver/remainder tiers with rc=0.
190 lines
8.3 KiB
Bash
190 lines
8.3 KiB
Bash
#!/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.
|
|
|
|
# so-grid-highstate
|
|
# =================
|
|
# Drives a batched, role-tiered highstate across every non-manager minion in the
|
|
# grid. soup fires this (detached) after it finishes upgrading the manager so the
|
|
# rest of the grid converges immediately instead of waiting for its own scheduled
|
|
# highstate -- which, since the schedule moved from 15 minutes to 120 minutes
|
|
# (salt:schedule:highstate_interval_minutes), could otherwise leave nodes on the
|
|
# old version for up to ~2.5 hours (interval + splay) while the manager runs new code.
|
|
#
|
|
# Work is done by the existing orch.push_batch orchestration (salt/orch/push_batch.sls),
|
|
# the same runner the active-push drainer uses, so batching/queueing behavior matches.
|
|
# Tiers are dispatched in declaration order: searchnodes/heavynodes (Elasticsearch data
|
|
# nodes) first, then receivers, then everything else -- so the data tier converges before
|
|
# the ingest tier before sensors/fleet/idh/etc.
|
|
#
|
|
# When soup also upgraded Salt itself, remote minions must first highstate onto the new
|
|
# salt-minion package (top.sls gates every real state on G@saltversion, so a stale-version
|
|
# minion only gets salt.minion until it upgrades and reconnects). --salt-upgraded runs that
|
|
# preliminary pass and waits for the fleet to settle before the tiered pass.
|
|
#
|
|
# This is best-effort: soup has already completed by the time this runs, and the 120-minute
|
|
# scheduled highstate remains the backstop for any node that is offline or missed a batch.
|
|
|
|
LOG_FILE=/opt/so/log/salt/so-grid-highstate.log
|
|
LOCK_FILE=/opt/so/state/so-grid-highstate.lock
|
|
SETTLE_MAX_WAIT=${GRID_HIGHSTATE_SETTLE_WAIT:-900} # backstop for the post-salt-upgrade settle loop
|
|
SETTLE_INTERVAL=15
|
|
SETTLE_STABLE_CHECKS=3
|
|
|
|
BATCH=""
|
|
BATCH_WAIT=""
|
|
SALT_UPGRADED=false
|
|
REASON="manual"
|
|
|
|
log() {
|
|
echo "$(date '+%Y-%m-%d %H:%M:%S') | $*" | tee -a "$LOG_FILE"
|
|
}
|
|
|
|
usage() {
|
|
echo "Usage: so-grid-highstate [--batch <spec>] [--batch-wait <sec>] [--salt-upgraded] [--reason <text>]"
|
|
exit 1
|
|
}
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--batch) BATCH="$2"; shift 2 ;;
|
|
--batch-wait) BATCH_WAIT="$2"; shift 2 ;;
|
|
--salt-upgraded) SALT_UPGRADED=true; shift ;;
|
|
--reason) REASON="$2"; shift 2 ;;
|
|
-h|--help) usage ;;
|
|
*) echo "Unknown option: $1"; usage ;;
|
|
esac
|
|
done
|
|
|
|
mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$LOCK_FILE")"
|
|
|
|
# Serialize: a second invocation (e.g. two soups, or a manual run overlapping soup's)
|
|
# should not dispatch a competing set of batches.
|
|
exec 9>"$LOCK_FILE"
|
|
if ! flock -n 9; then
|
|
log "another so-grid-highstate is already running (lock $LOCK_FILE held); exiting"
|
|
exit 0
|
|
fi
|
|
|
|
# Resolve batch settings from the salt:auto_apply pillar when not overridden on the
|
|
# command line, falling back to the same defaults orch.push_batch/salt.defaults use.
|
|
if [ -z "$BATCH" ]; then
|
|
BATCH=$(salt-call --out=newline_values_only pillar.get salt:auto_apply:batch 2>/dev/null)
|
|
[ -z "$BATCH" ] && BATCH='25%'
|
|
fi
|
|
if [ -z "$BATCH_WAIT" ]; then
|
|
BATCH_WAIT=$(salt-call --out=newline_values_only pillar.get salt:auto_apply:batch_wait 2>/dev/null)
|
|
[ -z "$BATCH_WAIT" ] && BATCH_WAIT=15
|
|
fi
|
|
|
|
MINIONID=$(salt-call --local --out=newline_values_only grains.get id 2>/dev/null)
|
|
[ -z "$MINIONID" ] && MINIONID=$(cat /etc/salt/minion_id 2>/dev/null)
|
|
if [ -z "$MINIONID" ]; then
|
|
log "could not determine this minion's id; aborting"
|
|
exit 1
|
|
fi
|
|
|
|
# Single-node grids (eval/standalone/import with no other accepted keys) have nothing
|
|
# remote to push -- the manager already highstated during soup.
|
|
NUM_ACCEPTED=$(salt-key --out=json --list=accepted 2>/dev/null | jq -r '.minions | length' 2>/dev/null)
|
|
NUM_ACCEPTED=${NUM_ACCEPTED:-0}
|
|
if [ "$NUM_ACCEPTED" -le 1 ]; then
|
|
log "single node grid ($NUM_ACCEPTED accepted minion(s)); nothing to push (reason=$REASON)"
|
|
exit 0
|
|
fi
|
|
|
|
log "starting grid highstate: reason=$REASON minion=$MINIONID accepted=$NUM_ACCEPTED batch=$BATCH batch_wait=$BATCH_WAIT salt_upgraded=$SALT_UPGRADED"
|
|
|
|
# Dispatch a single synchronous orch.push_batch run for the given actions JSON.
|
|
# Synchronous is fine: soup launched us detached, so blocking here does not hold soup up.
|
|
dispatch() {
|
|
local desc="$1"
|
|
local actions="$2"
|
|
log "dispatching $desc"
|
|
if salt-run state.orchestrate orch.push_batch pillar="{\"actions\": $actions}" >>"$LOG_FILE" 2>&1; then
|
|
log "$desc dispatch completed (rc=0)"
|
|
else
|
|
log "WARNING: $desc dispatch returned rc=$?; nodes it missed will converge on the scheduled highstate"
|
|
fi
|
|
}
|
|
|
|
# Wait for the responsive minion set to stop growing. Used after the salt-upgrade pass,
|
|
# where minions restart salt-minion (~30s delayed, see salt/salt/minion/init.sls) and drop
|
|
# off the bus before reconnecting on the new version. Mirrors so-boot-mine-update's settle
|
|
# loop: settle on the reachable set rather than requiring up==accepted, since an operator may
|
|
# have intentionally powered a node off. Bounded by SETTLE_MAX_WAIT so a down node never stalls us.
|
|
wait_for_settle() {
|
|
local elapsed=0 prev=-1 stable=0 up=0
|
|
while [ "$elapsed" -lt "$SETTLE_MAX_WAIT" ]; do
|
|
up=$(salt-run manage.up --out=json 2>/dev/null \
|
|
| python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null)
|
|
up=${up:-0}
|
|
if [ "$up" -gt 0 ] && [ "$up" -eq "$prev" ]; then
|
|
stable=$((stable + 1))
|
|
[ "$stable" -ge "$SETTLE_STABLE_CHECKS" ] && break
|
|
else
|
|
stable=0
|
|
fi
|
|
prev=$up
|
|
sleep "$SETTLE_INTERVAL"
|
|
elapsed=$((elapsed + SETTLE_INTERVAL))
|
|
done
|
|
log "fleet settled at ${up} minions up after ${elapsed}s"
|
|
[ "$elapsed" -ge "$SETTLE_MAX_WAIT" ] && log "WARNING: ${SETTLE_MAX_WAIT}s settle backstop hit; proceeding with whoever is up"
|
|
}
|
|
|
|
# Pass 0: when Salt itself was upgraded, remote minions still on the old version only match
|
|
# top.sls's 'not G@saltversion' block (salt.minion, which performs the package upgrade). Push
|
|
# an untiered highstate so they upgrade+reconnect, then wait for them to come back before the
|
|
# real tiered pass applies the new version's states.
|
|
if [ "$SALT_UPGRADED" = "true" ]; then
|
|
dispatch "salt-upgrade pass (all remote minions)" \
|
|
"[{\"highstate\": true, \"tgt\": \"not $MINIONID\", \"tgt_type\": \"compound\", \"batch\": \"$BATCH\", \"batch_wait\": $BATCH_WAIT}]"
|
|
log "waiting for minions to reconnect on the new salt version"
|
|
wait_for_settle
|
|
fi
|
|
|
|
# Tiered pass: Elasticsearch data nodes first, then receivers, then the remainder. The last
|
|
# tier is defined as the complement of the earlier tiers (and of this manager) so coverage is
|
|
# exhaustive -- sensors, fleet, idh, desktop, hypervisor, and any future role are all included.
|
|
TIER_TGTS=(
|
|
"( *_searchnode or *_heavynode ) and not $MINIONID"
|
|
"*_receiver and not $MINIONID"
|
|
"not $MINIONID and not *_searchnode and not *_heavynode and not *_receiver"
|
|
)
|
|
|
|
# Count minions a compound target matches, using the master's key/cache data (no execution).
|
|
tier_count() {
|
|
salt --out=json -C "$1" --preview-target 2>/dev/null | jq 'length' 2>/dev/null
|
|
}
|
|
|
|
# Build the actions JSON, including only tiers that actually match minions. An empty target
|
|
# would make orch.push_batch's salt.state step return "No minions returned" -- a failure --
|
|
# even though nothing needed to run, and grids commonly lack a tier (no receiver, etc.).
|
|
# Keep the JSON on a single line: salt parses `pillar=<value>` kwargs with a non-DOTALL
|
|
# regex, so an embedded newline makes it treat the whole token as a positional saltenv
|
|
# instead ("No matching salt environment for environment 'pillar=...'").
|
|
actions=""
|
|
for tgt in "${TIER_TGTS[@]}"; do
|
|
n=$(tier_count "$tgt"); n=${n:-0}
|
|
if [ "$n" -ge 1 ]; then
|
|
[ -n "$actions" ] && actions="$actions, "
|
|
actions="$actions{\"highstate\": true, \"tgt\": \"$tgt\", \"tgt_type\": \"compound\", \"batch\": \"$BATCH\", \"batch_wait\": $BATCH_WAIT}"
|
|
log "tier matched $n minion(s): $tgt"
|
|
else
|
|
log "tier matched 0 minions, skipping: $tgt"
|
|
fi
|
|
done
|
|
|
|
if [ -z "$actions" ]; then
|
|
log "no remote minions matched any tier; nothing to push (reason=$REASON)"
|
|
exit 0
|
|
fi
|
|
dispatch "tiered pass (searchnodes/heavynodes -> receivers -> remainder)" "[$actions]"
|
|
|
|
log "grid highstate complete (reason=$REASON)"
|
|
exit 0
|