diff --git a/README.md b/README.md index 29e2746..e527824 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,9 @@ Designed so an AI agent can SSH into a Proxmox host and one-shot a SOC. The full --include-secrets-json Include raw credentials in result JSON (default: redacted) --mcp-bind-host HOST MCP SSE bind host (default: 127.0.0.1; use 0.0.0.0 to expose) +--health-report Opt-in read-only health summary after install (no deploy) +--health-report-out PATH + Health report JSON path (default: /health-report.json) --version Print version and exit ``` @@ -212,6 +215,7 @@ soc-stack/ │ │ ├── preflight.sh │ │ ├── lxc.sh │ │ ├── navigator.sh # ATT&CK Navigator coverage layer emitter +│ │ ├── health.sh # opt-in post-install health report │ │ └── data/ │ │ └── attack-coverage.json │ └── components/ @@ -284,6 +288,13 @@ Already-deployed components are skipped by the idempotency check, so a plain re- sudo bash install.sh --components all --dry-run ``` +**Opt-in post-install health report** (read-only; re-runs each component's `verify.sh`, checks LXC state, integration status, and artifact freshness; does not deploy or integrate): +```bash +sudo bash install.sh --health-report +# optional: sudo bash install.sh --health-report --components wazuh,misp +# schedule periodically yourself (cron/systemd timer) — off by default +``` + **Remove a single component:** ```bash sudo bash scripts/components/misp/destroy.sh diff --git a/docs/design/specs/2026-05-15-soc-stack-unification-design.md b/docs/design/specs/2026-05-15-soc-stack-unification-design.md index 8ca8891..5123e06 100644 --- a/docs/design/specs/2026-05-15-soc-stack-unification-design.md +++ b/docs/design/specs/2026-05-15-soc-stack-unification-design.md @@ -82,6 +82,8 @@ curl -sSL .../install.sh | sudo bash -s -- --manifest /root/soc-stack-manifest.j | `--force` | - | redeploy even if state shows complete | | `--no-integrate` | - | skip cross-component wiring | | `--non-interactive` | auto when stdin not a tty | hard-fail on any prompt | +| `--health-report` | - | opt-in read-only health summary; JSON to stdout; no deploy | +| `--health-report-out` | `/health-report.json` | health report output path | | `--version` | - | print version and exit | ### Orchestration sequence diff --git a/scripts/install.sh b/scripts/install.sh index 427c08b..f3bea55 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -52,6 +52,9 @@ OPT_INCLUDE_SECRETS_JSON="0" OPT_MCP_BIND_HOST="127.0.0.1" # shellcheck disable=SC2034 OPT_EARLY_EXIT=0 +# Opt-in post-install health report (issue #17). Default off. +OPT_HEALTH_REPORT="0" +OPT_HEALTH_REPORT_OUT="" SOC_WARNINGS=() usage() { @@ -83,6 +86,9 @@ Flags: --include-secrets-json Include raw credentials in result JSON (default: redacted) --mcp-bind-host HOST MCP SSE bind host (default: 127.0.0.1; use 0.0.0.0 to expose) + --health-report Opt-in read-only health summary (no deploy); JSON to stdout + --health-report-out PATH + Health report file (default: /health-report.json) --version Print version and exit EOF } @@ -92,7 +98,7 @@ parse_args() { local flag while [[ $# -gt 0 ]]; do case "$1" in - --components|--preset|--bridge|--storage|--ip-mode|--ip-range|--vlan|--vmid-start|--manifest|--state-dir|--json-out|--mcp-config-out|--navigator-out|--log-file|--mcp-bind-host) + --components|--preset|--bridge|--storage|--ip-mode|--ip-range|--vlan|--vmid-start|--manifest|--state-dir|--json-out|--mcp-config-out|--navigator-out|--log-file|--mcp-bind-host|--health-report-out) flag="$1" if [[ $# -lt 2 || "$2" == --* ]]; then printf 'missing value for %s\n' "${flag}" >&2 @@ -115,6 +121,7 @@ parse_args() { --navigator-out) OPT_NAVIGATOR_OUT="$2" ;; --log-file) OPT_LOG_FILE="$2" ;; --mcp-bind-host) OPT_MCP_BIND_HOST="$2" ;; + --health-report-out) OPT_HEALTH_REPORT_OUT="$2" ;; esac shift 2 ;; @@ -123,6 +130,7 @@ parse_args() { --no-integrate) OPT_NO_INTEGRATE="1"; shift ;; --non-interactive) OPT_NON_INTERACTIVE="1"; shift ;; --include-secrets-json) OPT_INCLUDE_SECRETS_JSON="1"; shift ;; + --health-report) OPT_HEALTH_REPORT="1"; shift ;; --version) printf 'soc-stack v%s\n' "${SOC_STACK_VERSION}"; OPT_EARLY_EXIT=1; return 0 ;; --help|-h) usage; OPT_EARLY_EXIT=1; return 0 ;; *) printf 'unknown flag: %s\n' "$1" >&2; usage >&2; return 1 ;; @@ -259,6 +267,7 @@ source_libs() { source "${LIB_DIR}/lxc.sh" source "${LIB_DIR}/manifest.sh" source "${LIB_DIR}/navigator.sh" + source "${LIB_DIR}/health.sh" } # Known components in canonical order @@ -701,6 +710,15 @@ integrate_all() { main() { parse_args "$@" || return 2 [[ "${OPT_EARLY_EXIT}" == "1" ]] && return 0 + + # Opt-in health report: read-only path, skips deploy/integrate entirely. + if [[ "${OPT_HEALTH_REPORT}" == "1" ]]; then + source_libs + check_root || return 1 + run_health_report + return $? + fi + validate_options || return 2 maybe_pick_components || return 2 source_libs diff --git a/scripts/lib/health.sh b/scripts/lib/health.sh new file mode 100644 index 0000000..8cd9c5e --- /dev/null +++ b/scripts/lib/health.sh @@ -0,0 +1,466 @@ +#!/usr/bin/env bash +# scripts/lib/health.sh - opt-in, read-only post-install health report +# Reuses component verify.sh + state files. Non-destructive: no deploy/integrate/destroy. +# Requires: jq, lib/logging.sh, lib/json-out.sh, lib/lxc.sh, lib/idempotency.sh + +: "${SOC_STATE_DIR:=/var/lib/soc-stack}" +: "${COMPONENTS_DIR:=}" +: "${HEALTH_ARTIFACT_MAX_AGE_SECONDS:=604800}" # 7 days +: "${HEALTH_REPORT_OUT:=${SOC_STATE_DIR}/health-report.json}" + +# health_iso_now - UTC ISO-8601 timestamp +health_iso_now() { + date -u +"%Y-%m-%dT%H:%M:%SZ" +} + +# health_components_dir - resolve components directory +health_components_dir() { + if [[ -n "${COMPONENTS_DIR}" ]]; then + printf '%s\n' "${COMPONENTS_DIR}" + return 0 + fi + local here + here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + printf '%s\n' "${here}/components" +} + +# health_list_components [csv-or-all] +# Prints component names (one per line) that have state files, in canonical order +# when possible. If csv is set and not "all", restricts to that list (state optional). +health_list_components() { + local input="${1:-all}" + local known=("wazuh" "thehive-cortex" "misp" "zeek-suricata" "dashboards" "mcp") + local selected=() + + if [[ "${input}" == "all" || -z "${input}" ]]; then + local name + for name in "${known[@]}"; do + [[ -f "$(state_file "${name}")" ]] && selected+=("${name}") + done + # Include any unexpected state files not in known list + local f base + if compgen -G "${SOC_STATE_DIR}/state/*.json" >/dev/null; then + for f in "${SOC_STATE_DIR}"/state/*.json; do + base="$(basename "${f}" .json)" + local found=0 k + for k in "${selected[@]+"${selected[@]}"}"; do + [[ "${k}" == "${base}" ]] && { found=1; break; } + done + for k in "${known[@]}"; do + [[ "${k}" == "${base}" ]] && { found=1; break; } + done + [[ "${found}" -eq 0 ]] && selected+=("${base}") + done + fi + else + local csv arr=() + csv="$(tr -d '[:space:]' <<< "${input}")" + IFS=',' read -r -a arr <<< "${csv}" + selected=("${arr[@]}") + fi + + local c + for c in "${selected[@]+"${selected[@]}"}"; do + [[ -n "${c}" ]] && printf '%s\n' "${c}" + done +} + +# health_check_artifact [required:1|0] +# Prints a JSON object describing artifact existence, parseability, and freshness. +health_check_artifact() { + local name="$1" + local path="$2" + local required="${3:-1}" + local exists="false" + local valid_json="false" + local mtime="" + local age_seconds="" + local status="missing" + local detail="" + + if [[ -f "${path}" ]]; then + exists="true" + if jq -e . "${path}" >/dev/null 2>&1; then + valid_json="true" + mtime="$(date -u -r "${path}" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || stat -c '%y' "${path}" 2>/dev/null | awk '{print $1"T"$2"Z"}' || true)" + local now epoch_mtime + now="$(date +%s)" + epoch_mtime="$(date -r "${path}" +%s 2>/dev/null || stat -c '%Y' "${path}" 2>/dev/null || echo "")" + if [[ -n "${epoch_mtime}" ]]; then + age_seconds=$((now - epoch_mtime)) + if (( age_seconds > HEALTH_ARTIFACT_MAX_AGE_SECONDS )); then + status="stale" + detail="artifact older than ${HEALTH_ARTIFACT_MAX_AGE_SECONDS}s" + else + status="ok" + fi + else + status="ok" + fi + else + status="malformed" + detail="file exists but is not valid JSON" + fi + else + if [[ "${required}" == "1" ]]; then + status="missing" + detail="required artifact not found" + else + status="absent" + detail="optional artifact not found" + fi + fi + + jq -n \ + --arg name "${name}" \ + --arg path "${path}" \ + --argjson exists "${exists}" \ + --argjson valid_json "${valid_json}" \ + --arg mtime "${mtime}" \ + --arg age "${age_seconds}" \ + --arg status "${status}" \ + --arg detail "${detail}" \ + --argjson required "$([[ "${required}" == "1" ]] && echo true || echo false)" \ + '{ + name: $name, + path: $path, + required: $required, + exists: $exists, + valid_json: $valid_json, + mtime: (if $mtime == "" then null else $mtime end), + age_seconds: (if $age == "" then null else ($age | tonumber) end), + status: $status, + detail: (if $detail == "" then null else $detail end) + }' +} + +# health_probe_component +# Non-destructive probe: state + LXC running + verify.sh (once) + integration.status. +# Prints one JSON object. Does not mutate state files. +health_probe_component() { + local component="$1" + local state_path + state_path="$(state_file "${component}")" + + local state_status="" + local vmid="" + local hostname="" + local integration_status="" + local integrations_json='[]' + + if [[ -f "${state_path}" ]]; then + if ! jq -e . "${state_path}" >/dev/null 2>&1; then + jq -n --arg name "${component}" --arg path "${state_path}" '{ + name: $name, + state_status: null, + state_file: $path, + lxc: { vmid: null, exists: false, running: false }, + verify: { status: "skipped", detail: "state file malformed" }, + integration: { status: "unknown" }, + integrations: [], + observed_status: "unhealthy", + detail: "state file is not valid JSON" + }' + return 0 + fi + state_status="$(jq -r '.status // empty' "${state_path}")" + vmid="$(jq -r '.lxc.vmid // empty' "${state_path}")" + hostname="$(jq -r '.lxc.hostname // empty' "${state_path}")" + integration_status="$(jq -r '.integration.status // empty' "${state_path}")" + integrations_json="$(jq '.integrations // []' "${state_path}")" + fi + + local lxc_exists="false" + local lxc_running="false" + local verify_status="skipped" + local verify_detail="" + local observed="unknown" + local detail="" + + if [[ -z "${state_status}" ]]; then + observed="not_deployed" + detail="no state file" + verify_detail="no state file" + elif [[ "${state_status}" != "deployed" ]]; then + observed="unhealthy" + detail="state status is ${state_status}" + verify_detail="component not in deployed state" + elif [[ -z "${vmid}" ]]; then + observed="unhealthy" + detail="deployed but lxc.vmid missing from state" + verify_detail="missing vmid" + else + if lxc_exists "${vmid}"; then + lxc_exists="true" + fi + if lxc_running "${vmid}"; then + lxc_running="true" + fi + + if [[ "${lxc_exists}" != "true" ]]; then + observed="unhealthy" + detail="LXC ${vmid} does not exist" + verify_detail="container missing" + elif [[ "${lxc_running}" != "true" ]]; then + # Running=false with failed/absent verify must not look healthy + observed="unhealthy" + detail="LXC ${vmid} is not running" + verify_status="skipped" + verify_detail="container not running" + else + local comps_dir verify_local remote_verify + comps_dir="$(health_components_dir)" + verify_local="${comps_dir}/${component}/verify.sh" + if [[ ! -f "${verify_local}" ]]; then + verify_status="skipped" + verify_detail="verify.sh not found" + observed="degraded" + detail="no verify.sh for ${component}" + else + remote_verify="/tmp/soc-stack-health-${component}-verify.sh" + if lxc_push_script "${vmid}" "${verify_local}" "${remote_verify}" \ + && pct exec "${vmid}" -- bash "${remote_verify}"; then + verify_status="pass" + verify_detail="verify.sh exited 0" + # Integration soft-failures are degraded, not healthy-wash + case "${integration_status}" in + failed) + observed="degraded" + detail="services healthy but integration.status=failed" + ;; + "") + observed="healthy" + detail="verify passed; integration status unset" + ;; + *) + observed="healthy" + detail="verify passed" + ;; + esac + else + # Critical false-healthy path: running CT + failed service probe + verify_status="fail" + verify_detail="verify.sh exited non-zero" + observed="unhealthy" + detail="LXC running but verify.sh failed" + fi + fi + fi + fi + + local integ_status_out="${integration_status:-unknown}" + [[ -z "${integration_status}" ]] && integ_status_out="unknown" + + jq -n \ + --arg name "${component}" \ + --arg state_status "${state_status}" \ + --arg state_file "${state_path}" \ + --arg vmid "${vmid}" \ + --arg hostname "${hostname}" \ + --argjson lxc_exists "${lxc_exists}" \ + --argjson lxc_running "${lxc_running}" \ + --arg verify_status "${verify_status}" \ + --arg verify_detail "${verify_detail}" \ + --arg integ "${integ_status_out}" \ + --argjson integrations "${integrations_json}" \ + --arg observed "${observed}" \ + --arg detail "${detail}" \ + '{ + name: $name, + state_status: (if $state_status == "" then null else $state_status end), + state_file: $state_file, + lxc: { + vmid: (if $vmid == "" then null else ($vmid | tonumber? // $vmid) end), + hostname: (if $hostname == "" then null else $hostname end), + exists: $lxc_exists, + running: $lxc_running + }, + verify: { status: $verify_status, detail: $verify_detail }, + integration: { status: $integ }, + integrations: $integrations, + observed_status: $observed, + detail: (if $detail == "" then null else $detail end) + }' +} + +# health_overall_status +# Derive overall status. Missing/malformed required artifacts prevent "healthy". +health_overall_status() { + local components_json="$1" + local artifacts_json="$2" + + local bad_artifacts + bad_artifacts="$(jq '[.[] | select(.required == true and .status != "ok" and .status != "stale")] | length' <<< "${artifacts_json}")" + local stale_artifacts + stale_artifacts="$(jq '[.[] | select(.required == true and .status == "stale")] | length' <<< "${artifacts_json}")" + + local unhealthy degraded healthy total + unhealthy="$(jq '[.[] | select(.observed_status == "unhealthy")] | length' <<< "${components_json}")" + degraded="$(jq '[.[] | select(.observed_status == "degraded")] | length' <<< "${components_json}")" + healthy="$(jq '[.[] | select(.observed_status == "healthy")] | length' <<< "${components_json}")" + total="$(jq 'length' <<< "${components_json}")" + + if [[ "${total}" -eq 0 ]]; then + printf 'unknown\n' + return 0 + fi + if [[ "${bad_artifacts}" -gt 0 ]]; then + # Missing or malformed result artifact is never healthy + printf 'unhealthy\n' + return 0 + fi + if [[ "${unhealthy}" -gt 0 ]]; then + printf 'unhealthy\n' + return 0 + fi + if [[ "${degraded}" -gt 0 || "${stale_artifacts}" -gt 0 ]]; then + printf 'degraded\n' + return 0 + fi + if [[ "${healthy}" -eq "${total}" ]]; then + printf 'healthy\n' + return 0 + fi + printf 'degraded\n' +} + +# emit_health_report [components_csv] [result_json_path] [mcp_config_path] +# Writes health JSON to output_path and prints the same JSON to stdout. +# Exit 0 if overall healthy, 1 if degraded/unhealthy/unknown. +emit_health_report() { + local out="${1:-${HEALTH_REPORT_OUT}}" + local components_csv="${2:-all}" + local result_json_path="${3:-/root/soc-stack.json}" + local mcp_config_path="${4:-/root/mcp-clients.json}" + + secure_parent_dir "${out}" + secure_dir "${SOC_STATE_DIR}" + + local checked_at + checked_at="$(health_iso_now)" + + local comp_json_items=() + local name + while IFS= read -r name; do + [[ -n "${name}" ]] || continue + comp_json_items+=("$(health_probe_component "${name}")") + done < <(health_list_components "${components_csv}") + + local components_array='[]' + if [[ ${#comp_json_items[@]} -gt 0 ]]; then + components_array="$(printf '%s\n' "${comp_json_items[@]}" | jq -s '.')" + fi + + local art_result art_mcp art_state + art_result="$(health_check_artifact "result_json" "${result_json_path}" 1)" + # MCP config is required only when mcp is among probed components + local mcp_required=0 + if jq -e '.[] | select(.name == "mcp")' <<< "${components_array}" >/dev/null 2>&1; then + mcp_required=1 + fi + art_mcp="$(health_check_artifact "mcp_config" "${mcp_config_path}" "${mcp_required}")" + if [[ -d "${SOC_STATE_DIR}/state" ]]; then + art_state="$(jq -n --arg path "${SOC_STATE_DIR}/state" '{ + name: "state_dir", + path: $path, + required: true, + exists: true, + valid_json: true, + mtime: null, + age_seconds: null, + status: "ok", + detail: null + }')" + else + art_state="$(jq -n --arg path "${SOC_STATE_DIR}/state" '{ + name: "state_dir", + path: $path, + required: true, + exists: false, + valid_json: false, + mtime: null, + age_seconds: null, + status: "missing", + detail: "state directory missing" + }')" + fi + + local artifacts_array + artifacts_array="$(jq -s '.' <<< "$(printf '%s\n' "${art_result}" "${art_mcp}" "${art_state}")")" + + local overall + overall="$(health_overall_status "${components_array}" "${artifacts_array}")" + + local summary + summary="$(jq -n \ + --argjson comps "${components_array}" \ + --argjson arts "${artifacts_array}" \ + --arg overall "${overall}" \ + '{ + overall_status: $overall, + component_counts: { + total: ($comps | length), + healthy: [$comps[] | select(.observed_status == "healthy")] | length, + degraded: [$comps[] | select(.observed_status == "degraded")] | length, + unhealthy: [$comps[] | select(.observed_status == "unhealthy")] | length, + not_deployed: [$comps[] | select(.observed_status == "not_deployed")] | length, + unknown: [$comps[] | select(.observed_status == "unknown")] | length + }, + artifact_counts: { + total: ($arts | length), + ok: [$arts[] | select(.status == "ok")] | length, + stale: [$arts[] | select(.status == "stale")] | length, + missing: [$arts[] | select(.status == "missing")] | length, + malformed: [$arts[] | select(.status == "malformed")] | length + } + }')" + + local report + report="$(jq -n \ + --arg checked_at "${checked_at}" \ + --arg soc_stack_version "${SOC_STACK_VERSION:-1.0.0}" \ + --arg overall "${overall}" \ + --argjson components "${components_array}" \ + --argjson artifacts "${artifacts_array}" \ + --argjson summary "${summary}" \ + '{ + version: "1.0", + kind: "health_report", + checked_at: $checked_at, + soc_stack_version: $soc_stack_version, + overall_status: $overall, + components: $components, + artifacts: $artifacts, + summary: $summary + }')" + + printf '%s\n' "${report}" > "${out}" + chmod 600 "${out}" 2>/dev/null || true + printf '%s\n' "${report}" + + case "${overall}" in + healthy) return 0 ;; + *) return 1 ;; + esac +} + +# run_health_report - orchestrator entry; uses OPT_* when set +run_health_report() { + local components_csv="${OPT_COMPONENTS:-all}" + local out="${HEALTH_REPORT_OUT:-${SOC_STATE_DIR}/health-report.json}" + if [[ -n "${OPT_HEALTH_REPORT_OUT:-}" ]]; then + out="${OPT_HEALTH_REPORT_OUT}" + fi + local result_json="${OPT_JSON_OUT:-/root/soc-stack.json}" + local mcp_config="${OPT_MCP_CONFIG_OUT:-/root/mcp-clients.json}" + + msg_info "running opt-in health report (read-only)" + local rc=0 + emit_health_report "${out}" "${components_csv}" "${result_json}" "${mcp_config}" || rc=$? + if [[ "${rc}" -eq 0 ]]; then + msg_ok "health report: healthy (written to ${out})" + else + msg_warn "health report: not healthy (written to ${out})" + fi + return "${rc}" +} diff --git a/tests/unit/fixtures/bin/pct b/tests/unit/fixtures/bin/pct index 4ceac1d..94f3ddf 100755 --- a/tests/unit/fixtures/bin/pct +++ b/tests/unit/fixtures/bin/pct @@ -5,6 +5,8 @@ # MOCK_PCT_LIST - what `pct list` prints # MOCK_PCT_EXEC - what `pct exec -- ` prints # MOCK_PCT_EXIT - exit code for `pct create`, `pct start`, etc. (default 0) +# MOCK_PCT_EXEC_EXIT - exit code for `pct exec` (default: MOCK_PCT_EXIT) +# MOCK_PCT_VERIFY_EXIT - exit code when exec runs a health/verify script (default: MOCK_PCT_EXEC_EXIT) # MOCK_PCT_CALLS_LOG - file to append call log (default $BATS_TEST_TMPDIR/pct-calls.log) CALLS_LOG="${MOCK_PCT_CALLS_LOG:-${BATS_TEST_TMPDIR:-/tmp}/pct-calls.log}" @@ -23,6 +25,15 @@ case "$1" in ;; exec) printf '%s\n' "${MOCK_PCT_EXEC:-}" + # pct exec -- + shift 2 || true + [[ "${1:-}" == "--" ]] && shift + local_cmd="$*" + # Only the verify script invocation (bash ...verify...), not chmod of that path + if [[ "${local_cmd}" == bash\ *verify* ]]; then + exit "${MOCK_PCT_VERIFY_EXIT:-${MOCK_PCT_EXEC_EXIT:-${MOCK_PCT_EXIT:-0}}}" + fi + exit "${MOCK_PCT_EXEC_EXIT:-${MOCK_PCT_EXIT:-0}}" ;; create|start|stop|destroy|push|set) : # accept silently diff --git a/tests/unit/test_health.bats b/tests/unit/test_health.bats new file mode 100644 index 0000000..386a1d6 --- /dev/null +++ b/tests/unit/test_health.bats @@ -0,0 +1,164 @@ +#!/usr/bin/env bats +# shellcheck disable=SC2030,SC2031 + +load helpers/load.bash + +setup() { + export SOC_STATE_DIR="${BATS_TEST_TMPDIR}/var/lib/soc-stack" + export SOC_LOG_FILE="${BATS_TEST_TMPDIR}/soc-stack.log" + export SOC_SECRETS_DIR="${SOC_STATE_DIR}/secrets" + export COMPONENTS_DIR="${REPO_ROOT}/scripts/components" + export MOCK_PCT_CALLS_LOG="${BATS_TEST_TMPDIR}/pct-calls.log" + mkdir -p "${SOC_STATE_DIR}/state" "${SOC_SECRETS_DIR}" + : > "${MOCK_PCT_CALLS_LOG}" + + source_lib logging + source "${REPO_ROOT}/scripts/lib/json-out.sh" + source_lib idempotency + source_lib lxc + source_lib health +} + +@test "health_check_artifact reports missing required artifact" { + run health_check_artifact result_json "${BATS_TEST_TMPDIR}/missing.json" 1 + assert_success + jq -e '.status == "missing" and .exists == false and .required == true' <<< "${output}" +} + +@test "health_check_artifact reports malformed JSON" { + local path="${BATS_TEST_TMPDIR}/bad.json" + printf 'not-json\n' > "${path}" + run health_check_artifact result_json "${path}" 1 + assert_success + jq -e '.status == "malformed" and .exists == true and .valid_json == false' <<< "${output}" +} + +@test "health_check_artifact reports ok for valid JSON" { + local path="${BATS_TEST_TMPDIR}/ok.json" + printf '{"version":"1.0"}\n' > "${path}" + run health_check_artifact result_json "${path}" 1 + assert_success + jq -e '.status == "ok" and .valid_json == true' <<< "${output}" +} + +@test "health_probe_component: running LXC with failed verify is unhealthy" { + state_set wazuh status "deployed" + state_set wazuh "lxc.vmid" 201 + state_set wazuh "lxc.hostname" "s3-wazuh" + state_set wazuh "integration.status" "integrated" + + export MOCK_PCT_STATUS=running + export MOCK_PCT_EXIT=0 + export MOCK_PCT_VERIFY_EXIT=1 + + run health_probe_component wazuh + assert_success + jq -e ' + .lxc.running == true + and .verify.status == "fail" + and .observed_status == "unhealthy" + ' <<< "${output}" +} + +@test "health_probe_component: running LXC with passing verify is healthy" { + state_set wazuh status "deployed" + state_set wazuh "lxc.vmid" 201 + state_set wazuh "integration.status" "integrated" + + export MOCK_PCT_STATUS=running + export MOCK_PCT_EXIT=0 + export MOCK_PCT_VERIFY_EXIT=0 + + run health_probe_component wazuh + assert_success + jq -e ' + .lxc.running == true + and .verify.status == "pass" + and .observed_status == "healthy" + ' <<< "${output}" +} + +@test "health_probe_component: stopped LXC is unhealthy without claiming verify pass" { + state_set wazuh status "deployed" + state_set wazuh "lxc.vmid" 201 + + export MOCK_PCT_STATUS=stopped + export MOCK_PCT_EXIT=0 + + run health_probe_component wazuh + assert_success + jq -e ' + .lxc.running == false + and .verify.status == "skipped" + and .observed_status == "unhealthy" + ' <<< "${output}" +} + +@test "emit_health_report: missing result artifact prevents overall healthy" { + state_set wazuh status "deployed" + state_set wazuh "lxc.vmid" 201 + state_set wazuh "integration.status" "integrated" + + export MOCK_PCT_STATUS=running + export MOCK_PCT_VERIFY_EXIT=0 + + local out="${BATS_TEST_TMPDIR}/health.json" + local missing_result="${BATS_TEST_TMPDIR}/no-result.json" + run emit_health_report "${out}" "wazuh" "${missing_result}" "${BATS_TEST_TMPDIR}/no-mcp.json" + [[ "$status" -eq 1 ]] + jq -e ' + .overall_status == "unhealthy" + and (.artifacts[] | select(.name == "result_json") | .status == "missing") + and (.components[] | select(.name == "wazuh") | .observed_status == "healthy") + ' "${out}" +} + +@test "emit_health_report: malformed result artifact prevents overall healthy" { + state_set wazuh status "deployed" + state_set wazuh "lxc.vmid" 201 + state_set wazuh "integration.status" "integrated" + + export MOCK_PCT_STATUS=running + export MOCK_PCT_VERIFY_EXIT=0 + + local out="${BATS_TEST_TMPDIR}/health.json" + local bad_result="${BATS_TEST_TMPDIR}/bad-result.json" + printf '{{{' > "${bad_result}" + + run emit_health_report "${out}" "wazuh" "${bad_result}" "${BATS_TEST_TMPDIR}/no-mcp.json" + [[ "$status" -eq 1 ]] + jq -e ' + .overall_status == "unhealthy" + and (.artifacts[] | select(.name == "result_json") | .status == "malformed") + ' "${out}" +} + +@test "emit_health_report: healthy stack with valid artifacts exits 0" { + state_set wazuh status "deployed" + state_set wazuh "lxc.vmid" 201 + state_set wazuh "integration.status" "integrated" + + export MOCK_PCT_STATUS=running + export MOCK_PCT_VERIFY_EXIT=0 + + local out="${BATS_TEST_TMPDIR}/health.json" + local result="${BATS_TEST_TMPDIR}/result.json" + printf '{"version":"1.0","components":[]}\n' > "${result}" + + run emit_health_report "${out}" "wazuh" "${result}" "${BATS_TEST_TMPDIR}/no-mcp.json" + assert_success + jq -e ' + .kind == "health_report" + and .overall_status == "healthy" + and (.components | length == 1) + ' "${out}" +} + +@test "health_overall_status: running verify-fail component is unhealthy" { + local comps arts + comps='[{"observed_status":"unhealthy"}]' + arts='[{"required":true,"status":"ok"}]' + run health_overall_status "${comps}" "${arts}" + assert_success + assert_output "unhealthy" +} diff --git a/tests/unit/test_orchestrator_flag_parsing.bats b/tests/unit/test_orchestrator_flag_parsing.bats index ade69b1..480dbbd 100644 --- a/tests/unit/test_orchestrator_flag_parsing.bats +++ b/tests/unit/test_orchestrator_flag_parsing.bats @@ -98,6 +98,28 @@ setup() { [[ "${output}${stderr:-}" == *"--ip-range"* ]] } +@test "parse_args sets OPT_HEALTH_REPORT=1 when --health-report is passed" { + parse_args --health-report + [[ "${OPT_HEALTH_REPORT}" == "1" ]] +} + +@test "parse_args sets OPT_HEALTH_REPORT_OUT" { + parse_args --health-report --health-report-out /tmp/health.json + [[ "${OPT_HEALTH_REPORT}" == "1" ]] + [[ "${OPT_HEALTH_REPORT_OUT}" == "/tmp/health.json" ]] +} + +@test "parse_args defaults OPT_HEALTH_REPORT to 0" { + parse_args + [[ "${OPT_HEALTH_REPORT}" == "0" ]] +} + +@test "parse_args fails on missing --health-report-out value" { + run parse_args --health-report-out + [[ "$status" -ne 0 ]] + [[ "${output}${stderr:-}" == *"missing value"* ]] +} + @test "interactive picker toggles selected components when tty is forced" { export SOC_TEST_FORCE_TTY=1 OPT_NON_INTERACTIVE="0"