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 체커가 실패한다.
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 실행이다. 증명 단위는 두 번이다.
rollback-old— 현재 릴리스(6030a677…)에서 이전 릴리스(a8c27b0a…) 이미지로 되돌린다.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.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. 사전 상태 (읽기 전용으로 매번 재확인)
$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, Websha256:6fdbb646…f215 - previous images: API
sha256:79ec731f…0450, Websha256: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. 실행 순서
- 읽기 전용 재검증(§2).
- executor env(
executor-<run-id>.env, mode 0600) 생성 — 무작위 token과 stable journal key. 값은 출력하지 않는다. - 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 tokenAUTH_DEV_LOGIN_ENABLED=true,AUTH_ADMIN_EMAILS에 probe 계정만
launch-nas-preview-g8-helpers.py start --role executor→ isolation evidence 확인, unauth/healthz403 / token-auth 200 확인.launch-nas-preview-g8-helpers.py start --role control-plane→/api/health확인.- Windows SSH 터널
127.0.0.1:18018 → NAS 127.0.0.1:8018(exact PID 기록). probe-nas-preview-g8-rollback.py execute --plan rollback-old.plan.json→ release gate 2회 멱등, approval 2회 멱등, lifecycleexecuted, receipt binding, NAS previous images 활성화 + health 3회.- 같은 executor/control-plane으로
restore-current.plan.json실행 → current images 복원, health 3/3, OpenAPI 126, auth 401, Web 200. - 증거 저장 → UUID/email/secret regex scan 0.
- cleanup 순서: tunnel → control-plane → executor. listeners 0, helper 0 확인.
- 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_eventrollback/executed2 ·audit.ci_human_approval_eventauthorize_rollback2 ·app.ci_release_gate2(둘 다pending_human_approval, silent auto-promotion 0) ·ci_gate_artifact8 - HMAC journal 6 records,
previous_hash체인 전수 PASS, SHA256a5594feb…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에 있다.