# 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=` 멤버십 조회 | | 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-` | | 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` (37/37). 실제 실행 없이 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-.env`, mode 0600) 생성 — 무작위 token과 stable journal key. 값은 출력하지 않는다. 3. control-plane env(`control-plane-.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)에 있다.