# 작업 보고: task-3009 — InsuWiki Firestore 백업 복구

- 팀: dev2-team (오딘)
- 레벨: Lv.2 · 저장소: `Jeon-Jonghyuk/InsuWiki` (기본 브랜치 `master`)
- PR: **#7 OPEN** — https://github.com/Jeon-Jonghyuk/InsuWiki/pull/7 (★ 머지는 ANU, 자동 머지 금지)
- base: `4911a99` · head: `cba5e2a`

---

## S — 상황 (Situation)
인슈위키 지식 데이터(`documents` 등 **실측 7,513 문서**)를 지키는 Firestore Backup 워크플로가
**한 번도 성공한 적이 없었다.** 백업이 존재하지 않았고, 소실 시 복구 수단이 0이었다.

## C — 문제 (Complication)
"3일 연속 실패"(ANU 1차) → "98회·3개월"(태스크 명세) → **실측 191회·6개월**.
보고할 때마다 규모가 커졌다. 원인 규명도, 산출물 실증도 없었다.

## Q — 질문 (Question)
왜 실패하는가(추정 아닌 로그로), 실제로 백업을 만들 수 있는가, 산출물은 진짜 존재하는가,
그리고 다음 실패를 어떻게 알 것인가?

## A — 답변 (Answer) / 수행 결과
근본 원인 2개를 로그·API로 확정하고, **키리스(WIF)** 로 전환해
**해당 워크플로 사상 최초의 성공 2회**와 **실제 백업 산출물(29.3MB)** 을 만들었다.
"초록불"이 아니라 "산출물"이 증거가 되도록 **검증 게이트를 워크플로에 내장**하고,
그 게이트가 실제로 차단하는지 음성 테스트 4종으로 실증했다.

---

## 1. 실패 원인 확정 (검증 항목 ①)

### 명세 대비 실측 정정 — ★ ANU 보고 대상
| 항목 | ANU/명세 | **실측(전수)** | 근거 |
|---|---|---|---|
| 총 실행 | 98건 | **191건** | `runs?per_page=100 --paginate` 고유 run id 191개 |
| 성공 | 0 | **0** (변동 없음) | conclusion 집계 191/191 failure |
| 최초 실패 | 2026-05-18 | **2026-02-15T19:16:23Z** | 최오래된 run `22041507018` |
| 최근 실패 | 2026-08-23 | **2026-08-24T19:19:20Z** | run `32767476576` |
| 기간 | 3개월 | **6.3개월** | 월별 14/31/30/31/30/31/24 — 결번 없는 일 1회 |

> 2026-05-18~08-23 은 정확히 98일. ANU 수치는 **페이지네이션 미적용으로 최근 98건만 집계**된 것으로 보인다.
> 명세의 경고("최근 N건만으로 결론 내지 마라")가 **명세 자신에게도 해당**했다.

### 근본 원인 A — 시크릿 `GCP_SA_KEY` 미설정 (확정)
실패 run `32767476576` 로그 원문:
```
##[error]google-github-actions/auth failed with: the GitHub Action workflow must specify
exactly one of "workload_identity_provider" or "credentials_json"! If you are specifying
input values via GitHub secrets, ensure the secret is being injected into the environment.
```
★ 결정적 증거: 같은 로그의 `with:` 블록에 **`credentials_json` 행이 아예 없다.**
나열된 것은 전부 액션 기본값(`create_credentials_file`, `universe`, `access_token_lifetime` …).
즉 `'${{ secrets.GCP_SA_KEY }}'` 가 **빈 문자열로 평가되어 입력 자체가 사라진 것**이다.

**전 기간 동일 원인 논증** (로그는 90일 보존이라 최근분만 원문 확보 가능):
- 워크플로 파일 최종 변경 = **2026-02-26** 이후 무변경 (`git log --follow`)
- 실패 스텝을 전 구간 6개 지점 샘플링 — 전부 `Run google-github-actions/auth@v2`
  (03-26 / 05-05 / 06-14 / 07-24 / 08-23)
