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:
parent
76d0b9ae9b
commit
93dd8f82d7
22 changed files with 10057 additions and 473 deletions
|
|
@ -6,8 +6,10 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
|
@ -18,9 +20,11 @@ DEFAULT_SOURCE_GAPS = REPO_ROOT / "docs" / "guides" / "source-docs-and-gaps.md"
|
|||
DEFAULT_BACKLOG = REPO_ROOT / "docs" / "ops" / "backlog-2026-06-26.md"
|
||||
DEFAULT_TESTING = REPO_ROOT / "docs" / "guides" / "testing.md"
|
||||
DEFAULT_LOCAL_DEVELOPMENT = REPO_ROOT / "docs" / "guides" / "local-development.md"
|
||||
DEFAULT_TODO = REPO_ROOT / "docs" / "TODO.md"
|
||||
|
||||
EXPECTED_STATUS_COUNTS = {
|
||||
"done": 25,
|
||||
"done": 33,
|
||||
"doing": 2,
|
||||
"planned": 0,
|
||||
}
|
||||
EXPECTED_OWNER_COLUMN_COUNTS = {
|
||||
|
|
@ -31,6 +35,9 @@ EXPECTED_OWNER_COLUMN_COUNTS = {
|
|||
EXPECTED_CRITICAL_CARD_COUNT = 3
|
||||
EXPECTED_DECISION_DETAIL_ROWS = 0
|
||||
EXPECTED_EXTERNAL_GATE_ROWS = 6
|
||||
EXPECTED_OUTCOME_GATES = tuple(f"G{index}" for index in range(9))
|
||||
EXPECTED_AOS_IDS = tuple(f"AOS-{index:03d}" for index in range(1, 13))
|
||||
PLAYWRIGHT_ROOT = REPO_ROOT / "apps" / "web"
|
||||
|
||||
REQUIRED_SHARED_PHRASES = (
|
||||
"M2 digest worker + memory focused 30 passed",
|
||||
|
|
@ -43,7 +50,7 @@ REQUIRED_SHARED_PHRASES = (
|
|||
"owner 결정 7건은 2026-06-30 전건 확정",
|
||||
"결정 0개(전건 확정)",
|
||||
"MASTERPLAN_REVISIONS F-10 승인",
|
||||
"Public API <b>recovered OK</b>",
|
||||
"Public API <b>119 paths · G1~G8</b>",
|
||||
"error code 1033",
|
||||
"Docker Desktop/DB 재기동",
|
||||
"watchdog healthy: engine, api, web-preview, cloudflared, public-api",
|
||||
|
|
@ -84,10 +91,48 @@ class Check:
|
|||
detail: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlaywrightInventory:
|
||||
tests: int
|
||||
files: int
|
||||
|
||||
|
||||
def _parse_playwright_inventory(output: str) -> PlaywrightInventory:
|
||||
match = re.search(r"^Total:\s+(\d+) tests in (\d+) files$", output, re.MULTILINE)
|
||||
if match is None:
|
||||
raise ValueError("playwright --list summary is missing")
|
||||
return PlaywrightInventory(tests=int(match.group(1)), files=int(match.group(2)))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _collect_playwright_inventory() -> PlaywrightInventory:
|
||||
executable = shutil.which("npx.cmd") or shutil.which("npx")
|
||||
if executable is None:
|
||||
raise RuntimeError("npx is unavailable for Playwright inventory")
|
||||
completed = subprocess.run(
|
||||
[executable, "playwright", "test", "--list"],
|
||||
cwd=PLAYWRIGHT_ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=120,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
detail = (completed.stderr or completed.stdout).strip()
|
||||
raise RuntimeError(
|
||||
f"playwright --list failed ({completed.returncode}): {detail[-500:]}"
|
||||
)
|
||||
return _parse_playwright_inventory(completed.stdout)
|
||||
|
||||
|
||||
class DashboardStructureParser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.status_counts: dict[str, int] = {}
|
||||
self.status_counts: dict[str, int] = {
|
||||
status: 0 for status in EXPECTED_STATUS_COUNTS
|
||||
}
|
||||
self.owner_column_counts: dict[str, int] = {}
|
||||
self.decision_panel_rows = 0
|
||||
self.external_gate_rows = 0
|
||||
|
|
@ -164,15 +209,75 @@ def _has_check_status_markup(dashboard: str) -> bool:
|
|||
)
|
||||
|
||||
|
||||
def run_checks(paths: argparse.Namespace) -> dict[str, Any]:
|
||||
def run_checks(
|
||||
paths: argparse.Namespace,
|
||||
*,
|
||||
playwright_inventory: PlaywrightInventory | None = None,
|
||||
) -> dict[str, Any]:
|
||||
dashboard = _read(paths.dashboard)
|
||||
source_gaps = _read(paths.source_gaps)
|
||||
backlog = _read(paths.backlog)
|
||||
testing = _read(paths.testing)
|
||||
local_development = _read(paths.local_development)
|
||||
todo = _read(paths.todo)
|
||||
joined_docs = "\n".join([dashboard, source_gaps, backlog, testing, local_development])
|
||||
|
||||
checks: list[Check] = []
|
||||
inventory_error: str | None = None
|
||||
if playwright_inventory is None:
|
||||
try:
|
||||
playwright_inventory = _collect_playwright_inventory()
|
||||
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc:
|
||||
inventory_error = str(exc)
|
||||
if playwright_inventory is None:
|
||||
checks.append(
|
||||
Check(
|
||||
name="playwright_inventory_collected",
|
||||
passed=False,
|
||||
detail=inventory_error or "unknown inventory failure",
|
||||
)
|
||||
)
|
||||
else:
|
||||
inventory_phrase = (
|
||||
f"{playwright_inventory.tests} tests / {playwright_inventory.files} files"
|
||||
)
|
||||
checks.append(
|
||||
Check(
|
||||
name="playwright_inventory_collected",
|
||||
passed=True,
|
||||
detail=inventory_phrase,
|
||||
)
|
||||
)
|
||||
checks.append(
|
||||
Check(
|
||||
name="playwright_inventory_dashboard_current",
|
||||
passed=inventory_phrase in dashboard,
|
||||
detail=(
|
||||
f"expected current inventory in dashboard: {inventory_phrase}"
|
||||
),
|
||||
)
|
||||
)
|
||||
checks.append(
|
||||
Check(
|
||||
name="playwright_inventory_testing_guide_current",
|
||||
passed=inventory_phrase in testing,
|
||||
detail=(
|
||||
f"expected current inventory in testing guide: {inventory_phrase}"
|
||||
),
|
||||
)
|
||||
)
|
||||
stale_inventory_claims = (
|
||||
"현재 수집 기준 215 tests / 20 files",
|
||||
"**총 215 tests / 20 files**",
|
||||
)
|
||||
for phrase in stale_inventory_claims:
|
||||
checks.append(
|
||||
Check(
|
||||
name=f"stale_playwright_inventory::{phrase}",
|
||||
passed=phrase not in joined_docs,
|
||||
detail="absent" if phrase not in joined_docs else "present",
|
||||
)
|
||||
)
|
||||
structure = _dashboard_structure(dashboard)
|
||||
counts = structure.status_counts
|
||||
for status, expected in EXPECTED_STATUS_COUNTS.items():
|
||||
|
|
@ -184,12 +289,11 @@ def run_checks(paths: argparse.Namespace) -> dict[str, Any]:
|
|||
detail=f"expected={expected} actual={actual}",
|
||||
)
|
||||
)
|
||||
doing_count = counts.get("doing", 0)
|
||||
checks.append(
|
||||
Check(
|
||||
name="no_doing_cards",
|
||||
passed=doing_count == 0 and not _has_check_status_markup(dashboard),
|
||||
detail=f"doing={doing_count}",
|
||||
name="no_legacy_check_status_markup",
|
||||
passed=not _has_check_status_markup(dashboard),
|
||||
detail="task-status s-check/CHECK markup absent",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -202,6 +306,36 @@ def run_checks(paths: argparse.Namespace) -> dict[str, Any]:
|
|||
)
|
||||
)
|
||||
|
||||
outcome_gates = tuple(
|
||||
re.findall(
|
||||
r'<span class="chip c-(?:plan|doing|done)">(G[0-8]) (?:GATE|BUILD|DONE)(?: · [^<]+)?</span>',
|
||||
dashboard,
|
||||
)
|
||||
)
|
||||
checks.append(
|
||||
Check(
|
||||
name="outcome_alliance_gate_cards_complete",
|
||||
passed=outcome_gates == EXPECTED_OUTCOME_GATES,
|
||||
detail=f"expected={EXPECTED_OUTCOME_GATES} actual={outcome_gates}",
|
||||
)
|
||||
)
|
||||
todo_gate_headings = tuple(re.findall(r"^### I-[0-8]\. (G[0-8])\b", todo, re.MULTILINE))
|
||||
checks.append(
|
||||
Check(
|
||||
name="outcome_alliance_todo_gates_complete",
|
||||
passed=todo_gate_headings == EXPECTED_OUTCOME_GATES,
|
||||
detail=f"expected={EXPECTED_OUTCOME_GATES} actual={todo_gate_headings}",
|
||||
)
|
||||
)
|
||||
todo_aos_ids = tuple(re.findall(r"\*\*(AOS-\d{3})\*\*", todo))
|
||||
checks.append(
|
||||
Check(
|
||||
name="outcome_alliance_first_dag_complete",
|
||||
passed=todo_aos_ids == EXPECTED_AOS_IDS,
|
||||
detail=f"expected={EXPECTED_AOS_IDS} actual={todo_aos_ids}",
|
||||
)
|
||||
)
|
||||
|
||||
for owner_column, expected in EXPECTED_OWNER_COLUMN_COUNTS.items():
|
||||
actual = structure.owner_column_counts.get(owner_column, 0)
|
||||
checks.append(
|
||||
|
|
@ -279,6 +413,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
parser.add_argument("--backlog", type=Path, default=DEFAULT_BACKLOG)
|
||||
parser.add_argument("--testing", type=Path, default=DEFAULT_TESTING)
|
||||
parser.add_argument("--local-development", type=Path, default=DEFAULT_LOCAL_DEVELOPMENT)
|
||||
parser.add_argument("--todo", type=Path, default=DEFAULT_TODO)
|
||||
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
|
||||
return parser
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue