From e715887b7d5c344ee8eb67215450bc068d2e66c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EB=82=99=ED=9B=88?= Date: Fri, 31 Jul 2026 01:22:51 +0900 Subject: [PATCH] =?UTF-8?q?Codex=20=EC=9E=91=EC=97=85=20=EC=A7=80=EC=8B=9C?= =?UTF-8?q?=EC=84=9C:=20=EB=B0=B1=EC=97=85=20=ED=98=84=ED=99=A9=C2=B7?= =?UTF-8?q?=ED=9C=B4=EB=A9=B4=20=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EC=A0=90?= =?UTF-8?q?=EA=B2=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 디스크 감시와 같은 문제 구조다 — 조용히 실패하고, 안 보면 모른다. 7-A 백업 현황 HestiaCP 자동 백업은 cron 으로 돌고 세대 수만큼 자동 정리되지만, 특정 계정만 조용히 빠져도 아무도 모른다. 계정별 세대 수·경과일과 '백업이 아예 없는 계정' 목록을 낸다. 전체가 도는 것처럼 보여도 일부가 빠진 경우를 잡는 게 핵심이다. 7-B 휴면 사이트 접근 로그 90일 없음 + 파일 180일 변경 없음 → 신호 2개일 때만 후보로 올린다. 캐시·로그·업로드는 자동 갱신되므로 파일 검사에서 제외한다. 핵심 판단 — 오판 방향을 한쪽으로 고정한다 살아있는 사이트를 '휴면' 이라고 하면 안 되고, 놓치는 건 괜찮다. 계절성·내부용· 봇만 오는 사이트가 섞이기 때문이다. 그래서 신호 1개로는 올리지 않고, 로그 로테이션으로 mtime 이 최근이 되면 '살아있다' 쪽으로 판정되게 뒀다. 문구도 '정리 후보' 가 아니라 '확인 권장' 이고, 삭제 기능(PR #8)으로 가는 동선을 만들지 말라고 명시했다. 한 번 더 사람이 판단해야 한다. 검증 — 가짜 환경(계정 3개·사이트 4개)으로 실제 실행 백업 3세대/1세대40일전/없음 계정을 각각 정확히 분류 활성 · 200일방치 · 로그없고파일오래됨 · 로그없지만파일최신 4가지 중 마지막(신호 1개)이 후보에서 제외되는 것까지 확인 검증 중 결함 2건을 잡아 고쳤다 1) -newermt "-180 days" 는 이식성이 없다(bfs 등이 상대시간을 거부). POSIX -mtime 으로 교체. 2) -not -path '*/tmp/*' 가 상위 경로까지 걸러 살아있는 사이트를 휴면으로 오판했다. 제외 경로를 "$WR" 기준 절대경로로 고정. 둘 다 되돌리지 말라고 함정에 적었다. 용량은 이미 scan_cache 에 있으므로 서버에서 다시 재지 않고 UI 에서 조인한다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WxEiSzFMdeAzvohAQ39nyJ --- .claude/plans/2026-07-31-upkeep-SPEC.md | 300 ++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 .claude/plans/2026-07-31-upkeep-SPEC.md diff --git a/.claude/plans/2026-07-31-upkeep-SPEC.md b/.claude/plans/2026-07-31-upkeep-SPEC.md new file mode 100644 index 0000000..6a2b666 --- /dev/null +++ b/.claude/plans/2026-07-31-upkeep-SPEC.md @@ -0,0 +1,300 @@ +# 개발 스펙 — 백업 현황·휴면 사이트 점검 (Codex 작업 지시서) + +작성 2026-07-31. **구현 담당: Codex.** 판단이 끝난 확정 스펙이다. + +- 대상 브랜치: `dashboard-impl`(PR #11) 위에 이어서 작업 +- 선행: [dashboard-SPEC.md](2026-07-30-dashboard-SPEC.md) · [PHASE5](2026-07-30-dashboard-PHASE5.md) · [PHASE6](2026-07-30-dashboard-PHASE6.md) + +--- + +## 0. 배경 + +운영 중 나온 요청 2가지다. + +1. **백업이 실제로 돌고 있는지 알 수 없다.** HestiaCP 자동 백업은 cron 으로 돌고 세대 수만큼 + 자동 정리되지만, **특정 계정만 조용히 실패**해도 아무도 모른다. 정작 필요할 때 없으면 끝이다. +2. **오래 방치된 사이트를 찾고 싶다.** 디스크만 차지하고 아무도 안 쓰는 사이트가 쌓인다. + +디스크 감시와 같은 문제 구조다 — **조용히 실패하고, 안 보면 모른다.** + +--- + +## 1. 작업 규칙 + +- 이 문서에 없는 판단은 하지 말고 질문한다. **Phase 7-A → 7-B 순서로 커밋을 나눈다.** +- 빌드 `cargo build` / 테스트 `cargo test` / 릴리스 `cargo build --release` +- 주석·UI 문자열·커밋 메시지는 한국어. + +### 절대 규칙 (선행 스펙에서 이어짐) + +1. **대시보드가 자동으로 SSH 를 쏘지 않는다.** 이 점검도 **버튼을 눌렀을 때만** 실행한다. +2. **읽기 전용이다.** 이 스크립트는 어떤 파일도 지우거나 고치지 않는다. + `rm`·`v-delete-`·`DROP` 이 들어가면 안 되고, 테스트로 강제한다. +3. **휴면 후보는 "삭제 대상"이 아니라 "확인 대상"이다.** §3-2 를 반드시 지킬 것. +4. 자격증명을 소스·테스트에 넣지 않는다. + +--- + +## 2. Phase 7-A — 백업 현황 + +### 2-1. `ops.rs` 신규 + +```rust +/// 백업 현황 + 휴면 사이트 점검 (SSH, sudo, 읽기 전용). +/// 백업 누락 계정·마지막 백업 시각과, 오래 방치된 사이트 후보를 한 번에 조사한다. +pub fn build_upkeep_check(s: &Settings) -> Result +``` + +SSH 1회로 둘 다 수집한다(§3 과 같은 스크립트). 조회를 나누지 말 것. + +### 2-2. 수집 항목 + +| 마커 | 뜻 | +|---|---| +| `HM_BK_CRON` | 자동 백업 cron 등록 여부(`1`/`0`) | +| `HM_BK_KEEP` | 보관 세대 수(`BACKUPS` 설정값) | +| `HM_BK_TOTAL` | `/backup` 의 `.tar` 개수 | +| `HM_BK_SIZE` | `/backup` 총 용량 | +| `HM_BK_USERS` | HestiaCP 계정 수 | +| `HM_BK_MISSING` | **백업이 하나도 없는 계정 목록**(공백 구분) | +| `HM_BK_NEWEST` | 가장 최근 백업 시각(epoch) | +| `HM_BK_STALEST` | `<계정> <일수>` — 최신 백업이 가장 오래된 계정 | +| `HM_BKU <계정> <세대수> <며칠전>` | 계정별 한 줄 | + +**핵심은 `HM_BK_MISSING` 과 `HM_BKU` 다.** 전체가 잘 도는 것처럼 보여도 특정 계정만 +빠져 있는 경우를 잡는다. + +--- + +## 3. Phase 7-B — 휴면 사이트 점검 + +### 3-1. 판정 기준 + +두 신호를 보고 **2개 이상**일 때만 후보로 올린다. + +| 신호 | 판정 | 근거 | +|---|---|---| +| 접근 로그 mtime 이 90일 이상 전이거나 로그가 아예 없음 | +1 | 방문자가 없다 = 쓰이지 않는다 | +| 웹루트에 180일 내 수정된 파일이 없음 | +1 | 관리자도 손대지 않는다 | + +- 캐시·로그·업로드 디렉터리는 자동 갱신되므로 **파일 수정 검사에서 제외**한다. +- 로그가 로테이션되면 mtime 이 최근으로 보인다 → **"살아있다"로 판정**된다. + 이 방향의 오류는 **의도한 것**이다(§3-2). + +### 3-2. 오판 방향을 한쪽으로 고정한다 + +**살아있는 사이트를 "휴면"이라고 하면 안 된다. 놓치는 건 괜찮다.** + +- 계절성 사이트(연 1회 행사), 내부용 사이트, 봇만 오는 사이트가 섞일 수 있다 +- 그래서 신호 1개로는 후보에 올리지 않는다 +- UI 문구는 **"정리 후보"가 아니라 "확인 권장"** 이다. 버튼 이름에 `삭제`를 쓰지 말 것 +- 계정/도메인 삭제 기능(PR #8)과 **자동으로 연결하지 않는다.** + 목록에서 바로 삭제로 넘어가는 동선을 만들지 말 것 — 한 번 더 사람이 판단해야 한다 + +### 3-3. 수집 항목 + +| 마커 | 뜻 | +|---|---| +| `HM_IDLE <계정> <도메인> <접근없는일수> <파일오래됨0/1> <신호수>` | 후보 한 줄 (`접근없는일수 = -1` 은 로그 자체가 없음) | +| `HM_IDLE_COUNT` | 후보 개수 | + +용량은 **이미 `scan_cache` 에 있다**(`file_bytes`/`db_bytes`). 서버에서 다시 재지 말고 +UI 에서 도메인명으로 조인해 함께 보여준다. + +--- + +## 4. 스크립트 (실행 검증 완료 — 그대로 쓸 것) + +```sh +echo "[백업 현황]" +BCRON=0 +grep -rqi 'backup' /etc/cron.d/hestia 2>/dev/null && BCRON=1 +BKEEP=$(grep -E "^BACKUPS=" /usr/local/hestia/conf/hestia.conf 2>/dev/null | cut -d"'" -f2) +[ -z "$BKEEP" ] && BKEEP="-" +echo " 자동 백업 cron: $([ "$BCRON" = 1 ] && echo 등록됨 || echo '없음(수동 백업만)') 보관 세대: $BKEEP" +echo "HM_BK_CRON=$BCRON" +echo "HM_BK_KEEP=$BKEEP" + +if [ ! -d "$BDIR" ]; then + echo " ✗ $BDIR 없음" + echo "HM_BK_TOTAL=0"; echo "HM_BK_USERS=0"; echo "HM_BK_MISSING=" +else + NTAR=$(ls -1 "$BDIR"/*.tar 2>/dev/null | grep -c .) + SIZE=$(du -sh "$BDIR" 2>/dev/null | cut -f1) + echo " 파일 ${NTAR}개 · ${SIZE:-?}" + echo "HM_BK_TOTAL=$NTAR" + echo "HM_BK_SIZE=${SIZE:-}" + + USERS=$("$VBIN/v-list-users" plain 2>/dev/null | awk '{print $1}') + NU=$(printf '%s\n' "$USERS" | grep -c .) + MISSING=""; NEWEST=0; OLDESTU=""; OLDESTT=0 + for U in $USERS; do + LAST=$(ls -1t "$BDIR/$U".*.tar 2>/dev/null | head -1) + if [ -z "$LAST" ]; then MISSING="$MISSING $U"; continue; fi + N=$(ls -1 "$BDIR/$U".*.tar 2>/dev/null | grep -c .) + T=$(stat -c %Y "$LAST" 2>/dev/null); [ -z "$T" ] && T=0 + [ "$T" -gt "$NEWEST" ] 2>/dev/null && NEWEST=$T + # 가장 오래된 '최신 백업' = 제일 방치된 계정 + if [ "$OLDESTT" = 0 ] || { [ "$T" -lt "$OLDESTT" ] 2>/dev/null; }; then OLDESTT=$T; OLDESTU="$U"; fi + AGE=$(( (NOW - T) / 86400 )) + printf 'HM_BKU %s %s %s\n' "$U" "$N" "$AGE" + done + NMISS=$(printf '%s' "$MISSING" | wc -w) + echo " 계정 ${NU}개 중 백업 있음 $((NU - NMISS))개 · 없음 ${NMISS}개" + [ -n "$MISSING" ] && echo " 백업 없는 계정:$MISSING" + if [ "$NEWEST" -gt 0 ] 2>/dev/null; then + echo " 가장 최근 백업: $(date -d "@$NEWEST" '+%F %T') ($(( (NOW - NEWEST) / 86400 ))일 전)" + else + echo " ✗ 백업 파일이 하나도 없습니다" + fi + echo "HM_BK_USERS=$NU" + echo "HM_BK_MISSING=$(printf '%s' "$MISSING" | sed 's/^ *//')" + echo "HM_BK_NEWEST=$NEWEST" + [ -n "$OLDESTU" ] && echo "HM_BK_STALEST=$OLDESTU $(( (NOW - OLDESTT) / 86400 ))" +fi +echo + +echo "[휴면 후보 — 오래 방치된 사이트]" +echo " ※ 삭제 대상이 아니라 '확인해볼 곳' 이다. 계절성·내부용 사이트가 섞일 수 있다." +HITDAYS=90 # 접근 로그가 이만큼 없으면 신호 +MODDAYS=180 # 파일이 이만큼 안 바뀌면 신호 +NCAND=0 +shopt -s nullglob +for WR in /home/*/web/*/public_html; do + [ -d "$WR" ] || continue + DOM="$(basename "$(dirname "$WR")")" + OWN="$(stat -c %U "$WR" 2>/dev/null)"; [ -z "$OWN" ] && continue + + # 1) 마지막 접근 — 로그 파일의 mtime. 로테이션되면 최근으로 보이므로 '살아있다' 쪽으로 안전하게 기운다. + HITT=0 + for L in /var/log/apache2/domains/"$DOM".log /var/log/nginx/domains/"$DOM".log \ + /home/"$OWN"/web/"$DOM"/logs/"$DOM".log; do + [ -s "$L" ] || continue + T=$(stat -c %Y "$L" 2>/dev/null); [ -z "$T" ] && continue + [ "$T" -gt "$HITT" ] 2>/dev/null && HITT=$T + done + if [ "$HITT" = 0 ]; then HITD=-1; else HITD=$(( (NOW - HITT) / 86400 )); fi + + # 2) 마지막 파일 수정 — 캐시·로그는 자동 갱신되므로 제외한다. + # -mtime 은 POSIX 라 어떤 find 에서도 동작한다(-newermt 는 상대시간 표기를 거부하는 구현이 있다). + # 제외 경로는 반드시 "$WR" 기준으로 쓴다. '*/tmp/*' 처럼 쓰면 상위 경로에 tmp 가 있을 때 전부 걸러진다. + FRESH=$(find "$WR" -type f -mtime -"$MODDAYS" \ + -not -path "$WR/*cache/*" -not -path "$WR/logs/*" -not -path "$WR/tmp/*" \ + -not -path "$WR/wp-content/uploads/*" -print -quit 2>/dev/null) + [ -n "$FRESH" ] && MODOLD=0 || MODOLD=1 + + # 3) 신호 집계 — 2개 이상이면 후보 + SIG=0; WHY="" + if [ "$HITD" = -1 ]; then SIG=$((SIG+1)); WHY="접근로그 없음" + elif [ "$HITD" -ge "$HITDAYS" ] 2>/dev/null; then SIG=$((SIG+1)); WHY="${HITD}일간 접근 없음"; fi + if [ "$MODOLD" = 1 ]; then SIG=$((SIG+1)); WHY="$WHY${WHY:+ · }${MODDAYS}일+ 파일 변경 없음"; fi + + if [ "$SIG" -ge 2 ] 2>/dev/null; then + NCAND=$((NCAND+1)) + printf ' %-34s %-10s %s\n' "$DOM" "$OWN" "$WHY" + printf 'HM_IDLE %s %s %s %s %s\n' "$OWN" "$DOM" "$HITD" "$MODOLD" "$SIG" + fi +done +[ "$NCAND" = 0 ] && echo " 없음 — 최근 활동이 확인되지 않는 사이트가 없습니다" +echo "HM_IDLE_COUNT=$NCAND" +``` + +### 검증한 동작 + +가짜 환경(계정 3개·사이트 4개)을 만들어 실제로 돌린 결과다. **아래 도메인은 전부 테스트용 +가짜 데이터이고 실제 서버와 무관하다.** + +| 대상 | 상태 | 결과 | +|---|---|---| +| 계정 `eond` | 백업 3세대, 1일 전 | `HM_BKU eond 3 1` | +| 계정 `rokmc` | 백업 1세대, 40일 전 | `HM_BKU rokmc 1 40` · `HM_BK_STALEST=rokmc 40` | +| 계정 `ghost` | 백업 없음 | `HM_BK_MISSING=ghost` | +| 사이트 (활성) | 로그 최신 + 파일 최신 | 후보 아님 | +| 사이트 (200일 접근 없음 + 파일 300일) | 신호 2 | **후보** | +| 사이트 (로그 없음 + 파일 400일) | 신호 2 | **후보** | +| 사이트 (로그 없음 + **파일 최신**) | 신호 1 | **후보 아님** ← 오판 방지 확인 | + +마지막 줄이 중요하다. 로그가 없어도 파일이 최근에 바뀌었으면 누군가 관리 중인 사이트다. + +### 검증 중 잡은 결함 2건 (수정 완료 — 되돌리지 말 것) + +1. **`-newermt "-180 days"` 는 이식성이 없다.** 일부 `find` 구현(bfs 등)이 상대시간 표기를 + 거부한다. **POSIX `-mtime -180`** 으로 바꿨다. +2. **`-not -path '*/tmp/*'` 는 상위 경로까지 걸러낸다.** 웹루트 위 어딘가에 `tmp` 가 있으면 + 모든 파일이 제외되어 **살아있는 사이트가 휴면으로 오판**됐다. + 제외 경로를 **`"$WR"` 기준 절대경로**로 고쳤다. + +--- + +## 5. UI + +대시보드에 카드 2개를 추가한다. 조회 버튼은 **하나**다(`백업·정리 점검`, SSH 1회). + +### 5-1. 백업 현황 카드 + +``` +백업 현황 [점검] + 자동 백업 cron 등록됨 · 보관 3세대 · 파일 84개 · 215G + 계정 28개 중 백업 있음 26 · 없음 2 ← 없음이 0 이 아니면 빨강 + 백업 없는 계정: ghost, testuser + 가장 최근 백업 1일 전 · 가장 밀린 계정 rokmc (40일) +``` + +- `HM_BK_CRON=0` 이면 빨강 `자동 백업이 등록되어 있지 않습니다` +- `HM_BK_MISSING` 이 비어있지 않으면 빨강, 계정 목록을 그대로 보여준다 +- `HM_BK_NEWEST` 가 **3일 이상 전**이면 주황 `백업이 N일째 갱신되지 않았습니다` +- 계정별 표(`HM_BKU`)는 접어두고, 펼치면 세대 수·경과일 정렬 + +### 5-2. 휴면 사이트 카드 + +``` +확인 권장 사이트 3건 + ※ 삭제 대상이 아니라 확인해볼 곳입니다. 계절성·내부용 사이트가 섞일 수 있습니다. + 도메인 계정 사유 용량 + a.example.com rokmc 200일간 접근 없음 · 파일 변경 없음 1.2GB +``` + +- 용량은 `scan_cache` 에서 조인(없으면 `-`) +- 도메인 클릭 → 계정 관리 페이지(기존 `open_account_page`). **삭제 화면으로 바로 보내지 말 것** +- 0건이면 초록 `최근 활동이 확인되지 않는 사이트가 없습니다` +- 목록은 §PHASE6 4-1 과 같은 방식으로 **스크롤 + TSV 복사** 를 붙인다 + +### 5-3. 저장 + +```rust +#[serde(default)] pub backup_status: BackupStatus, // 위 HM_BK_* 를 담는 구조체 +#[serde(default)] pub backup_users: Vec, // HM_BKU +#[serde(default)] pub idle_sites: Vec, // HM_IDLE +#[serde(default)] pub upkeep_at: i64, +``` + +`Done{ok:true}` 에서만 커밋하고, 실패 시 이전 값을 유지한다(선행 스펙 함정 7과 동일). + +--- + +## 6. 함정 + +1. **자동 SSH 금지** — 버튼을 눌렀을 때만. +2. **읽기 전용 강제** — 스크립트에 `rm`·`v-delete-`·`DROP` 이 없어야 하고 테스트로 확인한다. +3. **`-mtime` 을 `-newermt` 로 되돌리지 말 것**(§4 결함 1). +4. **제외 경로를 `"$WR"` 기준으로 유지할 것**(§4 결함 2). `*/tmp/*` 같은 전역 패턴 금지. +5. **신호 1개짜리를 후보에 넣지 말 것.** 오판이 늘고 그러면 목록 전체를 안 믿게 된다. +6. 휴면 목록에서 **삭제 기능으로 가는 동선을 만들지 말 것**(§3-2). +7. 계정명이 다른 계정의 접두어인 경우(`eond` / `eond2`)가 있다. 백업 파일 glob 은 + `"$U".*.tar` 처럼 **점을 포함**해야 한다. `"$U"*.tar` 로 바꾸면 서로 섞인다. +8. `find` 가 97개 사이트를 도는 데 시간이 걸린다. `-print -quit` 로 **첫 매치에서 멈추는** + 현재 구조를 유지할 것(전체 순회로 바꾸면 몇 분씩 걸린다). + +--- + +## 7. DoD + +- [ ] `cargo test` 전부 통과 / `cargo build --release` 경고 없음 +- [ ] 스크립트에 파괴 명령이 없음(테스트로 강제) +- [ ] `bash -n` 통과 +- [ ] 백업 없는 계정이 있으면 빨강으로 계정명이 표시된다 +- [ ] `HM_BK_CRON=0` 일 때 경고가 뜬다 +- [ ] 신호 1개짜리 사이트가 후보에 **오르지 않는다**(단위 테스트) +- [ ] 휴면 목록에서 삭제 화면으로 가는 버튼이 **없다** +- [ ] 휴면 목록이 스크롤되고 TSV 복사가 된다 +- [ ] `docs/dashboard.md` 에 백업 점검·휴면 판정 기준과 **오판 방향 고정 원칙**(§3-2)을 추가