- 191건 전부 `event=schedule`, 수동 실행 이력 0 → 사람이 한 번도 들여다본 적 없음
- ★ 구분: 최근 run 은 **로그 원문으로 확정**, 과거 run 은 **동일 실패 스텝 + 파일 무변경에 의한 강한 추론**

### 근본 원인 B — 백업 대상 버킷 부재 (확정, 명세 미기재)
`gcloud storage buckets list` 실측 결과 프로젝트 `insuwiki-j2h` 에 존재하는 버킷은
`gcf-sources-*` / `gcf-v2-sources-*` / `gcf-v2-uploads-*` **3개(전부 Cloud Functions 소스용)** 뿐.
**백업용 버킷이 아예 없었다.** 시크릿 A 를 고쳐도 `gcloud firestore export gs://` 에서 다시 실패했을 것이다.
→ 원인은 1개가 아니라 **2개 직렬**이었다.

---

## 2. 복구 내용 (검증 항목 ②)

### 인증: 시크릿 방식 → **Workload Identity Federation(키리스)**
봇 토큰은 시크릿 읽기/쓰기 권한이 없고(403), SA 키 생성·커밋은 태스크가 금지한다.
WIF 의 provider 리소스명과 SA 이메일은 **비밀값이 아니라** YAML 평문 기입이 가능하다.
→ **GitHub Secrets 의존을 0으로 만들어** 같은 원인의 재발 자체를 불가능하게 했다.
(기존 워크플로에 이미 `id-token: write` 가 있었다 — 애초 WIF 를 의도했던 흔적으로 보인다.)

신규 구축(전부 추가만, 기존 리소스 삭제·변경 0):
- Pool `github-pool` / Provider `github-provider` (state **ACTIVE**)
- ★ `attributeCondition: assertion.repository == 'Jeon-Jonghyuk/InsuWiki'`
  — 이게 없으면 **임의의 GitHub 저장소가 우리 GCP 에 인증 가능**해진다. 실측으로 적용 확인
- 신규 **최소권한** SA `insuwiki-backup@insuwiki-j2h.iam.gserviceaccount.com`
  (`roles/datastore.importExportAdmin` + 버킷 한정 `roles/storage.objectAdmin`)
  ※ 기존 `anu2026` 은 `roles/owner` 라 GitHub Actions 연결 대상으로 부적절하여 사용하지 않음
- `roles/iam.workloadIdentityUser` 를 저장소 한정 principalSet 으로 바인딩
- ★ **SA 키 JSON 생성 0건** / `sts.googleapis.com` 은 비활성이라 활성화함

### 저장소
- 버킷 `gs://insuwiki-j2h-firestore-backup` — **asia-northeast3**(Firestore DB 와 동일 리전 필수),
  uniform bucket-level access, **90일 lifecycle**(무한 누적 방지)

### 워크플로
`env:` 상수화 · `project_id` 명시 · export **동기 실행**(`--async` 미사용, 실패가 드러나게) ·
`set -euo pipefail` · `concurrency` 중복 방지 · **산출물 검증 스텝 신설**

---

## 3. 실제 실행 성공 + 산출물 실증 (검증 항목 ③ — 핵심 완료 조건)

| 구분 | run id | conclusion | 비고 |
|---|---|---|---|
| 대조군(수정 전, master) | `32809421962` | **failure** | auth 스텝에서 동일 실패 — 사전 상태 고정 |
| 수정 후 1차 | `32809888396` | **success** | ★ **이 워크플로 사상 최초 성공** |
| 수정 후 2차(결함 수정 반영) | `32810076453` | **success** | 바이트 집계 정정 반영 |

**산출물 (팀장이 `gcloud` 로 독립 검증 — 봇 자기보고 신뢰하지 않음):**
```
gs://insuwiki-j2h-firestore-backup/2026-08-25T04:44:43_92577/
  TOTAL: 57 objects, 29320428 bytes (27.96MiB)
  .../2026-08-25T04:44:43_92577.overall_export_metadata   98 bytes  2026-08-25T04:44:43Z
```
- 객체 수 **57** · 총 **29,320,428 bytes** · 생성 시각 **2026-08-25T04:44:43Z**
- export 대상 문서 수 **7,513** (operation `progressDocuments.completedWork`)
- ★ CI 로그의 주장과 팀장의 독립 `gcloud storage ls -l -r` 결과가 **일치**함을 확인

### ★ 발견·수정한 결함 — "초록불인데 거짓말하는 집계"
1차 성공 run 은 초록불이었으나 검증 스텝이 **`총 바이트: 0`** 을 출력했다. 실제로는 29,320,428 bytes 였다.
원인: `gcloud storage du -s --format="value(size)"` 는 **지원되지 않는 문법**이고
(`ERROR: The only valid format value for ls and du is "gsutil"`),
`|| echo "0"` 이 그 에러를 **삼켜서** 0 을 만든 것.
→ 폴백 제거 + 첫 필드 파싱 + **0바이트를 실패로 처리**하도록 수정(`cba5e2a`), 2차 run 에서 `29320428` 정상 출력.
> 이 태스크가 없애려는 "초록불인데 실체 없음"이 **우리가 새로 넣은 검증 코드 안에서 재현**됐다. 잡아서 고쳤다.

---

## 4. 복원 가능성 (검증 항목 ④)
- Firestore import 규격의 필수 요소인 **`.overall_export_metadata` 존재 확인**(98 bytes)
- 구조: `<prefix>/all_namespaces/all_kinds/` + `all_namespaces_all_kinds.export_metadata` + `output-0..54`(55 샤드)
  ※ `--collection-ids` 미지정 전체 export 의 표준 산출 형태 (명세가 예시한 `kind_*` 는 컬렉션 지정 시 형태)
- `gcloud firestore import` 는 INPUT_URI_PREFIX 로 **직전 export 의 `outputUriPrefix` 를 그대로** 받는다고 명시 → 규격 일치
- ★ **실제 import 는 실행하지 않음** — 프로덕션 데이터를 덮어쓰므로 금지 사항. 구조 검사까지만 수행
- ★ **한계 명시**: "구조가 규격에 맞다"까지 확인했고 **"복원이 실제로 된다"는 실증이 아니다.**
  완전 실증은 별도 프로젝트에 test import 가 필요하며 회장 승인 사안 → **ANU 판단 요청**

## 5. 재발 방지 (검증 항목 ④)
1. **원인 A 는 구조적으로 소멸** — `secrets.` 참조 **0건**(grep 실측). 미설정될 시크릿이 존재하지 않음
2. **원인 B 소멸** — 버킷 실존 + 90일 lifecycle
3. **거짓 성공 차단** — 산출물 검증 스텝이 metadata 부재/객체 0/0바이트에서 `exit 1`
4. ★ **게이트가 진짜 막는지 실증**(헤임달, 통과만 보지 않고 차단을 봄):

| 시나리오 | 기대 | 실제 exit | 판정 |
|---|---|---|---|
| N1 빈 prefix | 차단 | **1** | 차단 성공 |
| N2 존재하지 않는 경로(실 GCS 조회) | 차단 | **1** | 차단 성공 |
| N3 객체는 있으나 metadata 없음 | 차단 | **1** | 차단 성공 |
| N4 du 가 0 반환 | 차단 | **1** | 차단 성공 |
| P1 실제 백업(양성 대조군) | 통과 | **0** (29320428) | 오탐 없음 |

> N3·N4 는 실 버킷에 쓰지 않기 위해 `gcloud` 스텁으로 재현 — **실환경 아님을 명시**한다.

## 6. 기존 워크플로 무영향 (검증 항목 ⑤)
- `PR Check`: **선재 실패**. 최근 run `32677672143` 은 **TypeScript type check** 스텝에서 실패
  (2026-07-27·08-20·08-24 3건 연속 실패, 우리 변경 이전). 본 PR 은 `nextapp/**` 를 건드리지 않음 → **무관·미개선·미악화**
- `CI`(`ci.yml`): 워크플로 등록은 살아있으나 **master 에 파일이 없음**(고아). 총 run 1건(2026-05-05 success, 삭제된 브랜치). 본 변경과 무접점
- 변경 파일은 **2개뿐**(아래 표) — 다른 워크플로 미수정

---

## 3 Step Why

**1st Why — 왜 백업이 191회 실패했나?**
`google-github-actions/auth@v2` 가 인증 입력을 하나도 받지 못했다. 시크릿 `GCP_SA_KEY` 가 미설정이라
`credentials_json` 이 빈 문자열로 평가되어 입력 자체가 사라졌다(로그의 `with:` 블록에 해당 행 부재).

**2nd Why — 왜 미설정 상태가 6개월간 유지됐나?**
실패가 **아무에게도 도달하지 않았다.** 191건 전부 `event=schedule` 이고 수동 실행 이력이 0이다.
즉 사람이 Actions 탭을 한 번도 열지 않았고, 실패를 능동적으로 알리는 경로도 없었다.
설상가상 워크플로는 인증 실패 시점에 죽어서 **산출물이 없다는 사실조차 검사하지 않았다.**

**3rd Why — 왜 그 구조가 애초에 만들어졌나?**
워크플로가 **한 번도 성공하지 않은 채로 커밋됐고**(2026-02-16 최초 커밋, 2026-02-15부터 실패 시작),
성공 실증 없이 "설정했으니 됐다"로 종결됐다. 백업 대상 버킷조차 만들어진 적이 없다.
→ 교훈: **"파이프라인을 작성했다" ≠ "백업이 존재한다".** 산출물 실증이 없으면 그것은 백업이 아니다.
이번 수정에서 산출물 검증을 워크플로 자체에 내장하고, 그 게이트가 실제로 차단하는지까지 실증한 이유다.

## L1 스모크테스트
- **서버 재시작**: 해당없음 (GitHub Actions 워크플로 · 상주 서버 없음)
- **API 응답 확인**: **성공** — 실제 CI 실행 2회 `conclusion=success`
  (`32809888396`, `32810076453`), 로컬 `gcloud firestore export` 1회 성공
  독립 검증: `gcloud storage ls -l -r` → `TOTAL: 57 objects, 29320428 bytes`
- **스크린샷**: 해당없음 (UI 없음). 대체 증거로 run id·GCS 객체 목록·바이트 수 제시
- ★ pytest 성격의 테스트는 이 태스크에 없음. **실제 러너 실행 + 실제 GCS 산출물**이 L1 증거

## 수정 파일별 검증 상태
| 파일(절대경로) | 변경 내용 | grep/실행 검증 | 상태 |
|---|---|---|---|
| /home/jay/projects/insuwiki/.worktrees/task-3009-dev2/.github/workflows/firestore-backup.yml | WIF 전환·env 상수화·산출물 검증 스텝·concurrency (+122/-29) | `grep "secrets\."` **0건** · YAML 파싱 PASS · CI run 2회 success | **verified** |
| /home/jay/projects/insuwiki/.worktrees/task-3009-dev2/docs/firestore-backup.md | 백업/복원/실패대응 운영 문서 신설 (+115) | 파일 존재·115줄 확인 · PR files API 등재 | **verified** |

planned 항목 **0건**.

## trip-wire 5종 (실측)
- Critical7: **0**
- PII net-new: **0** (diff 시크릿 패턴 스캔 무검출 — private key/토큰/`secrets.` 참조 전부 0)
- 회귀 실패: **0** (`PR Check` 는 선재 실패, 본 변경과 무관·미악화)
- forbidden_paths 침범: **0** (`nextapp/`·`functions/`·`scripts/youtube-pipeline/`·`firestore.rules` 미포함 — `git diff --name-only` 실측)
- nonce=task_id 일치: **task-3009** (브랜치·커밋·PR 제목 전부 일치)

