G8 실제 rollback 증명 종료와 비-secure origin 회기 리뷰 크래시 수정

G8 마지막 게이트인 receipt-bound 실제 image rollback을 격리 NAS
vignette-preview-20260807 에서 실행해 종료했다.

Gate6 계약 정정:
감사 대상 current API 이미지가 com.docker.compose.project/service/version
image label 을 갖고 있어 "helper 의 compose label 0개" 계약은 감사되지 않은
다른 이미지를 쓰지 않는 한 성립하지 않는다. 계약을 key 부재가 아니라
소속(membership) 으로 바꿔 launch-nas-preview-g8-helpers.py 에 구현했다.
image 상속 label 을 baseline 으로 읽고 container 의 모든 compose label 이
baseline 과 같거나 선언된 격리 override 인지 검사하며, 최종 project 는
target 이 아니고 service 는 api/web/db/proxy 가 아니어야 한다. docker run
argv 에 target label 을 주입하면 fake-runner 테스트가 먼저 깨진다 (37/37).

실행 결과:
- rollback-old  receipt nas-g8-723eeef22eab05e63e3fafb0 -> 79ec../c530..
- restore-current receipt nas-g8-2738846cf2cf4fbe8ce0fc26 -> 52e0../6fdb..
- release gate/approval 각 2회 멱등, audit.ci_lifecycle_event rollback/executed 2,
  audit.ci_human_approval_event authorize_rollback 2, silent auto-promotion 0
- HMAC journal 6-record 체인 검증, health 3/3, OpenAPI 126, auth 401, Web 200
- helper 0, listener 0, 비밀 env 파기. down/volume rm/prune 미실행, 공개 런타임 미접촉
- 계획했던 Windows SSH 터널은 NAS sshd 가 direct-tcpip 를 거부해 사용할 수 없어
  sshd 설정 변경 대신 같은 격리 계약의 NAS-side probe 컨테이너로 실행했다

비-secure origin 크래시 수정:
배포된 NAS 프리뷰(평문 HTTP, 비-localhost)에 회기 스펙을 돌려 24건 실패를 확인했고
원인은 하나였다. crypto.randomUUID 는 secure context 전용인데 제품 코드 18곳이
fallback 없이 호출했고 RuptureRepairCard 는 렌더 시점 호출이라 회기 리뷰 라우트
전체가 error boundary 로 떨어졌다. 릴리스 게이트 108/108 은 localhost 후보 스택에서만
돌아 이 경로를 밟은 적이 없다. src/lib/uuid.ts 의 randomUuid() 로 통일하고 fallback 도
crypto.getRandomValues 를 우선 사용해 idempotency key 의 예측 불가능성을 유지했다.
회귀는 insecure-context-uuid.spec.ts 6/6 으로 고정했다(직접 호출 0건 검사 포함).
이 수정은 아직 NAS 에 배포하지 않았다.

검증:
API 898, gateway 58, executor 28, probe 11, helper launcher 37, release agent 21,
ruff clean, web api-types/typecheck/build, SSOT FAIL 0, SSOT unit 5/5,
dashboard E2E 10/10, 학생 폐루프 실 DB 브라우저 4/4(일회용 클론),
crypto 수정 후 기존 스펙 회귀 70/70, 복원된 NAS 실제 브라우저 SSE->DB 리뷰 PASS.

부수 발견(열린 항목):
공개 API 가 engine=false 로 degraded 인데 워치독이 이를 감지하지 못한다. engine 판정이
게이트웨이 /health 의 ok 만 보고 claude readiness probe 를 돌리지 않기 때문이다.
같은 .env 와 같은 CLI 로 새 게이트웨이를 다른 포트에 띄우면 즉시 ready 이므로 상주
프로세스의 세션만 죽은 형태다. TODO A절과 대시보드에 기록했다.

이 커밋은 파일 단위로 담겼다. 위 파일들에는 이전 세션의 미커밋 G0~G8 작업이 함께
들어 있으며, hunk 를 쪼개면 대시보드/체커/TODO 정합성이 깨져 SSOT 체커가 실패한다.
This commit is contained in:
Yun Chan 2026-08-07 22:17:20 +09:00
parent 76d0b9ae9b
commit 93dd8f82d7
22 changed files with 10057 additions and 473 deletions

View file

