diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 556d58ac..616fc869 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,3 +131,29 @@ jobs: - name: Test working-directory: ./realtime run: go test -race ./... + + infra: + name: Infra (ops 스크립트) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # 운영 알림·헬스 감시는 셸이라 컴파일러도 타입체커도 없다. 그런데 이 스크립트들은 + # **조용히 깨지는 종류**다 — 만드는 동안 실제로 두 번 깨졌고 둘 다 눈에 안 띄었다: + # · 임베디드 파이썬 따옴표가 깨져 판정이 빈 값 → 감시가 그냥 통과 + # · `[ -n "$x" ] && notify` 단락으로 정상인데 exit 1 → cron 이 실패로 봄 + # selftest 는 로컬 수신기로만 보낸다(팀 채널로 아무것도 나가지 않는다). + - name: Shell syntax + run: | + for f in infra/ops/*.sh infra/backup/backup.sh; do + bash -n "$f" || exit 1 + done + + - name: ShellCheck + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck + shellcheck -S warning infra/ops/notify.sh infra/ops/health-check.sh \ + infra/ops/selftest.sh infra/backup/backup.sh + + - name: Self test + run: bash infra/ops/selftest.sh diff --git a/infra/ops/README.md b/infra/ops/README.md index 59ee2df2..2ec66205 100644 --- a/infra/ops/README.md +++ b/infra/ops/README.md @@ -19,6 +19,7 @@ |---|---| | `notify.sh` | 보내는 곳 한 군데. `ops_notify <제목> <본문>` | | `health-check.sh` | 헬스 폴링 + **상태가 바뀔 때만** 알림 | +| `selftest.sh` | 위 둘의 자가 테스트. 로컬 수신기로만 보낸다 — **팀 채널로 아무것도 안 나간다** | `infra/backup/backup.sh` 도 `notify.sh` 를 소스해서 실패를 알린다. @@ -56,13 +57,19 @@ aggregate 에서 빠져 있어서(informational — "복구 수단이 없다"와 `OPS_ALERT_WEBHOOK_URL` → 없으면 `DISCORD_WEBHOOK_URL`(현재 PR 알림 봇과 같은 채널). 운영 알림을 따로 받고 싶으면 전용 웹훅을 `.env` 에 넣으면 그쪽으로 옮겨간다. -## 검증 방법 (팀 채널로 보내지 않고) +**cron 은 정상일 때 0 으로 끝나야 한다.** 감시 도구가 정상 상태에서 실패 코드를 내면 +"이 cron 은 원래 그래" 가 되고, 진짜 실패도 같이 무시된다. 실제로 `[ -n "$previous" ] && +ops_notify ...` 로 썼다가 첫 실행마다 exit 1 이 나던 것을 잡았다 — 단락 평가의 마지막 +상태가 그대로 종료 코드가 된다. + +## 검증 ```bash -# 로컬 수신기를 띄우고 그쪽으로 보낸다 -python3 -m http.server 8897 & # 실제로는 POST 를 받는 핸들러 필요 -OPS_ALERT_WEBHOOK_URL=http://127.0.0.1:8897/hook ./health-check.sh +bash infra/ops/selftest.sh # 14개 케이스, python3 + curl 만 필요 ``` +CI 의 `Infra (ops 스크립트)` 잡이 `bash -n` · `shellcheck -S warning` · selftest 를 돌린다. +셸은 컴파일러도 타입체커도 없고, 이 스크립트들은 **조용히 깨지는 종류**라서 CI 가 대신 본다. + 웹훅 **유효성**은 게시 없이 확인할 수 있다 — Discord 웹훅 URL 에 `GET` 하면 이름과 -channel_id 를 돌려준다. +channel_id 를 돌려준다(`http=200`, `name=…`). diff --git a/infra/ops/health-check.sh b/infra/ops/health-check.sh index 82051ac3..2a5300ba 100755 --- a/infra/ops/health-check.sh +++ b/infra/ops/health-check.sh @@ -56,7 +56,18 @@ fi if [ "$current" = "UP" ]; then # 첫 실행(previous 없음)에 "복구됨"을 보내지 않는다 — 처음부터 정상인 게 정상이다. - [ -n "$previous" ] && ops_notify ok "헬스 복구 (이전: $previous)" "$detail" + # + # `[ -n "$previous" ] && ops_notify ...` 로 쓰면 안 된다. previous 가 비었을 때 + # && 가 단락되어 **스크립트가 1로 끝난다** — 정상인데 cron 이 실패로 보고, + # 매 첫 실행마다 메일/로그 소음이 난다. 감시 도구가 정상 상태에서 실패 코드를 + # 내면 "이 cron 이 원래 그래" 가 되고, 진짜 실패도 같이 무시된다. + if [ -n "$previous" ]; then + ops_notify ok "헬스 복구 (이전: $previous)" "$detail" + fi else ops_notify fail "헬스 이상: $current" "$detail" fi + +# 알림 전송 실패가 이 스크립트의 종료 코드를 오염시키지 않게 명시적으로 끝낸다. +# (ops_notify 는 항상 0 을 돌려주지만, 마지막 명령의 상태에 의존하지 않는다.) +exit 0 diff --git a/infra/ops/selftest.sh b/infra/ops/selftest.sh new file mode 100755 index 00000000..4b1314e2 --- /dev/null +++ b/infra/ops/selftest.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# infra/ops 자가 테스트. **팀 채널로 아무것도 보내지 않는다** — 로컬 수신기로만 보낸다. +# +# 왜 레포에 두는가 — 이 스크립트들을 만들면서 실제로 두 번 깨졌고 둘 다 조용한 실패였다: +# 1. 임베디드 파이썬 따옴표 이스케이프가 깨져 판정이 빈 값 → 감시가 그냥 통과 +# 2. `[ -n "$previous" ] && ops_notify ...` 단락으로 정상인데 exit 1 → cron 이 실패로 봄 +# 셸 스크립트는 "돌려보면 되겠지" 로 넘어가기 쉬운데, 이 둘은 돌려봐도 눈에 안 띈다. +# +# 사용: bash infra/ops/selftest.sh (python3, curl 필요) +set -u +set +m # 리스너를 죽일 때 "Terminated" 잡 제어 메시지가 결과에 섞이지 않게 +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PORT="${SELFTEST_PORT:-8896}" +RECV=$(mktemp); HEALTH=$(mktemp); STATE=$(mktemp -u) +pass=0; fail=0 + +check() { # check <설명> <기대> <실제> + if [ "$2" = "$3" ]; then printf ' ok %s\n' "$1"; pass=$((pass+1)) + else printf ' FAIL %s (기대=%s 실제=%s)\n' "$1" "$2" "$3"; fail=$((fail+1)); fi +} + +RECV="$RECV" HEALTH="$HEALTH" PORT="$PORT" python3 - <<'PY' & +import http.server, socketserver, os +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = open(os.environ["HEALTH"], "rb").read() + self.send_response(200); self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body) + def do_POST(self): + n = int(self.headers.get("Content-Length", 0)) + with open(os.environ["RECV"], "a") as f: + f.write(self.rfile.read(n).decode() + "\n") + self.send_response(204); self.end_headers() + def log_message(self, *a): pass +socketserver.TCPServer.allow_reuse_address = True +socketserver.TCPServer(("127.0.0.1", int(os.environ["PORT"])), H).serve_forever() +PY +LISTENER=$! +disown $LISTENER 2>/dev/null || true # 종료 시 셸이 "Terminated" 를 찍지 않게 +trap 'kill $LISTENER 2>/dev/null; rm -f "$RECV" "$HEALTH" "$STATE"' EXIT +sleep 1 + +export OPS_ALERT_WEBHOOK_URL="http://127.0.0.1:$PORT/hook" +export OPS_ENV_FILE=/dev/null +export HEALTH_URL="http://127.0.0.1:$PORT/health" +export OPS_STATE_FILE="$STATE" + +sent() { local n=0; [ -s "$RECV" ] && n=$(grep -c . "$RECV"); printf '%s' "$n"; } +UP='{"status":"UP","components":{"database":{"status":"UP"},"backup":{"status":"UP"}}}' +DEGRADED='{"status":"UP","components":{"database":{"status":"UP"},"backup":{"status":"DOWN"}}}' + +echo "[notify.sh]" +. ./notify.sh +ops_notify fail '따옴표 "큰" 작은'"'"' 백슬래시 \x' '두 줄 +째' +check 'JSON 이 깨지지 않는다' 1 "$(python3 -c " +import json,sys +try: + json.loads(open('$RECV').readline()); print(1) +except Exception: print(0)")" +: > "$RECV" +( unset OPS_ALERT_WEBHOOK_URL DISCORD_WEBHOOK_URL; . ./notify.sh; ops_notify fail x y ) +check '웹훅 없으면 무발송' 0 "$(sent)" +( export OPS_ALERT_WEBHOOK_URL="http://127.0.0.1:9/dead"; . ./notify.sh; set -e + ops_notify fail x y ) >/dev/null 2>&1 +check '죽은 엔드포인트가 호출자를 죽이지 않는다' 0 $? + +echo "[health-check.sh]" +: > "$RECV"; rm -f "$STATE" +echo "$UP" > "$HEALTH" +./health-check.sh; check '첫 실행 정상: exit 0' 0 $? +check '첫 실행 정상: 무발송' 0 "$(sent)" +./health-check.sh; check '정상 반복: exit 0' 0 $? +check '정상 반복: 무발송' 0 "$(sent)" + +echo "$DEGRADED" > "$HEALTH" +./health-check.sh; check '집계 UP·backup DOWN 포착: exit 0' 0 $? +check '집계 UP·backup DOWN 포착: 1건' 1 "$(sent)" +./health-check.sh +check '같은 이상 반복: 발송 늘지 않음' 1 "$(sent)" + +echo "$UP" > "$HEALTH" +./health-check.sh; check '복구: exit 0' 0 $? +check '복구: 2건' 2 "$(sent)" + +# 리스너를 죽이는 대신 죽은 포트를 가리킨다 — kill 하면 셸이 "Terminated" 를 +# 결과 사이에 끼워 넣어 읽기 나빠진다(리스너는 EXIT trap 이 정리한다). +HEALTH_URL="http://127.0.0.1:9/health" ./health-check.sh +check '도달 불가: exit 0' 0 $? +check '도달 불가: UNREACHABLE 로 기록' UNREACHABLE "$(cat "$STATE")" + +echo +echo "통과 $pass, 실패 $fail" +[ "$fail" -eq 0 ]