docs(rel-003): M3 daemon-scoped 크레덴셜 릴리즈 문서 + Step2 ops 런북

REL-003(dcy-010/011 + WAC #15 동반) draft + Step2 마이그레이션 핸드오프(프리체크·롤백·go/no-go).
내일 ops 윈도우용. 미푸시(사장님 결재 시 release와 함께 push).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
2026-06-28 23:02:35 +09:00
co-authored by Claude Opus 4.8
parent 9fe6f3b511
commit 9d98d47e2d
3 changed files with 531 additions and 78 deletions
+1
View File
@@ -10,3 +10,4 @@
| --- | --- | --- | --- | --- | --- |
| REL-001 | app-service | released | BAT-001~016 (전체 통합) | 2026-05-21 | [rel-001](./rel-001_20260521_v1-api-full-surface.md) |
| REL-002 | app-service | feedback-captured | BAT-017 (dcy-007 M1: 거래내역+실현PnL API) | 2026-06-19 | [rel-002](./rel-002_20260619_trade-history-and-pnl-api.md) |
| REL-003 | app-service | draft | BAT-018~022 (dcy-008/009/010/011: WAC조회+데몬목록+daemon-scoped 인증정보+관리API) | 2026-06-28 | [rel-003](./rel-003_20260628_daemon-credential-and-admin-api.md) |
@@ -0,0 +1,257 @@
# REL-003: daemon-scoped 브로커 인증정보 + 관리 API (dcy-010 + dcy-011)
- Status: draft
- Profile: app-service
- Included Batch: BAT-018 (REQ-043), BAT-019 (REQ-044, REQ-045), BAT-020 (REQ-046~051), BAT-021 (REQ-047 Step2 통합 및 런북), BAT-022 (REQ-052~054)
---
## Scope
### Included
- **BAT-018** (dcy-008, batch-lite, release-candidate) — REQ-043: `GET /v1/portfolios/{id}/holdings/cost-basis` 엔드포인트 신설 (WAC/평단 조회)
- **BAT-019** (dcy-009, standard, confirmed) — REQ-044/045: `GET /v1/assets` 권한 완화(require_read) + `GET /v1/daemons` 신설
- **BAT-020** (dcy-010, standard, release-candidate) — REQ-046~051: BrokerCredential daemon_id 도메인 모델 + Step1 마이그레이션 + backfill CLI + `/v1/user_info` daemon-scoped 확장 + CLI `--daemon-id` 옵션 + 보안 격리
- **BAT-021** (dcy-010, standard, release-candidate) — REQ-047: Step2 동반 코드 수정(ensure_schema/upsert ON CONFLICT) + 실 PostgreSQL 통합검증 + Step2 런북 (`docs/runbooks/bat-021-req047-step2-migration-runbook.md`)
- **BAT-022** (dcy-011, standard, release-candidate) — REQ-052~054: 사용자 소유 스코프 인증정보 관리 API + admin 데몬 CRUD API + 인증정보 보안 격리(쓰기전용·secret 미반환·로그 미노출)
- **(동반 배포) dcy-035 WAC #15** (`3a6adc9`, REQ-041 change-req CHG-20260626-01) — `/pnl/realized`·holdings cost-basis의 **매수 수수료를 WAC cost basis에 편입**. 이미 origin/main 머지됨 → 이번 portfolio-hub 코드 배포(main)에 함께 실린다. trex-core 운영리포트(dcy-035, REL-010)와 짝을 이루는 멀티데이 WAC 정확값의 portfolio-hub 측. (별도 배포·재시작 불필요 — 동일 배포에 포함)
### Excluded
- REQ-047 Step2(PK 전환) 운영 DB 실제 적용 — **이번 릴리즈의 코드 배포 이전** ops 윈도우에서 별도 수행 (런북 `docs/runbooks/bat-021-req047-step2-migration-runbook.md` 엄수, 사장님 결재 게이트)
- trex-core 소비자 어댑터 (KISConfig fetch 경로) — 별도 trex-core Discovery (dcy-010 out-of-scope)
- trex-web UI (dcy-009) — trex-web repo 별도 진행
- 서비스 토큰 발급·폐기 (dcy-011 out-of-scope)
---
## 전제 조건 (배포 시작 전 필수 확인)
1. **`CREDENTIAL_ENCRYPTION_KEY`** — 운영 환경 env에 Fernet key 값이 설정돼 있어야 한다. 미설정 시 인증정보 암·복호화 전체가 동작하지 않는다.
- 확인: 서비스 실행 환경에서 `echo $CREDENTIAL_ENCRYPTION_KEY | head -c 10` (공백이면 미설정)
- 최초 설정: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 결과를 `CREDENTIAL_ENCRYPTION_KEY`에 등록
- **주의**: 기존 운영 인스턴스에 이미 설정돼 있으면 변경 금지 (기존 암호화 payload를 복호화할 수 없게 됨)
2. **Step2 마이그레이션 완료 확인** — 코드 배포 전에 REQ-047 Step2(alembic revision `20260628_0027`) 적용이 완료되어야 한다. Step2 미적용 상태로 BAT-021 코드를 배포하면 `upsert_broker_credential`의 `ON CONFLICT (daemon_id, broker)` 절이 구 스키마(PK=`(user_id, broker)`)와 맞지 않아 인증정보 등록/회전 실패.
- 검증 쿼리: `SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'broker_credentials_pkey';`
- 기대값: `PRIMARY KEY (daemon_id, broker)`
3. **alembic 상태 사전 확인** — 현행 운영 DB의 alembic head가 `20260628_0027` (Step2 완료 후) 인지 확인:
```bash
alembic current
# 기대: 20260628_0027 (head)
```
4. **DB 백업** — Step2 마이그레이션 적용 전 운영 DB 전체 스냅샷 백업 (런북 1단계 참조).
5. **pytest 통과 확인** — main HEAD (`9fe6f3b`) 기준으로 `pytest` 전체가 통과해야 한다.
- BAT-022 기준 565 passed + 핫픽스 커밋 포함 최신 스위트 통과 확인 필요.
---
## Rollout Plan
> **배포 순서 엄수. 특히 Step2 미적용 상태에서 코드 배포하면 upsert 실패가 발생한다.**
```
[ops 윈도우] DB 백업 → Step1 upgrade → backfill CLI → NULL 0건 확인
→ Step2 upgrade (사장님 결재 필수) → Step2 스키마 검증
[코드 배포] → portfolio-hub 서비스 rolling restart
[smoke] → 헬스체크 → 스모크 테스트 체크리스트 실행
```
### 단계별 상세
**[ops 윈도우 전용 — 코드 배포 전 수행]**
1. **DB 백업**: 운영 DB 전체 스냅샷 (`pg_dump`) — 런북 1단계 참조
2. **Step1 upgrade** (`alembic upgrade 20260628_0026`): `broker_credentials`에 `daemon_id TEXT NULL` 컬럼 추가. 기존 PK `(user_id, broker)` 유지.
3. **backfill CLI**: `pfhub credential backfill-daemon-id --user <username> --daemon-id <daemon_slug>` — 기존 user-scoped 인증정보 행에 daemon_id 설정. 사용자별 반복. `--dry-run` 선 확인 권장.
4. **NULL 0건 검증**: `SELECT COUNT(*) FROM broker_credentials WHERE daemon_id IS NULL;` → 반드시 0 확인. 1건이라도 있으면 Step2 진행 금지 (upgrade가 RuntimeError로 자동 거부함).
5. **Step2 upgrade** (`alembic upgrade 20260628_0027`, **사장님 결재 필수**): PK를 `(user_id, broker)` → `(daemon_id, broker)`로 전환. `daemon_id NOT NULL` 제약 추가. **비가역 작업.**
6. **Step2 스키마 검증**: PK 전환·daemon_id NOT NULL·user_id 유지를 SQL 쿼리로 확인 — 런북 4단계 검증 SQL 참조.
**[코드 배포]**
7. **portfolio-hub 서비스 rolling restart**: freeze 커밋 `9fe6f3b` (main HEAD) 이미지로 교체.
- 포함 변경 모듈: `app/repositories/postgres.py` (ensure_schema·upsert ON CONFLICT), `app/api/v1_daemons.py` (admin CRUD·daemon 목록), `app/api/v1_credentials.py` (소유 스코프 관리 API), `app/api/v1_portfolios.py` (holdings/cost-basis), `app/api/v1_user_info.py` (daemon-scoped broker 전달), `app/cli/commands/credential.py` (--daemon-id·backfill), `app/domain/pnl.py` (calculate_current_wac)
**[배포 후 smoke]**
8. 아래 Verification Checklist 항목 순서대로 실행.
---
## Rollback Plan
### 트리거 조건
- `GET /health` HTTP 200 실패
- `GET /v1/user_info` (service token) HTTP 500 또는 broker 필드 누락
- 인증정보 PUT/GET 연속 500 발생
- 기존 API(`/v1/portfolios`, `/v1/assets`, `/v1/daemons`) 회귀 발생
### 코드 롤백 (1순위)
BAT-022/021/020은 기존 기능의 additive 추가 및 REQ-049 동작 변경(brokers→broker 단일화)이 포함된다. 코드 롤백 시 REL-002 freeze 이미지(커밋 `d89f137`)로 복원하면 된다. DB는 Step2 이후 상태 그대로 둬도 무방 (코드가 구 스키마를 참조하지 않는 방향으로 하위호환 불가하여 DB 롤백 필요 여부를 판단).
```
portfolio-hub → REL-002 이미지(커밋 d89f137)로 rolling restart
```
### DB 롤백 (Step2 롤백 — 조건부)
Step2 downgrade는 같은 user의 여러 daemon이 동일 broker를 쓰는 경우 RuntimeError로 자동 거부된다. 단일 daemon 1계좌 환경이면 downgrade 가능:
```bash
alembic downgrade 20260628_0026 # Step2 → Step1 상태
alembic downgrade 20260605_0025 # Step1 → 이전 상태 (daemon_id 컬럼 제거)
```
**경고**: Step2 downgrade는 조건부 가역이다. 백업 복원이 최후 수단.
```bash
pg_restore -h <DB_HOST> -U <DB_USER> -d <DB_NAME> -c backup_pre_bat021_<TIMESTAMP>.dump
```
### 롤백 영향 분석
- REQ-049 (`/v1/user_info` daemon-scoped): brokers 리스트→단일 broker 변경. trex-core 소비자는 `daemon_id`만 읽으므로 무영향(소비자 인벤토리 확인 완료).
- REQ-052/053: HTTP API 추가분 — additive. 롤백 시 엔드포인트 제거만 발생. 기존 운영 영향 없음.
- REQ-043/044/045 (BAT-018/019): additive 추가·권한 완화. 롤백 시 신규 엔드포인트 제거 + GET /v1/assets가 require_admin으로 복원.
---
## Verification Checklist
### 배포 전
- [ ] Step2 완료 확인: `SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'broker_credentials_pkey';` → `PRIMARY KEY (daemon_id, broker)`
- [ ] alembic head 확인: `alembic current` → `20260628_0027 (head)`
- [ ] NULL 잔존 0건: `SELECT COUNT(*) FROM broker_credentials WHERE daemon_id IS NULL;` → `0`
- [ ] `CREDENTIAL_ENCRYPTION_KEY` 운영 환경 설정 확인
- [ ] pytest 전체 통과 확인 (freeze 커밋 `9fe6f3b` 기준)
- [ ] DB 백업 완료 확인 (백업 파일 경로/사이즈 기록)
### 배포 직후
#### 기본 헬스
- [ ] `GET /health` → HTTP 200, `db_enabled: true`
#### admin 데몬 CRUD (REQ-053 — BAT-022)
- [ ] `POST /v1/admin/daemons` (admin-token) — 데몬 생성 → HTTP 201 + `DaemonAdminResponse` (id, owner_user_id, daemon_slug, is_active)
- [ ] `GET /v1/admin/daemons` (admin-token) — 데몬 목록 → HTTP 200 + 데몬 목록 반환
- [ ] `GET /v1/admin/daemons/{daemon_id}` (admin-token) → HTTP 200
- [ ] `PATCH /v1/admin/daemons/{daemon_id}` (admin-token) — display_name 변경 → HTTP 200
- [ ] `PATCH /v1/admin/daemons/{daemon_id}` (admin-token, is_active=false) — 비활성화 → HTTP 200 + is_active=false
- [ ] `DELETE /v1/admin/daemons/{daemon_id}` (admin-token) → HTTP 405 (하드 삭제 금지)
- [ ] 비admin user-token으로 admin CRUD 시도 → HTTP 403
#### 인증정보 관리 API (REQ-052 — BAT-022)
- [ ] `PUT /v1/daemons/{daemon_id}/credential` (user-token, 소유 데몬) → HTTP 200 + 메타데이터(account_ref, credential_version, last_rotated_at, broker) 응답; **app_key/app_secret 미포함 확인 (필드 부재)**
- [ ] `GET /v1/daemons/{daemon_id}/credential` (user-token, 소유 데몬) → HTTP 200 + 메타데이터; **secret 미포함 확인**
- [ ] `DELETE /v1/daemons/{daemon_id}/credential` (user-token, 소유 데몬) → HTTP 204
- [ ] 타 사용자 user-token으로 소유 스코프 외 데몬 인증정보 조회/수정 → HTTP 403
#### service-token `/v1/user_info` daemon-scoped (REQ-049 — BAT-020)
- [ ] service-token(daemon_id 바인딩)으로 `GET /v1/user_info` → `broker` 단일 객체(account_ref, credential_payload 포함), `brokers` 리스트 키 없음 확인
- [ ] `CREDENTIAL_ENCRYPTION_KEY`와 일치하는 키로 인증정보를 등록한 경우 `credential_payload` 복호화 값(app_key 포함) 반환 확인
- [ ] daemon_id 없는 service-token으로 `/v1/user_info` → HTTP 403
- [ ] user-token으로 `/v1/user_info` → HTTP 403 (REQ-051 보안 격리 회귀)
#### WAC 조회 엔드포인트 (REQ-043 — BAT-018)
- [ ] `GET /v1/portfolios/{id}/holdings/cost-basis` (read-token, 소유 portfolio) → HTTP 200 + `items`, `cost_basis_from` 포함
#### 자산/데몬 목록 (REQ-044/045 — BAT-019)
- [ ] `GET /v1/assets` (read-token, non-admin) → HTTP 200 (권한 완화 확인)
- [ ] `GET /v1/daemons` (user-token) → HTTP 200 + 소유 데몬 목록
#### 기존 API 회귀
- [ ] `GET /v1/portfolios` (read-token 이상) → HTTP 200
- [ ] `POST /v1/portfolios/{id}/ledger/trades` (trade-write-token) → 기존 동작 유지
- [ ] `GET /v1/portfolios/{id}/trades` (read-token 이상) → HTTP 200
- [ ] `GET /v1/portfolios/{id}/pnl/realized` (read-token 이상) → HTTP 200
---
## Known Issues (릴리즈 전 인지 사항)
1. **BAT-022 AC-3 통합 수준**: REQ-052 AC-3(CLI·API·service-token 3경로 정합)이 unit-level 검증. 실 DB 통합 환경에서 재확인 권장(비가역 아님, 선택적).
2. ~~owner 재할당 원자성~~ **(해소됨 — 오기 정정)**: `update_daemon_owner()`는 **단일 `conn.transaction()` 트랜잭션**으로 credential DELETE(선행) → owner UPDATE를 원자 처리한다(BAT-022 QC Major fix, 커밋 83e6dde). 어느 한쪽 실패 시 전체 롤백되어 "owner만 바뀌고 구 인증정보 잔존"하는 leak 상태가 발생하지 않는다. 라이브 통합검증(2026-06-28)에서 owner 재할당 → 인증정보 무효화 PASS. **리스크 아님.**
3. **DELETE credential broker 고정**: `DELETE /v1/daemons/{daemon_id}/credential`은 broker="kis" 고정. 다중 broker 지원 시 별도 설계 필요(현재 단일 broker 가정 설계).
4. **REL-002 Known Issues 이월**: symbol 필터 edge 테스트, HTTP_422 deprecated 경고, 성능 미검증 — 여전히 비차단 잔여.
---
## Release Execution Evidence
- Release Status Transition Target: released
### Deployment Facts
- Executed At:
- (미실행 — 배포 전)
- Completed At:
- (미실행)
- Environment:
- portfolio-hub 운영 인스턴스
- Operator:
- (ops 윈도우 담당자 기입)
- Revision or Artifact:
- 배포 커밋: `9fe6f3b` (main HEAD, 2026-06-28)
- alembic head (코드 배포 후 기대): `20260628_0027`
- Commands or Procedure:
- 상세는 위 Rollout Plan 참조
- DB 마이그레이션: `docs/runbooks/bat-021-req047-step2-migration-runbook.md` 엄수
- 코드 배포: portfolio-hub 서비스 rolling restart (freeze 커밋 `9fe6f3b`)
### Profile-Specific Checks
- app-service:
- Health Endpoint:
- (배포 후 기입)
- Startup Log Fields:
- (배포 후 기입)
- Request Log Fields:
- (배포 후 기입)
- Smoke or Manual Verification:
- (배포 후 Verification Checklist 결과 기입)
### Rollback Outcome
- Rollback Required:
- (배포 후 기입)
- Rollback Trigger:
- (배포 후 기입)
- Rollback Action:
- (배포 후 기입)
### Release Decision Evidence
- Released Decision:
- (배포 후 팀장 기입)
- Rationale:
- (배포 후 기입)
- Recorded By:
- (배포 후 기입)
- Recorded At:
- (배포 후 기입)
---
## Feedback Handoff
- Observation Summary:
- (배포 후 운영 관찰 기입)
- Discovery Input:
- trex-core 소비자 어댑터 (KISConfig fetch, dcy-010 out-of-scope) — 별도 trex-core Discovery (dcy-036/BAT-054로 진행, PR #35)
- REQ Input:
- (배포 후 기입)
- Change Request Input:
- (배포 후 기입)
@@ -3,34 +3,67 @@
- Batch: BAT-021
- Discovery: dcy-010
- Date: 2026-06-28
- 결재 게이트: 사장님 결재 필수 (비가역 PK 변경)
- 결재 게이트: 사장님 결재 필수 (비가역 PK 변경 — Step 4)
- 작업 예상 시간: 약 20~30분 (데이터량에 따라 상이)
---
## 전제 조건
- REQ-046 ~ REQ-051 코드(BAT-020 PR #17, d4acfd4)가 배포돼 있어야 한다.
- BAT-021 코드(ensure_schema 갱신, ON CONFLICT 수정)는 **Step2 적용 완료 이후** 배포한다(아래 배포 순서 준수).
- BAT-021 코드(ensure_schema 갱신, ON CONFLICT 수정)는 **Step4 적용 완료 이후** 배포한다(아래 배포 순서 준수).
- 마이그레이션 전 DB 백업이 완료돼 있어야 한다.
- 이 런북의 모든 alembic 명령은 `PORTFOLIOHUB_DB_ENABLED=true` 및 DB 접속 환경변수가 설정된 셸에서 실행한다.
---
## 배포 순서 개요
```
백업 → Step1 upgrade → backfill CLI → NULL 0건 확인 → Step2 upgrade(결재) → 코드 배포 → 동작 확인 → 비상 시 롤백
DB 백업 → 프리체크(alembic head·NULL 확인·락 확인)
→ Step1 upgrade(20260628_0026) → Step1 검증
→ backfill CLI(pfhub credential backfill-daemon-id) → NULL 0건 확인(게이트)
→ Step2 upgrade(20260628_0027, 사장님 결재 후) → Step2 검증
→ BAT-021 코드 배포(rolling restart)
→ 배포 후 동작 확인
```
---
## 순서 위반 실패 모드 (반드시 읽을 것)
| 위반 | 증상 |
| --- | --- |
| **코드 배포를 Step2보다 먼저 수행** | `upsert_broker_credential`의 `ON CONFLICT (daemon_id, broker)` 절이 구 PK `(user_id, broker)`와 불일치 → 인증정보 등록·회전 시 `psycopg.errors.UndefinedColumn` 또는 `ProgrammingError` 발생 |
| **Step2를 backfill 없이 수행** | `alembic upgrade 20260628_0027` 내부 사전 검증 로직이 `daemon_id IS NULL` 행을 감지하고 `RuntimeError`로 자동 거부 (DB 변경 없음) |
| **Step1 없이 Step2 수행** | Step2 revision(`down_revision = 20260628_0026`)이 Step1 적용 여부를 검사하므로 alembic 체인 오류로 실패 |
| **백업 없이 Step2 수행** | 비가역 PK 변경 후 이슈 발생 시 복원 불가 — 반드시 백업 후 진행 |
---
## 1단계: DB 백업
운영 DB에 마이그레이션을 적용하기 전 전체 스냅샷을 백업한다.
```bash
# 예: pg_dump 사용
pg_dump -h <DB_HOST> -U <DB_USER> -d <DB_NAME> -F c -f backup_pre_bat021_$(date +%Y%m%d%H%M%S).dump
pg_dump -h <DB_HOST> -U <DB_USER> -d <DB_NAME> -F c \
-f backup_pre_bat021_$(date +%Y%m%d%H%M%S).dump
```
백업 완료 후 파일 크기와 종료 코드를 확인한다:
```bash
ls -lh backup_pre_bat021_*.dump
# 기대: 파일이 존재하고 크기가 0이 아닌 값
echo $? # 기대: 0
```
> **중단 기준**: 백업 실패(파일 미생성, 크기 0, exit code != 0) 시 이후 단계 진행 금지. 백업 성공 확인 후 진행.
---
## 2단계: Step1 alembic upgrade (daemon_id NULL 컬럼 추가)
## 2단계: 사전 프리체크 (마이그레이션 시작 전)
### 2-1: 현재 alembic head/stamp 확인
```bash
export TIMESCALEDB_HOST=<DB_HOST>
@@ -40,62 +73,180 @@ export TIMESCALEDB_USER=<DB_USER>
export TIMESCALEDB_PASSWORD=<DB_PASS>
export PORTFOLIOHUB_DB_ENABLED=true
alembic current
```
**기대값**: `20260605_0025 (head)` — REL-002 마이그레이션(`20260605_0025 ledger_consolidation`)까지 적용된 상태
> **주의**: alembic 상태가 없거나(`None`) 다른 revision인 경우 팀장에 보고 후 중단. `alembic stamp 20260605_0025` 조치가 필요한지 여부를 팀장이 판단한다.
### 2-2: broker_credentials 현재 스키마 확인
```sql
-- 현재 컬럼 목록 확인
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'broker_credentials'
ORDER BY ordinal_position;
-- 기대: daemon_id 컬럼이 없는 상태 (Step1 미적용) 또는 nullable인 상태 (Step1 적용 후)
-- 현재 PK 확인
SELECT constraint_name, constraint_type
FROM information_schema.table_constraints
WHERE table_name = 'broker_credentials' AND constraint_type = 'PRIMARY KEY';
-- 기대: broker_credentials_pkey PRIMARY KEY
```
### 2-3: 기존 데이터 행 수 및 daemon_id NULL 현황 확인
```sql
-- 전체 행 수 (백업·복원 검증용으로 기록해 둘 것)
SELECT COUNT(*) AS total_rows FROM broker_credentials;
-- NULL 현황 (Step1 이전에는 daemon_id 컬럼 자체가 없으므로 실패 정상)
-- Step1 적용 후 실행:
SELECT COUNT(*) AS null_daemon_id_rows FROM broker_credentials WHERE daemon_id IS NULL;
```
### 2-4: 활성 connection 및 락 확인
```sql
-- 현재 broker_credentials 테이블을 잡고 있는 락 확인
SELECT pid, usename, application_name, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE query ILIKE '%broker_credentials%' AND state != 'idle';
-- 기대: 결과 없음 (락 없음)
-- 전체 활성 connection 수 확인
SELECT COUNT(*) FROM pg_stat_activity WHERE state = 'active';
```
> **중단 기준**: broker_credentials를 잡고 있는 활성 쿼리/락이 있으면 대기 또는 ops 윈도우 재조정. 서비스 배포 중단 확인 후 진행 권장.
---
## 3단계: Step1 alembic upgrade (daemon_id NULL 컬럼 추가)
```bash
alembic upgrade 20260628_0026
```
예상 출력:
```
INFO [alembic.runtime.migration] Running upgrade <prev> -> 20260628_0026, broker_credentials: daemon_id TEXT NULL 컬럼 추가 (REQ-047 Step1)
INFO [alembic.runtime.migration] Running upgrade 20260605_0025 -> 20260628_0026, broker_credentials: daemon_id TEXT NULL 컬럼 추가 (REQ-047 Step1)
```
### 검증
### Step1 검증
```sql
-- broker_credentials에 daemon_id TEXT NULL 컬럼이 존재해야 함
\d broker_credentials
-- 또는
SELECT column_name, is_nullable FROM information_schema.columns
-- daemon_id TEXT NULL 컬럼이 존재해야 함
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'broker_credentials' AND column_name = 'daemon_id';
-- 기대: daemon_id | YES (nullable)
-- 기대: daemon_id | text | YES (nullable)
-- 기존 PK (user_id, broker) 유지 확인
SELECT constraint_name, constraint_type FROM information_schema.table_constraints
WHERE table_name = 'broker_credentials' AND constraint_type = 'PRIMARY KEY';
-- 기대: broker_credentials_pkey | PRIMARY KEY
SELECT pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conname = 'broker_credentials_pkey';
-- 기대: PRIMARY KEY (user_id, broker)
-- 기존 데이터 daemon_id=NULL 보존 확인
SELECT COUNT(*) FROM broker_credentials; -- 기존 행 수 유지
SELECT COUNT(*) AS total_rows FROM broker_credentials;
-- 기대: 2단계에서 확인한 total_rows와 동일
```
### Step1 롤백 절차
Step1에서 문제 발생 시:
```bash
alembic downgrade 20260605_0025
```
예상 출력:
```
INFO [alembic.runtime.migration] Running downgrade 20260628_0026 -> 20260605_0025, ...
```
롤백 검증:
```sql
SELECT column_name FROM information_schema.columns
WHERE table_name = 'broker_credentials' AND column_name = 'daemon_id';
-- 기대: 결과 없음 (컬럼 제거됨)
```
> **중단 기준**: Step1 upgrade 실패(exit code != 0, 오류 메시지 출력) 시 즉시 downgrade 후 팀장에 보고.
---
## 3단계: backfill CLI — daemon_id 매핑
## 4단계: backfill CLI — daemon_id 매핑
모든 기존 broker_credentials 행에 daemon_id를 설정한다.
Step1 적용 후 모든 기존 broker_credentials 행에 daemon_id를 설정한다.
```bash
# 사용자별로 실행 (daemon_id는 해당 user의 daemon id)
pfhub credential backfill-daemon-id --user <username> --daemon-id <daemon_slug>
### 4-1: 대상 사용자/daemon 확인
# --dry-run으로 먼저 확인
pfhub credential backfill-daemon-id --user <username> --daemon-id <daemon_slug> --dry-run
```
### 검증: NULL 0건 확인
현재 user-scoped 인증정보(daemon_id IS NULL)가 있는 사용자와 그 데몬을 사전 파악한다:
```sql
-- 이 쿼리가 0을 반환해야 Step2를 진행할 수 있다
SELECT bc.user_id, u.username, bc.broker, bc.account_ref
FROM broker_credentials bc
LEFT JOIN users u ON bc.user_id = u.id
WHERE bc.daemon_id IS NULL;
```
각 (username, daemon_slug) 쌍을 확인한다. username은 포트폴리오 시스템에 등록된 사용자명, daemon_slug는 해당 사용자의 데몬 식별자(pfhub daemon list로 확인 가능).
### 4-2: --dry-run으로 선 확인
```bash
# 각 사용자별로 실행 (daemon_id는 해당 user의 daemon slug 또는 UUID)
pfhub credential backfill-daemon-id \
--user <username> \
--daemon-id <daemon_slug> \
--dry-run
```
예상 출력:
```
[dry-run] 대상 N건:
user_id=<uuid> broker=kis → daemon_id=<daemon_uuid>
{'status': 'dry_run', 'would_update': N}
```
### 4-3: 실제 백필 실행
```bash
pfhub credential backfill-daemon-id \
--user <username> \
--daemon-id <daemon_slug> \
--force
```
여러 사용자가 있으면 각 사용자별로 반복 실행한다.
### 4-4: NULL 0건 게이트 — Step2 진행 전 필수 확인
```sql
-- 이 쿼리가 반드시 0을 반환해야 Step2를 진행할 수 있다
SELECT COUNT(*) FROM broker_credentials WHERE daemon_id IS NULL;
-- 기대: 0
```
**경고: NULL이 1건이라도 있으면 Step2 upgrade가 RuntimeError로 거부된다. 반드시 0건 확인 후 진행.**
> **중단 기준**: NULL 잔존 시 해당 행의 user와 daemon 매핑이 확인되지 않은 것이므로 팀장/사장님에게 보고 후 처리 방침 확인. 임의로 daemon을 지정하지 않는다.
---
## 4단계: Step2 alembic upgrade (PK 전환) — 사장님 결재 필요
## 5단계: Step2 alembic upgrade (PK 전환) — 사장님 결재 필수
이 단계는 비가역 PK 변경이다. **사장님 결재 후에만 실행한다.**
> **CRITICAL**: 이 단계는 비가역 PK 변경이다. **사장님 결재 후에만 실행한다.** 결재 없이 실행 금지.
Step2 이전 최종 체크리스트:
- [ ] DB 백업 완료 (1단계)
- [ ] Step1 적용·검증 완료 (3단계)
- [ ] NULL 0건 확인 완료 (4단계)
- [ ] 사장님 결재 획득
```bash
alembic upgrade 20260628_0027
@@ -106,88 +257,132 @@ alembic upgrade 20260628_0027
INFO [alembic.runtime.migration] Running upgrade 20260628_0026 -> 20260628_0027, broker_credentials: PK (user_id,broker) → (daemon_id,broker) 전환 (REQ-047 Step2)
```
### 검증: Step2 완료 확인
### Step2 검증 — 완료 확인 (필수)
```sql
-- PK가 (daemon_id, broker)로 변경됐는지 확인
SELECT constraint_name FROM information_schema.table_constraints
WHERE table_name = 'broker_credentials' AND constraint_type = 'PRIMARY KEY';
-- 기대: broker_credentials_pkey
SELECT pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conname = 'broker_credentials_pkey';
-- 기대: PRIMARY KEY (daemon_id, broker)
-- daemon_id NOT NULL 확인
SELECT column_name, is_nullable FROM information_schema.columns
SELECT column_name, is_nullable
FROM information_schema.columns
WHERE table_name = 'broker_credentials' AND column_name = 'daemon_id';
-- 기대: daemon_id | NO (not nullable)
-- user_id 컬럼 유지 확인
-- user_id 컬럼 유지 확인 (감사/이력 목적, REQ-047 AC-4)
SELECT column_name FROM information_schema.columns
WHERE table_name = 'broker_credentials' AND column_name = 'user_id';
-- 기대: user_id 존재
-- 데이터 손실 없음 확인
SELECT COUNT(*) AS total_rows FROM broker_credentials;
-- 기대: 2단계 total_rows와 동일
-- alembic head 갱신 확인
-- (alembic 명령으로 확인)
```
---
## 5단계: 코드 배포 (BAT-021 코드 포함)
Step2 alembic upgrade가 완료된 후에 코드를 배포한다.
포함 변경:
- `app/repositories/postgres.py`:
- `ensure_schema()` broker_credentials: daemon_id NOT NULL, PK=(daemon_id, broker)
- `_row_to_broker_credential()`: Step2 컬럼 순서 (daemon_id 선두)
- `upsert_broker_credential()`: ON CONFLICT (daemon_id, broker), fallback 제거
- `list_broker_credentials()`: fallback 제거, Step2 컬럼 순서
---
## 6단계: 동작 확인
```bash
# pfhub CLI로 인증정보 조회/등록 테스트
pfhub credential list --user <username> --daemon-id <daemon_id>
# 서비스 헬스체크
curl http://<SERVICE_HOST>/health
alembic current
# 기대: 20260628_0027 (head)
```
---
### Step2 롤백 절차 (조건부 가역 — 주의)
## 롤백 절차
### Step2 롤백 (alembic downgrade)
Step2 downgrade는 (user_id, broker) 중복이 없는 경우에만 가능하다.
Step2 downgrade는 같은 user의 여러 daemon이 동일 broker를 사용하는 경우 RuntimeError로 자동 거부된다.
```bash
# Step2 → Step1 상태로 복원
# Step2 → Step1 상태로 복원 시도
alembic downgrade 20260628_0026
```
**경고**: 같은 user의 여러 daemon이 동일 broker를 사용하는 경우 downgrade가 RuntimeError로 거부된다.
이 경우 수동 데이터 정리 후 downgrade하거나 백업에서 복원한다.
### Step1 롤백
```bash
# Step1 → 이전 상태로 복원 (daemon_id 컬럼 제거)
alembic downgrade 20260605_0025
**경고**: 같은 user의 여러 daemon이 동일 broker를 사용하는 경우 downgrade가 다음과 같이 거부된다:
```
RuntimeError: downgrade 불가: N개 (user_id, broker) 조합이 중복됩니다.
같은 user의 여러 daemon이 동일 broker를 사용하고 있어
(user_id, broker) PK로 복원 시 데이터 손실이 발생합니다.
수동으로 데이터를 정리한 후 downgrade하세요.
```
이 경우 백업에서 복원해야 한다(아래 DB 복원 참조).
### DB 복원 (최후 수단)
```bash
pg_restore -h <DB_HOST> -U <DB_USER> -d <DB_NAME> -c backup_pre_bat021_<TIMESTAMP>.dump
pg_restore -h <DB_HOST> -U <DB_USER> -d <DB_NAME> -c \
backup_pre_bat021_<TIMESTAMP>.dump
```
> **비가역 지점 강조**: Step2 PK 전환 완료 후에는 구 스키마(`PK=(user_id, broker)`)로의 롤백이 데이터 정리 없이 불가능할 수 있다. **백업 없이 Step2를 실행하면 복원이 불가능하다.** Step2 완료 후 추가 백업도 권장한다.
---
## 6단계: BAT-021 코드 배포 (Step2 완료 이후)
Step2 alembic upgrade 완료 후 코드를 배포한다.
포함 변경:
- `app/repositories/postgres.py`:
- `ensure_schema()` broker_credentials: daemon_id NOT NULL, PK=(daemon_id, broker) 정합
- `_row_to_broker_credential()`: Step2 컬럼 순서 (daemon_id 선두)
- `upsert_broker_credential()`: ON CONFLICT (daemon_id, broker), fallback 제거
- `list_broker_credentials()`: fallback 제거, Step2 컬럼 순서
```bash
# portfolio-hub 서비스 rolling restart (freeze 커밋 9fe6f3b)
# 배포 방법은 운영 절차에 따름 (예: 컨테이너 재시작, systemd restart 등)
```
---
## 주의사항
## 7단계: 배포 후 동작 확인
- Step2 이전에 BAT-021 코드를 배포하면 `ON CONFLICT (daemon_id, broker)` 절이 구 스키마와 맞지 않아 오류 발생.
- 배포 순서: Step1 → backfill → Step2 → 코드 배포 순서를 엄수한다.
```bash
# 헬스체크
curl -s http://<SERVICE_HOST>/health | python3 -m json.tool
# 기대: {"status": "ok", "db_enabled": true, ...}
# pfhub CLI로 인증정보 조회 테스트 (Step2 이후 스키마 정합)
pfhub credential list --user <username> --daemon-id <daemon_slug>
# 기대: 인증정보 1건, daemon_id가 설정된 상태
```
---
## go/no-go 게이트
| 게이트 | 조건 | 판단 |
| --- | --- | --- |
| DB 백업 | 파일 생성 + 크기 > 0 + exit 0 | go / **NO-GO → 중단** |
| alembic current | `20260605_0025 (head)` (Step1 이전 기준) | go / **NO-GO → 팀장 보고** |
| 활성 락 없음 | broker_credentials 대상 활성 쿼리 없음 | go / **NO-GO → 대기 또는 재조정** |
| Step1 검증 | daemon_id TEXT NULL, PK=(user_id,broker) 유지 | go / **NO-GO → Step1 downgrade 후 팀장 보고** |
| NULL 0건 | `SELECT COUNT(*) ... WHERE daemon_id IS NULL` = 0 | go / **NO-GO → backfill 재수행 또는 팀장 보고** |
| 사장님 결재 | 명시적 결재 확인 | go / **NO-GO → Step2 실행 금지** |
| Step2 검증 | PK=(daemon_id,broker), NOT NULL, user_id 존재, 행 수 일치 | go / **NO-GO → 롤백 또는 DB 복원** |
| 배포 후 헬스 | GET /health → 200, db_enabled=true | go / **NO-GO → 코드 롤백** |
### 중단 기준 요약
이 중 하나라도 발생하면 즉시 해당 단계를 중단하고 팀장에게 보고한다:
1. DB 백업 실패
2. alembic current가 기대값과 다름
3. broker_credentials 활성 락 존재 (ops 윈도우 재조정)
4. Step1 upgrade 실패
5. NULL 잔존 (backfill 미완료)
6. 사장님 결재 미획득 상태에서 Step2 실행 시도
7. Step2 upgrade 실패
8. Step2 검증 쿼리 기대값 불일치
9. 배포 후 헬스체크 실패
---
## 주의사항 요약
- Step2 이전에 BAT-021 코드를 배포하면 `ON CONFLICT (daemon_id, broker)` 절이 구 스키마와 맞지 않아 인증정보 upsert 오류 발생.
- 배포 순서: **Step1 → backfill → NULL 0건 확인 → Step2(결재) → 코드 배포** 순서를 엄수한다.
- `ensure_schema()`는 `IF NOT EXISTS`를 사용하므로 Step2 적용 후 실행 시 테이블이 이미 존재해 스킵됨 (신규 인스턴스 초기화 시에만 적용됨).
- `CREDENTIAL_ENCRYPTION_KEY`가 운영 환경에 설정돼 있지 않으면 인증정보 암·복호화가 동작하지 않는다. 배포 전 반드시 확인.