@ -0,0 +1,136 @@
# NAS 프리뷰 G8 실제 rollback 증명 런북 (2026-08-07)
> 대상: 격리 NAS 프리뷰 `vignette-preview-20260807` (`http://100.116.83.60:8088`).
> 이 런북은 **공개 서비스(`vignette.chanpaca.net`)를 건드리지 않는다.**
> 상태 SSOT는 [`docs/dev_dashboard.html`](../dev_dashboard.html)이다.
## 0. 무엇을 증명하는가
G8 "지속 개선"의 마지막 열린 게이트는 *합성 control plane*이 아니라 **실제 NAS 런타임에서
receipt가 묶인 rollback 실행**이다. 증명 단위는 두 번이다.
1. `rollback-old` — 현재 릴리스(`6030a677…`)에서 이전 릴리스(`a8c27b0a…`) 이미지로 되돌린다.
2. `restore-current` — 다시 현재 릴리스 이미지로 복원한다.
두 번 모두 durable `executed` lifecycle receipt, artifact/approval binding, health 재확인까지
통과해야 한다. health만 보고 성공이라 부르지 않는다.
## 1. Gate6 helper 격리 계약 (2026-08-07 정정)
### 정정 이전의 잘못된 계약
이전 계획은 helper container의 `com.docker.compose.*` label 개수가 **0개**여야 한다고 요구했다.
이 계약은 성립할 수 없다. 감사 대상 런타임 이미지 자체가 image label을 갖고 있기 때문이다.
```text
sha256:52e0e816…8b2d → com.docker.compose.project=vignette-preview-20260807
com.docker.compose.service=api
com.docker.compose.version=2.20.1
```
이 이미지로 만든 모든 container는 위 label을 **상속**한다. 따라서 "0개" 요구를 지키려면
감사되지 않은 다른 이미지를 써야 하고, 그건 오히려 증명을 약하게 만든다.
### 실제로 강제하는 계약 — key 부재가 아니라 소속(membership)
`scripts/launch-nas-preview-g8-helpers.py`가 아래를 코드로 강제한다.
| # | 계약 | 구현 |
|---|---|---|
| 1 | helper는 target Compose project에 속하지 않는다 | `verify_container_isolation` + `docker ps --filter label=com.docker.compose.project=<target>` 멤버십 조회 |
| 2 | image 상속 label을 baseline으로 읽고, container의 모든 `com.docker.compose.*`는 baseline과 같거나 **선언된 isolated override**여야 한다 | `docker image inspect` baseline ↔ `docker inspect` container 비교, drift는 `helper_compose_label_drift`로 fail-closed |
| 3 | 최종 `com.docker.compose.project``vignette-preview-20260807` | isolated override `vignette-g8-helper-<run-id>` |
| 4 | 최종 `com.docker.compose.service` ∉ {`api`,`web`,`db`,`proxy`} | isolated override `g8-rollback-executor` / `g8-rollback-control-plane` |
| 5 | `docker run` argv에 target project/service label을 넣지 않는다 | `assert_argv_isolation` + fake-runner 테스트 |
| 6 | exact container name·ID와 `docker inspect` 증거를 남긴다 | `verify` 산출 evidence(`vignette.nas-preview-g8-helper-isolation.v1`) |
테스트: `scripts/test_launch_nas_preview_g8_helpers.py` (31/31). 실제 실행 없이 argv 계약이
고정되므로, 누가 나중에 target label을 주입하면 테스트가 먼저 깨진다.
### helper 하드닝 (argv에 고정)
`--network host`(loopback bind 전용), `--user 1028:100`, `--group-add 101`(docker socket),
`--read-only`, `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit 256`,
`--restart no`, target root RO 마운트 + `.rollback-executor` RW 중첩, Docker binary/Compose
plugin RO, socket 마운트. `--publish`는 쓰지 않는다(host network).
## 2. 사전 상태 (읽기 전용으로 매번 재확인)
```powershell
$ssh = @('-i','C:\Users\encep\.ssh\id_ed25519','-o','IdentitiesOnly=yes','-o','BatchMode=yes','YunChan@100.116.83.60')
```
확인 항목:
- current images: API `sha256:52e0e816…8b2d`, Web `sha256:6fdbb646…f215`
- previous images: API `sha256:79ec731f…0450`, Web `sha256:c530213f…62f28`
- preview health `http://127.0.0.1:8088/api/health``status=ok`, `db=true`, `engine=true`
- listeners 18149 / 8018 = 0, helper containers = 0
- `.rollback-executor` 해시 4종(manifest / 두 plan / executor·probe 스크립트)
- pre-proof DB dump 존재와 SHA256
## 3. 실행 순서
1. 읽기 전용 재검증(§2).
2. executor env(`executor-<run-id>.env`, mode 0600) 생성 — 무작위 token과 stable journal key.
값은 출력하지 않는다.
3. control-plane env(`control-plane-<run-id>.env`, mode 0600) 생성 — preview `.env`에서
비밀값을 **NAS 안에서만** 복사하고 아래를 덮어쓴다.
- `DATABASE_URL``vignette_app@172.22.0.2:5432/vignette_preview`
- producer/drift trigger 전부 `false`
- `VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_ENABLED=true`
- `…_ENDPOINT=http://127.0.0.1:18149/rollback`
- `…_TOKEN` = executor token
- `AUTH_DEV_LOGIN_ENABLED=true`, `AUTH_ADMIN_EMAILS`에 probe 계정만
4. `launch-nas-preview-g8-helpers.py start --role executor` → isolation evidence 확인,
unauth `/healthz` 403 / token-auth 200 확인.
5. `launch-nas-preview-g8-helpers.py start --role control-plane``/api/health` 확인.
6. Windows SSH 터널 `127.0.0.1:18018 → NAS 127.0.0.1:8018` (exact PID 기록).
7. `probe-nas-preview-g8-rollback.py execute --plan rollback-old.plan.json`
release gate 2회 멱등, approval 2회 멱등, lifecycle `executed`, receipt binding,
NAS previous images 활성화 + health 3회.
8. 같은 executor/control-plane으로 `restore-current.plan.json` 실행 → current images 복원,
health 3/3, OpenAPI 126, auth 401, Web 200.
9. 증거 저장 → UUID/email/secret regex scan 0.
10. cleanup 순서: tunnel → control-plane → executor. listeners 0, helper 0 확인.
11. token/control env 삭제, journal/manifest/evidence 보존.
## 4. 금지 사항
- `docker compose down`, `docker volume rm`, `docker system prune` 금지.
- current images와 health를 확인하기 전에 helper를 종료하거나 DONE으로 기록하지 않는다.
- 비밀값·이메일·UUID·raw response를 로그·증거·채팅에 출력하지 않는다.
- 공개 복구 DB(`vignette-dev-db`)와 그 volume/dump/container는 건드리지 않는다.
## 5. 실패 시 복구
executor는 실행 직전 관측한 exact image ID로 되돌린다. 그래도 어긋나면 pinned current images
(`52e0e816…8b2d` / `6fdbb646…f215`)로 `compose up -d --no-build --no-deps --pull never
--force-recreate api web`를 수행하고 health 3회를 다시 통과시킨다. DB는 pre-proof dump
(`vignette-preview-g8-preproof-20260807T110610Z.dump`, SHA256 `92ed4736…1570f`)로 복원 가능하다.
## 6. 실행 결과 (2026-08-07 21:04~21:12 KST, run-id `20260807g8a`)
| plan | receipt | 활성화된 API/Web 이미지 |
|---|---|---|
| `rollback-old` | `nas-g8-723eeef22eab05e63e3fafb0` | `79ec…4450` / `c530…2f28` |
| `restore-current` | `nas-g8-2738846cf2cf4fbe8ce0fc26` | `52e0…8b2d` / `6fdb…f215` |
- release gate·approval 각각 2회 멱등, lifecycle `executed`, binding·control-plane 분리 검증 PASS
- DB: `audit.ci_lifecycle_event` `rollback/executed` 2 · `audit.ci_human_approval_event` `authorize_rollback` 2 ·
`app.ci_release_gate` 2(둘 다 `pending_human_approval`, silent auto-promotion 0) · `ci_gate_artifact` 8
- HMAC journal 6 records, `previous_hash` 체인 전수 PASS, SHA256 `a5594feb…0690`
- 최종: health 3/3 · OpenAPI 126 · auth 401 · Web 200 · helper 0 · listener 0 · 비밀 env 파기
- 롤백/복원 뒤 실제 브라우저 SSE→DB review 재실행 PASS (35.0s)
### 3단계 이탈 기록 — SSH 터널 사용 불가
계획된 Windows SSH 터널(`18018 → 8018`)은 NAS sshd가 direct-tcpip를
`administratively prohibited: open failed`로 거부했다. **sshd 설정은 바꾸지 않았고**, 같은 격리 계약의
NAS-side probe helper 컨테이너에서 loopback으로 실행했다. control-plane(`127.0.0.1:8018`)과
preview(`127.0.0.1:8088`) origin이 다르므로 probe의 `main_preview_self_rollback_forbidden` 계약은
그대로 강제된다. 다음 실행자도 NAS에서 포트 포워딩을 기대하지 말 것.
상세 hash와 container ID는
[`nas-preview-deployment-evidence-2026-08-07.md`](./nas-preview-deployment-evidence-2026-08-07.md)의 G8 절과
[`evidence/nas-preview-g8-actual-rollback-2026-08-07.json`](./evidence/nas-preview-g8-actual-rollback-2026-08-07.json)에 있다.