## 모델 사용 기록
| 팀원 | 역할 | 모델 | 담당 |
|---|---|---|---|
| 토르(Thor) | 백엔드/인프라 | sonnet | 버킷·export 실증 / WIF 구성 / 워크플로 전환 / 결함 수정 (4회 위임) |
| 헤임달(Heimdall) | 테스트/QA | sonnet | 알림 경로 조사 / fail-closed 음성 테스트 |
| 오딘(Odin) | 팀장 | opus | 원인 규명·전수 조사·설계 판단·**산출물 독립 검증**·PR |
haiku 미사용(전 작업이 인프라/분석/조사 성격이라 sonnet 이상 필요). 팀장 직접 코딩 0.

---

## ★ ANU 판단 요청 (3건)

### ① 실패 알림 경로 — 제안만, 미구현 (명세 ⑤ 지시대로)
- **`alert_notify.py`(task-3008) 직접 재사용은 구조적으로 불가.** 실제 위치는
  `/home/jay/projects/InsuRo/server/scripts/alert_notify.py`(워크스페이스 **밖**)이고,
  cokacdir 바이너리·키 파일이 **로컬 서버 자산**이라 GitHub 클라우드 러너에서 도달 불가
- `cokacdir --help`(v0.8.17) 재확인: **`--sendmsg` 부재**, 발송 정본은 `--cron` + **분 단위**
- ★ **주목할 발견**: GitHub 은 예약 워크플로 실패 시 **워크플로 파일 최초 작성자에게 이메일**을 보낸다.
  `git log --diff-filter=A` 실측 결과 최초 작성자는 **회장님 본인 계정**(`jonghyuk.jeon@gmail.com`)이고
  cron 라인 재수정 이력도 없다. → **191회분 실패 메일이 이미 갔을 가능성**이 있다.
  실제 도달 여부·계정 Actions 알림 설정은 **API 조회 불가**(토큰에 `notifications` scope 없음) → **회장님 본인 확인 필요**
- **권장안**: 로컬 서버가 `gh api .../runs` 를 폴링해 `failure` 면 기존 `alert_notify` 로 발송
  (신규 자격증명 **불필요** — GitHub 쪽에 새 시크릿을 만드는 방식은 이번 사고 패턴의 반복이라 비권장)
  → dev4 소유 자산을 넘나들므로 **별도 태스크 발급 필요**. 60일 무활동 자동 비활성화 정책은 **무관**으로 확인
- ★ **현재 상태**: 알림은 아직 **없다**. PR 머지만으로 알림이 생기지 않는다

### ② 복원 실증의 한계
규격 일치까지만 확인. 실제 import 실증은 별도 프로젝트/DB 필요 → 승인 사안

### ③ 도구 결함 — `worktree_manager.py`
`create` 가 base 를 **`origin/main` 하드코딩**, CLI 오버라이드 플래그 없음.
기본 브랜치가 `master` 인 저장소(InsuWiki)에서 **fail-closed 로 생성 실패**.
이번엔 모듈을 직접 호출해 `base_ref='origin/master'` 로 우회(안전장치는 그대로 통과, base `4911a99` 정상 고정).
→ **외부 저장소 전반에 재발할 구조적 결함.** 별도 태스크 권장

---

## 비고
- ★ **PR #7 은 OPEN 상태로 둔다. 직접 머지하지 않았다.** (명세 + DIRECT-WORKFLOW 머지 게이트 준수)
- Firestore 는 **export(읽기)만** 수행 — 쓰기/삭제/import **0건**
- 버킷에 백업 prefix 3개 존재(로컬 실증 1 + CI 2). 90일 lifecycle 로 자동 정리됨
- GCP 변경은 **전부 추가**(pool/provider/SA/버킷/역할). 기존 SA·바인딩·버킷 **삭제·변경 0건**

## 세션 통계
- 총 도구 호출: 0회

