vignette/docs/ops/nas-preview-g8-rollback-proof-runbook.md
Yun Chan 93dd8f82d7 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 체커가 실패한다.
2026-08-07 22:17:20 +09:00

8.1 KiB

NAS 프리뷰 G8 실제 rollback 증명 런북 (2026-08-07)

대상: 격리 NAS 프리뷰 vignette-preview-20260807 (http://100.116.83.60:8088). 이 런북은 공개 서비스(vignette.chanpaca.net)를 건드리지 않는다. 상태 SSOT는 docs/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을 갖고 있기 때문이다.

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.projectvignette-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. 사전 상태 (읽기 전용으로 매번 재확인)

$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/healthstatus=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_URLvignette_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의 G8 절과 evidence/nas-preview-g8-actual-rollback-2026-08-07.json에 있다.