diff --git a/docs/GSMSV_RUNTIME_ALERTS.md b/docs/GSMSV_RUNTIME_ALERTS.md new file mode 100644 index 0000000..ef7338d --- /dev/null +++ b/docs/GSMSV_RUNTIME_ALERTS.md @@ -0,0 +1,50 @@ +# GSMSV 런타임 장애 알림 운영 가이드 + +이 도구는 배포가 끝난 뒤 GSMSV VM에서 MUDDA 운영 컨테이너의 상태와 오류 신호를 1분 주기로 확인하고 Discord에 요약 알림을 보내기 위한 최소 감시 구성이다. 상태 확인과 알림만 수행하며 컨테이너 재시작, Compose 변경, volume 조작, 자동 복구는 수행하지 않는다. + +## 감시 범위 + +- Compose project `mudda-prod`의 `app`, `postgres`, `redis` 컨테이너 존재·running·healthy 상태 +- `http://127.0.0.1:8080/actuator/health/readiness` 응답 +- 직전 검사 이후 app restart count 증가 +- 최근 검사 구간의 app `ERROR` 로그 +- 루트 디스크 사용률과 시스템 메모리 사용률 + +기본 임계치는 디스크 80% 경고/90% 위험, 메모리 85% 경고/90% 위험, readiness timeout 5초, 반복 알림 억제 10분이다. 설정은 스크립트의 환경변수로 조정할 수 있다. RAM 7.9GB·swap 없음 환경을 고려하지만 swap 생성이나 서버 자원 변경은 하지 않는다. + +health failure, 컨테이너 종료, restart 증가를 주요 신호로 삼고 ERROR 로그는 보조 신호로만 사용한다. 동일 fingerprint는 기본 10분 동안 억제하며 장애가 정상으로 돌아오면 복구 알림을 한 번 보낸다. 최초 정상 실행에서는 정상 알림을 보내지 않는다. + +## 설치 위치와 권한 + +저장소의 `ops/runtime/install.sh`를 서버에서 root로 실행하면 감시 파일을 `/usr/local/libexec/mudda-runtime-alert`, systemd unit을 `/etc/systemd/system`, 상태 파일을 `/var/lib/mudda/runtime-alerts`에 설치한다. Docker socket과 root:600 webhook 파일을 읽기 때문에 service는 root로 실행하지만, 읽기 전용 경로·NoNewPrivileges·PrivateTmp·보호 옵션으로 범위를 제한한다. Docker socket 접근 권한은 Docker daemon 전체 제어 권한에 준하므로 별도 운영 계정·호스트의 접근 통제를 유지해야 한다. + +Webhook은 GitHub Secret이 아니라 서버 전용 파일로 보관한다. + +```text +/etc/mudda/discord-alert.env +owner: root +mode: 600 +DISCORD_RUNTIME_WEBHOOK_URL=실제 값 +``` + +저장소의 `ops/runtime/discord-alert.env.example`에는 placeholder만 있다. 실제 URL을 문서·Issue·PR·로그에 붙여넣지 않는다. GitHub Secret은 서버 프로세스가 직접 읽을 수 없으며, 향후 CD가 값을 안전하게 전달할 확장 지점만 남겨 둔다. 배포 알림과 런타임 장애 알림은 서로 다른 webhook 사용을 권장한다. + +## systemd 실행과 검증 + +```bash +sudo ops/runtime/install.sh +sudoedit /etc/mudda/discord-alert.env +sudo chmod 600 /etc/mudda/discord-alert.env +sudo systemctl enable --now mudda-runtime-alert.timer +systemctl status mudda-runtime-alert.timer +systemctl list-timers mudda-runtime-alert.timer +journalctl -u mudda-runtime-alert.service -n 50 --no-pager +``` + +수동 확인은 `sudo systemctl start mudda-runtime-alert.service`로 수행한다. 저장소 fixture 테스트는 `ops/runtime/tests/test.sh`이며 실제 Docker나 webhook을 호출하지 않는다. `MUDDA_RUNTIME_NO_SEND=true`와 fixture 디렉터리를 사용해 정상·unhealthy·readiness 실패·restart 증가·ERROR·임계치·중복 억제·복구·마스킹을 검증한다. + +설정 해제는 `sudo ops/runtime/uninstall.sh`로 수행한다. 이 스크립트는 timer와 설치 파일만 제거하고 webhook·상태 파일은 보존한다. rollback은 unit을 제거하거나 이전 저장소 버전의 감시 파일을 재설치하는 범위이며 운영 컨테이너에는 명령을 보내지 않는다. + +Discord는 전체 로그 저장소가 아니라 요약 알림 채널이다. 알림은 최근 일부 로그만 최대 약 2,500자로 정제해 포함하며, password·token·JWT·Authorization·Cookie·AWS key·이메일 등 알려진 패턴을 마스킹한다. 마스킹은 완전한 개인정보 탐지를 보장하지 않으므로 전체 로그는 `docker compose -f /opt/mudda/docker-compose.prod.yml logs` 또는 향후 Loki/Grafana에서 확인한다. + +Webhook 미설정·Discord 장애는 감시 로그에 URL 없이 실패로 남고 운영 컨테이너 상태에는 영향을 주지 않는다. 실제 운영 Secret과 서버 통합 검증은 아직 수행하지 않았다. diff --git a/ops/runtime/discord-alert.env.example b/ops/runtime/discord-alert.env.example new file mode 100644 index 0000000..9d91bd7 --- /dev/null +++ b/ops/runtime/discord-alert.env.example @@ -0,0 +1,2 @@ +# Copy to /etc/mudda/discord-alert.env on the VM and replace the placeholder. +DISCORD_RUNTIME_WEBHOOK_URL=REPLACE_WITH_DISCORD_RUNTIME_WEBHOOK_URL diff --git a/ops/runtime/discord-runtime-notify.sh b/ops/runtime/discord-runtime-notify.sh new file mode 100755 index 0000000..ded27d8 --- /dev/null +++ b/ops/runtime/discord-runtime-notify.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +status="${1:-}" +[[ "$status" == incident || "$status" == recovery ]] || { echo "invalid runtime notification status" >&2; exit 2; } +webhook_url="${DISCORD_RUNTIME_WEBHOOK_URL:-}" +[[ -n "$webhook_url" ]] || { echo "runtime Discord webhook is not configured" >&2; exit 1; } +[[ "$webhook_url" =~ ^https://discord(app)?\.com/api/webhooks/ ]] || { echo "runtime Discord webhook has an invalid host" >&2; exit 1; } +command -v jq >/dev/null || { echo "jq is required for runtime notifications" >&2; exit 1; } +command -v curl >/dev/null || { echo "curl is required for runtime notifications" >&2; exit 1; } + +notify_dir="$(mktemp -d)" +payload_file="$notify_dir/payload.json" +config_file="$notify_dir/curl.conf" +error_file="$notify_dir/curl.err" +trap 'rm -rf "$notify_dir"' EXIT + +logs="$(printf '%s' "${RUNTIME_LOG_EXCERPT:-}" | MAX_CHARS=2500 "$(dirname "$0")/redact-log.sh")" +summary="$(printf '%s' "${RUNTIME_SUMMARY:-unknown}" | MAX_CHARS=1000 "$(dirname "$0")/redact-log.sh")" +if [[ "$status" == recovery ]]; then + title="MUDDA 운영 장애 복구" + color=5763719 + state_label="복구" +else + title="MUDDA 운영 장애 감지" + color=15548997 + state_label="장애" +fi + +jq -n \ + --arg title "$title" \ + --arg state "$state_label" \ + --arg time "${RUNTIME_TIME:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" \ + --arg server "${RUNTIME_SERVER:-GSMSV VM}" \ + --arg environment "${RUNTIME_ENVIRONMENT:-production}" \ + --arg service "${RUNTIME_SERVICE:-app, postgres, redis}" \ + --arg container "${RUNTIME_CONTAINER_STATUS:-unknown}" \ + --arg health "${RUNTIME_HEALTH_STATUS:-unknown}" \ + --arg restarts "${RUNTIME_RESTART_COUNTS:-unknown}" \ + --arg summary "$summary" \ + --arg logs "$logs" \ + --argjson color "$color" \ + '{embeds: [{title: $title, color: $color, fields: [ + {name: "상태", value: $state, inline: true}, + {name: "발생 시각", value: $time, inline: true}, + {name: "서버/환경", value: ($server + " / " + $environment), inline: false}, + {name: "영향 서비스", value: $service, inline: false}, + {name: "컨테이너 상태", value: $container, inline: false}, + {name: "Health / Restart", value: ($health + " / " + $restarts), inline: false}, + {name: "요약", value: $summary, inline: false}, + {name: "최근 로그", value: (if $logs == "" then "(없음)" else ("```\\n" + $logs + "\\n```") end), inline: false} + ]} ]}' > "$payload_file" + +{ + printf 'url = "' + printf '%s' "$webhook_url" + printf '"\nrequest = "POST"\nheader = "Content-Type: application/json"\ndata-binary = @%s\n' "$payload_file" +} > "$config_file" + +if ! curl --silent --show-error --fail --max-time 15 --config "$config_file" >/dev/null 2>"$error_file"; then + echo "runtime Discord notification failed" >&2 + exit 1 +fi diff --git a/ops/runtime/install.sh b/ops/runtime/install.sh new file mode 100755 index 0000000..3a1b28d --- /dev/null +++ b/ops/runtime/install.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +[[ "$(id -u)" -eq 0 ]] || { echo "run this installer as root" >&2; exit 1; } +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +install_dir=/usr/local/libexec/mudda-runtime-alert +state_dir=/var/lib/mudda/runtime-alerts +env_dir=/etc/mudda + +[[ -f "$repo_dir/mudda-runtime-alert.sh" && -f "$repo_dir/discord-runtime-notify.sh" ]] || { echo "runtime alert sources are missing" >&2; exit 1; } +install -d -m 0750 "$install_dir" "$state_dir" "$env_dir" +install -m 0750 "$repo_dir/mudda-runtime-alert.sh" "$install_dir/mudda-runtime-alert.sh" +install -m 0750 "$repo_dir/discord-runtime-notify.sh" "$install_dir/discord-runtime-notify.sh" +install -m 0750 "$repo_dir/redact-log.sh" "$install_dir/redact-log.sh" +install -m 0644 "$repo_dir/mudda-runtime-alert.service" /etc/systemd/system/mudda-runtime-alert.service +install -m 0644 "$repo_dir/mudda-runtime-alert.timer" /etc/systemd/system/mudda-runtime-alert.timer + +if [[ ! -e "$env_dir/discord-alert.env" ]]; then + install -m 0600 /dev/null "$env_dir/discord-alert.env" + echo "Created empty $env_dir/discord-alert.env; add DISCORD_RUNTIME_WEBHOOK_URL before enabling the timer." +fi +chmod 0600 "$env_dir/discord-alert.env" +systemctl daemon-reload +echo "Installed runtime alert files. Enable after configuring the root-owned webhook file:" +echo " systemctl enable --now mudda-runtime-alert.timer" diff --git a/ops/runtime/mudda-runtime-alert.service b/ops/runtime/mudda-runtime-alert.service new file mode 100644 index 0000000..4057211 --- /dev/null +++ b/ops/runtime/mudda-runtime-alert.service @@ -0,0 +1,22 @@ +[Unit] +Description=MUDDA runtime health and error alert check +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +ExecStart=/usr/local/libexec/mudda-runtime-alert/mudda-runtime-alert.sh +User=root +Group=root +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=full +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +ReadOnlyPaths=/opt/mudda /etc/mudda/discord-alert.env +ReadWritePaths=/var/lib/mudda/runtime-alerts +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native +TimeoutStartSec=30 diff --git a/ops/runtime/mudda-runtime-alert.sh b/ops/runtime/mudda-runtime-alert.sh new file mode 100755 index 0000000..39f7aa1 --- /dev/null +++ b/ops/runtime/mudda-runtime-alert.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +compose_project="${MUDDA_COMPOSE_PROJECT:-mudda-prod}" +compose_file="${MUDDA_COMPOSE_FILE:-/opt/mudda/docker-compose.prod.yml}" +compose_env="${MUDDA_COMPOSE_ENV:-/opt/mudda/.env.production}" +state_dir="${MUDDA_RUNTIME_STATE_DIR:-/var/lib/mudda/runtime-alerts}" +webhook_env="${MUDDA_RUNTIME_WEBHOOK_ENV:-/etc/mudda/discord-alert.env}" +fixture_dir="${MUDDA_RUNTIME_FIXTURE_DIR:-}" +no_send="${MUDDA_RUNTIME_NO_SEND:-false}" +disk_warn="${MUDDA_DISK_WARN_PERCENT:-80}" +disk_critical="${MUDDA_DISK_CRITICAL_PERCENT:-90}" +memory_warn="${MUDDA_MEMORY_WARN_PERCENT:-85}" +memory_critical="${MUDDA_MEMORY_CRITICAL_PERCENT:-90}" +cooldown="${MUDDA_ALERT_COOLDOWN_SECONDS:-600}" +readiness_timeout="${MUDDA_READINESS_TIMEOUT_SECONDS:-5}" +now="${MUDDA_RUNTIME_NOW:-$(date +%s)}" + +for value in "$now" "$cooldown" "$readiness_timeout" "$disk_warn" "$disk_critical" "$memory_warn" "$memory_critical"; do + [[ "$value" =~ ^[0-9]+$ ]] || { echo "runtime alert numeric configuration is invalid" >&2; exit 2; } +done +(( disk_critical >= disk_warn && memory_critical >= memory_warn )) || { echo "runtime alert thresholds are invalid" >&2; exit 2; } +mkdir -p "$state_dir" +exec 9>"$state_dir/.lock" +flock -n 9 || { echo "runtime alert check is already running"; exit 0; } + +state_file="$state_dir/state.json" +state='{}' +[[ -f "$state_file" ]] && state="$(cat "$state_file")" +last_check="$(jq -r '.last_check_epoch // 0' <<< "$state")" +had_problem="$(jq -r '.had_problem // false' <<< "$state")" +old_fingerprint="$(jq -r '.active_fingerprint // empty' <<< "$state")" +last_alert="$(jq -r '.last_alert_epoch // 0' <<< "$state")" +log_window=60 +if (( last_check > 0 && now > last_check )); then + log_window=$((now - last_check)) + (( log_window <= 600 )) || log_window=600 +fi + +fixture_read() { + local name="$1" fallback="${2:-}" + if [[ -n "$fixture_dir" && -f "$fixture_dir/$name" ]]; then cat "$fixture_dir/$name"; else printf '%s' "$fallback"; fi +} + +container_info() { + local service="$1" id + if [[ -n "$fixture_dir" ]]; then + fixture_read "$service.status" "missing|none|0" + return + fi + id="$(docker ps -a --filter "label=com.docker.compose.project=$compose_project" --filter "label=com.docker.compose.service=$service" --format '{{.ID}}' | head -n 1)" + [[ -n "$id" ]] || { printf 'missing|none|0'; return; } + docker inspect --format '{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}|{{.RestartCount}}' "$id" +} + +readiness_ok() { + if [[ -n "$fixture_dir" ]]; then [[ "$(fixture_read readiness fail)" == ok ]]; return; fi + curl --fail --silent --max-time "$readiness_timeout" http://127.0.0.1:8080/actuator/health/readiness >/dev/null +} + +metric() { + local name="$1" + if [[ -n "$fixture_dir" ]]; then fixture_read "$name" 0; return; fi + if [[ "$name" == disk_percent ]]; then df -P / | awk 'NR == 2 { gsub(/%/, "", $5); print $5 }'; else free | awk '/Mem:/ { printf "%d", ($2 - $7) * 100 / $2 }'; fi +} + +logs="" +if [[ -n "$fixture_dir" ]]; then + logs="$(fixture_read app.logs "")" +else + app_id="$(docker ps -a --filter "label=com.docker.compose.project=$compose_project" --filter 'label=com.docker.compose.service=app' --format '{{.ID}}' | head -n 1)" + [[ -z "$app_id" ]] || logs="$(docker logs --since "${log_window}s" --tail 100 "$app_id" 2>&1 || true)" +fi +logs="$(printf '%s' "$logs" | MAX_CHARS=2500 "$script_dir/redact-log.sh")" + +declare -a problems=() +declare -a service_rows=() +declare -a restart_rows=() +for service in app postgres redis; do + IFS='|' read -r status health restarts <<< "$(container_info "$service")" + service_rows+=("$service=$status/$health") + restart_rows+=("$service=$restarts") + [[ "$status" == running ]] || problems+=("$service container=$status") + [[ "$health" == healthy ]] || problems+=("$service health=$health") + previous_restart="$(jq -r --arg service "$service" '.restart_counts[$service] // 0' <<< "$state")" + [[ "$restarts" =~ ^[0-9]+$ && "$previous_restart" =~ ^[0-9]+$ && "$restarts" -le "$previous_restart" ]] || problems+=("$service restart_count=$restarts (previous=$previous_restart)") +done + +if ! readiness_ok; then problems+=("app readiness=failed"); fi +disk="$(metric disk_percent)" +memory="$(metric memory_percent)" +if (( disk >= disk_critical )); then problems+=("disk critical=${disk}%"); elif (( disk >= disk_warn )); then problems+=("disk warning=${disk}%"); fi +if (( memory >= memory_critical )); then problems+=("memory critical=${memory}%"); elif (( memory >= memory_warn )); then problems+=("memory warning=${memory}%"); fi +if printf '%s' "$logs" | grep -Eqi '(^|[^[:alpha:]])ERROR([^[:alpha:]]|$)'; then problems+=("app ERROR log detected"); fi + +problem_text="$(printf '%s\n' "${problems[@]:-}" | sed '/^$/d' | head -n 20)" +fingerprint="" +[[ -z "$problem_text" ]] || fingerprint="$(printf '%s' "$problem_text" | sha256sum | awk '{print $1}')" +service_text="$(IFS=', '; printf '%s' "${service_rows[*]}")" +restart_text="$(IFS=', '; printf '%s' "${restart_rows[*]}")" + +write_state() { + local next="$1" temp + temp="$(mktemp "$state_dir/state.XXXXXX")" + chmod 600 "$temp" + printf '%s\n' "$next" > "$temp" + mv -f "$temp" "$state_file" +} + +make_state() { + local active="$1" alerted="$2" had="$3" + jq -n --argjson now "$now" --arg fingerprint "$active" --argjson alerted "$alerted" --argjson had "$had" \ + --argjson counts "$(printf '%s\n' "${restart_rows[@]}" | jq -Rn '[inputs | split("=") | {key: .[0], value: (.[1] | tonumber)}] | from_entries')" \ + '{last_check_epoch: $now, active_fingerprint: (if $fingerprint == "" then null else $fingerprint end), last_alert_epoch: $alerted, had_problem: $had, restart_counts: $counts}' +} + +notify() { + local status="$1" + [[ "$no_send" == true ]] && { echo "runtime notification skipped (test mode)"; return 0; } + local webhook + [[ -f "$webhook_env" ]] || { echo "runtime webhook env file is missing" >&2; return 1; } + webhook="$(sed -n 's/^DISCORD_RUNTIME_WEBHOOK_URL=//p' "$webhook_env" | head -n 1)" + webhook="${webhook#\"}"; webhook="${webhook%\"}" + [[ -n "$webhook" ]] || { echo "runtime webhook is not configured" >&2; return 1; } + DISCORD_RUNTIME_WEBHOOK_URL="$webhook" \ + RUNTIME_SUMMARY="${problem_text:-normal}" \ + RUNTIME_LOG_EXCERPT="$logs" \ + RUNTIME_SERVICE="app, postgres, redis" \ + RUNTIME_CONTAINER_STATUS="$service_text" \ + RUNTIME_HEALTH_STATUS="readiness=$([[ -z "$fingerprint" ]] && echo healthy || echo failed); disk=${disk}%; memory=${memory}%" \ + RUNTIME_RESTART_COUNTS="$restart_text" \ + "$script_dir/discord-runtime-notify.sh" "$status" +} + +if [[ -n "$fingerprint" ]]; then + alert_epoch="$last_alert" + should_alert=true + if [[ "$fingerprint" == "$old_fingerprint" && $((now - last_alert)) -lt cooldown ]]; then should_alert=false; fi + if [[ "$should_alert" == true ]]; then + if notify incident; then alert_epoch="$now"; else echo "runtime incident notification could not be sent" >&2; fi + write_state "$(make_state "$fingerprint" "$alert_epoch" true)" + echo "runtime status: incident" + else + write_state "$(make_state "$fingerprint" "$alert_epoch" true)" + echo "runtime status: incident suppressed" + fi +elif [[ "$had_problem" == true ]]; then + if notify recovery; then + write_state "$(make_state "" "$last_alert" false)" + echo "runtime status: recovery" + else + write_state "$(make_state "$old_fingerprint" "$last_alert" true)" + echo "runtime recovery notification could not be sent" >&2 + fi +else + write_state "$(make_state "" "$last_alert" false)" + echo "runtime status: healthy" +fi diff --git a/ops/runtime/mudda-runtime-alert.timer b/ops/runtime/mudda-runtime-alert.timer new file mode 100644 index 0000000..f6349fd --- /dev/null +++ b/ops/runtime/mudda-runtime-alert.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Run MUDDA runtime alert check every minute + +[Timer] +OnBootSec=1min +OnUnitActiveSec=1min +AccuracySec=30s +Persistent=true +Unit=mudda-runtime-alert.service + +[Install] +WantedBy=timers.target diff --git a/ops/runtime/redact-log.sh b/ops/runtime/redact-log.sh new file mode 100755 index 0000000..76a708d --- /dev/null +++ b/ops/runtime/redact-log.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +max_chars="${MAX_CHARS:-2500}" +[[ "$max_chars" =~ ^[1-9][0-9]*$ ]] || max_chars=2500 + +sed -E \ + -e 's#https://discord(app)?\.com/api/webhooks/[[:alnum:]_/-]+#[REDACTED_URL]#gI' \ + -e 's/(Bearer[[:space:]]+)[^[:space:]]+/\1[REDACTED]/gI' \ + -e 's/((password|passwd|secret|token|jwt|authorization|cookie|api[-_]?key|client[-_]?secret|access[-_]?key|secret[-_]?key)[[:space:]]*[:=][[:space:]]*)[^,[:space:]]+/\1[REDACTED]/gI' \ + -e 's/AKIA[0-9A-Z]{16}/[REDACTED_AWS_KEY]/g' \ + -e 's/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/[REDACTED_JWT]/g' \ + -e 's/[[:alnum:]._%+-]+@[[:alnum:].-]+\.[A-Za-z]{2,}/[REDACTED_EMAIL]/g' \ + -e 's/::/ : :/g' | + awk -v limit="$max_chars" 'BEGIN { ORS="" } { remaining=limit-length(out); if (remaining > 0) out=out substr($0, 1, remaining) "\n" } END { printf "%s", substr(out, 1, limit) }' diff --git a/ops/runtime/tests/test.sh b/ops/runtime/tests/test.sh new file mode 100755 index 0000000..3dfccff --- /dev/null +++ b/ops/runtime/tests/test.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +test_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +runtime_dir="$(cd "$test_dir/.." && pwd)" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +bash -n "$runtime_dir"/*.sh "$runtime_dir"/tests/test.sh + +flock_available=true +if ! command -v flock >/dev/null; then + flock_available=false + test_bin="$tmp_dir/bin" + mkdir -p "$test_bin" + cat > "$test_bin/flock" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$test_bin/flock" + PATH="$test_bin:$PATH" +fi + +fixture="$tmp_dir/fixture" +state="$tmp_dir/state" +mkdir -p "$fixture" +for service in app postgres redis; do printf 'running|healthy|0\n' > "$fixture/$service.status"; done +printf 'ok\n' > "$fixture/readiness" +printf '10\n' > "$fixture/disk_percent" +printf '20\n' > "$fixture/memory_percent" +printf 'INFO healthy\n' > "$fixture/app.logs" + +run_check() { + MUDDA_RUNTIME_FIXTURE_DIR="$fixture" MUDDA_RUNTIME_STATE_DIR="$state" MUDDA_RUNTIME_NO_SEND=true MUDDA_RUNTIME_NOW="$1" \ + bash "$runtime_dir/mudda-runtime-alert.sh" +} +[[ "$(run_check 100)" == *'runtime status: healthy'* ]] + +printf 'running|unhealthy|0\n' > "$fixture/app.status" +printf 'ERROR password=secret Bearer abc\n' > "$fixture/app.logs" +[[ "$(run_check 200)" == *'runtime status: incident'* ]] +[[ "$(run_check 201)" == *'incident suppressed'* ]] +[[ "$(cat "$state/state.json")" != *'secret'* ]] + +printf 'running|healthy|0\n' > "$fixture/app.status" +printf 'INFO recovered\n' > "$fixture/app.logs" +[[ "$(run_check 900)" == *'runtime status: recovery'* ]] +[[ ! -e "$state/state.XXXXXX" ]] + +printf 'exited|none|0\n' > "$fixture/postgres.status" +[[ "$(run_check 1000)" == *'runtime status: incident'* ]] +printf 'running|healthy|0\n' > "$fixture/postgres.status" +printf 'running|unhealthy|0\n' > "$fixture/redis.status" +[[ "$(run_check 1100)" == *'runtime status: incident'* ]] +printf 'running|healthy|0\n' > "$fixture/redis.status" +printf 'fail\n' > "$fixture/readiness" +[[ "$(run_check 1200)" == *'runtime status: incident'* ]] +printf 'ok\n' > "$fixture/readiness" +printf 'running|healthy|1\n' > "$fixture/app.status" +[[ "$(run_check 1300)" == *'runtime status: incident'* ]] +printf 'running|healthy|0\n' > "$fixture/app.status" +printf '95\n' > "$fixture/disk_percent" +printf '92\n' > "$fixture/memory_percent" +[[ "$(run_check 1400)" == *'runtime status: incident'* ]] +printf '10\n' > "$fixture/disk_percent" +printf '20\n' > "$fixture/memory_percent" +[[ "$(run_check 2000)" == *'runtime status: recovery'* ]] + +fake_bin="$tmp_dir/bin" +mkdir -p "$fake_bin" +cat > "$fake_bin/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +config="" +while (($#)); do + [[ "$1" == --config ]] && config="$2" && shift 2 && continue + shift +done +cp "$(sed -n 's/^data-binary = @//p' "$config")" "${FAKE_RUNTIME_PAYLOAD:?}" +EOF +chmod +x "$fake_bin/curl" +payload="$tmp_dir/runtime-payload.json" +PATH="$fake_bin:$PATH" FAKE_RUNTIME_PAYLOAD="$payload" \ + DISCORD_RUNTIME_WEBHOOK_URL='https://discord.com/api/webhooks/test/token' \ + RUNTIME_SUMMARY=$'ERROR "quoted"\npassword=secret' \ + RUNTIME_LOG_EXCERPT=$'Bearer abc\nAKIA1234567890ABCDEF' \ + bash "$runtime_dir/discord-runtime-notify.sh" incident +jq -e '.embeds | length == 1 and (.[0].fields | length == 8)' "$payload" >/dev/null +! grep -q 'secret' "$payload" +! grep -q 'AKIA1234567890ABCDEF' "$payload" + +if DISCORD_RUNTIME_WEBHOOK_URL='' bash "$runtime_dir/discord-runtime-notify.sh" incident 2>"$tmp_dir/missing.err"; then exit 1; fi +! grep -q 'discord.com' "$tmp_dir/missing.err" + +if [[ "$flock_available" == true ]]; then + exec 9>"$state/.lock" + flock -n 9 + if MUDDA_RUNTIME_FIXTURE_DIR="$fixture" MUDDA_RUNTIME_STATE_DIR="$state" MUDDA_RUNTIME_NO_SEND=true MUDDA_RUNTIME_NOW=1000 \ + bash "$runtime_dir/mudda-runtime-alert.sh" >"$tmp_dir/lock.out"; then exit 1; fi + grep -q 'already running' "$tmp_dir/lock.out" + exec 9>&- +else + grep -q 'flock -n 9' "$runtime_dir/mudda-runtime-alert.sh" +fi + +echo "runtime alert tests passed" diff --git a/ops/runtime/uninstall.sh b/ops/runtime/uninstall.sh new file mode 100755 index 0000000..60579d8 --- /dev/null +++ b/ops/runtime/uninstall.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +[[ "$(id -u)" -eq 0 ]] || { echo "run this uninstaller as root" >&2; exit 1; } +systemctl disable --now mudda-runtime-alert.timer 2>/dev/null || true +systemctl stop mudda-runtime-alert.service 2>/dev/null || true +rm -f /etc/systemd/system/mudda-runtime-alert.timer /etc/systemd/system/mudda-runtime-alert.service +rm -f /usr/local/libexec/mudda-runtime-alert/mudda-runtime-alert.sh +rm -f /usr/local/libexec/mudda-runtime-alert/discord-runtime-notify.sh +rm -f /usr/local/libexec/mudda-runtime-alert/redact-log.sh +rmdir /usr/local/libexec/mudda-runtime-alert 2>/dev/null || true +systemctl daemon-reload +echo "Removed runtime alert service files; webhook and state files were preserved."