vignette/docs/ops/nas-preview-g8-rollback-proof-runbook.md
2026-08-09 18:22:03 +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 (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. 사전 상태 (읽기 전용으로 매번 재확인)

$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에 있다.