ci: win-ci 검증 출력을 호스트 파일로 회수한다 (증거 기준선)
Some checks failed
Windows / verify (push) Has been cancelled

VM 을 만든 이유가 "리눅스에서 통과한 게 Windows 에서 깨지는 것을 재현
가능하게 본다" 인데, 정작 실패 트레이스백이 호스트에 한 줄도 안 남았다.
로그 채널은 마지막 몇 줄만 흘렸고(Select-Object -Last 30), serial.log 는
VM 이 뜰 때마다 truncate 되고, guest.log 는 stage2 가 다시 돌 때만 채워진다.
그래서 실패 내용을 화면에서 읽어 옮겨 적는 상태였고 — 고친 뒤에 고쳐졌다는
것을 증명할 기준선이 없었다.

- serve-host.py: POST /artifact/<name> 추가. results/<name> 에 파일로 저장
  (경로는 basename 으로만 취하고 32MB 상한)
- stage2.ps1: Step 이 각 단계의 출력 전문을 StreamWriter(AutoFlush)로 받아
  호스트로 업로드한다. 중간에 예외로 죽어도 거기까지는 남는다.
  출력 자르기를 없애고 pytest 는 --tb=long, 검증 리비전을 박은
  summary.log 도 함께 올린다
- sync.sh: 스크립트만 /home/claude/win-ci 로 반영한다. build.sh 를 다시
  돌리면 지워둔 Windows ISO 4.8GB 를 다시 내려받는다

첫 수집 결과(rev 162540b)로 실제 원인 두 개를 특정했다.
This commit is contained in:
EJClaw
2026-09-25 23:38:48 +09:00
parent dbe1370425
commit a66eedee8b
3 changed files with 136 additions and 23 deletions

View File

@@ -5,22 +5,31 @@
이 서버는 그 반대편이다. 이 서버는 그 반대편이다.
GET /ping 게스트가 네트워크 올라왔는지 확인용 GET /ping 게스트가 네트워크 올라왔는지 확인용
GET /stage20.ps1 게스트가 실행할 설치 스크립트 (serve/ 안의 파일 그대로) GET /stage2.ps1 게스트가 실행할 설치 스크립트 (serve/ 안의 파일 그대로)
GET /<file> serve/ 안의 아무 파일 (러너 등록 토큰 등) GET /<file> serve/ 안의 아무 파일 (러너 등록 토큰 등)
POST /log 게스트 로그 -> guest.log 에 append POST /log 게스트 로그 -> guest.log 에 append
POST /artifact/<name> 게스트 명령 출력 원본 -> results/<name> 에 파일로 저장
/artifact 가 필요한 이유: 게스트 검증 결과를 사람이 화면에서 읽고 옮겨 적으면
"고쳤다"를 증명할 기준선이 안 남는다. serial.log 는 VM 이 뜰 때마다 truncate 되고
로그 채널은 마지막 몇 줄만 흘린다. pytest 트레이스백처럼 전문이 필요한 출력은
반드시 이 엔드포인트로 호스트 파일에 떨어뜨린다.
127.0.0.1 만 바인딩한다. slirp 의 10.0.2.2 는 호스트 루프백으로 들어오므로 127.0.0.1 만 바인딩한다. slirp 의 10.0.2.2 는 호스트 루프백으로 들어오므로
외부에 열 필요가 없다. 외부에 열 필요가 없다.
""" """
import http.server import http.server
import os import os
import posixpath
import socketserver import socketserver
import sys import sys
from datetime import datetime from datetime import datetime
ROOT = "/home/claude/win-ci/serve" ROOT = "/home/claude/win-ci/serve"
LOG = "/home/claude/win-ci/guest.log" LOG = "/home/claude/win-ci/guest.log"
RESULTS = "/home/claude/win-ci/results"
PORT = 8099 PORT = 8099
MAX_ARTIFACT = 32 * 1024 * 1024
class Handler(http.server.SimpleHTTPRequestHandler): class Handler(http.server.SimpleHTTPRequestHandler):
@@ -47,9 +56,15 @@ class Handler(http.server.SimpleHTTPRequestHandler):
super().do_GET() super().do_GET()
def do_POST(self): def do_POST(self):
if self.path.rstrip("/") != "/log": path = self.path.rstrip("/")
if path == "/log":
self._recv_log()
elif path.startswith("/artifact/"):
self._recv_artifact(path[len("/artifact/") :])
else:
self.send_error(404) self.send_error(404)
return
def _recv_log(self):
n = int(self.headers.get("Content-Length") or 0) n = int(self.headers.get("Content-Length") or 0)
data = self.rfile.read(n).decode("utf-8", "replace").strip() data = self.rfile.read(n).decode("utf-8", "replace").strip()
with open(LOG, "a", encoding="utf-8") as f: with open(LOG, "a", encoding="utf-8") as f:
@@ -57,9 +72,27 @@ class Handler(http.server.SimpleHTTPRequestHandler):
self.send_response(204) self.send_response(204)
self.end_headers() self.end_headers()
def _recv_artifact(self, name):
# 게스트가 주는 이름을 그대로 경로에 쓰면 안 된다. 파일명만 취한다.
name = posixpath.basename(name.replace("\\", "/"))
if not name or name.startswith("."):
self.send_error(400, "bad artifact name")
return
n = int(self.headers.get("Content-Length") or 0)
if n > MAX_ARTIFACT:
self.send_error(413, "artifact too large")
return
data = self.rfile.read(n)
os.makedirs(RESULTS, exist_ok=True)
with open(os.path.join(RESULTS, name), "wb") as f:
f.write(data)
print(f"artifact {name} {len(data)} bytes", flush=True)
self.send_response(204)
self.end_headers()
def log_message(self, fmt, *args): def log_message(self, fmt, *args):
# 접근 로그는 stdout 으로만. journald 가 받는다. # 접근 로그는 stdout 으로만. journald 가 받는다.
sys.stdout.write("%s %s\n" % (self.address_string(), fmt % args)) sys.stdout.write(f"{self.address_string()} {fmt % args}\n")
sys.stdout.flush() sys.stdout.flush()
@@ -70,6 +103,7 @@ class Server(socketserver.ThreadingTCPServer):
if __name__ == "__main__": if __name__ == "__main__":
os.makedirs(ROOT, exist_ok=True) os.makedirs(ROOT, exist_ok=True)
os.makedirs(RESULTS, exist_ok=True)
with Server(("127.0.0.1", PORT), Handler) as httpd: with Server(("127.0.0.1", PORT), Handler) as httpd:
print(f"win-ci control server on 127.0.0.1:{PORT}, root={ROOT}", flush=True) print(f"win-ci control server on 127.0.0.1:{PORT}, root={ROOT}", flush=True)
httpd.serve_forever() httpd.serve_forever()

View File

@@ -211,18 +211,59 @@ Log "=== stage2 완료 ==="
# agent 가 무한 재시도에 빠지지 않는다. 결과는 로그로 남는다. # agent 가 무한 재시도에 빠지지 않는다. 결과는 로그로 남는다.
# ================================================================== # ==================================================================
$Repo = 'C:\win-ci\live-app-translator' $Repo = 'C:\win-ci\live-app-translator'
function Step($name, $block) { $OutDir = 'C:\win-ci\out'
Log "--- [$name] 시작" New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
# 출력 원본을 호스트 파일로 회수한다.
#
# 왜 필요한가: 로그 채널로는 마지막 몇 줄만 흘렸고 serial.log 는 VM 이 뜰 때마다
# truncate 된다. 그래서 pytest 실패 트레이스백이 호스트에 한 줄도 안 남았고
# 화면에서 읽어 옮겨 적는 상태였다. 파일로 된 기준선이 없으면 고친 뒤에
# 고쳐졌다는 증명을 할 수 없다.
function Send-Artifact($name, $path) {
if (-not (Test-Path $path)) { return }
try { try {
& $block $bytes = [IO.File]::ReadAllBytes($path)
Log "--- [$name] 통과" # 백틱 줄바꿈을 쓰지 않는다. LF 로 저장된 스크립트에서 토크나이저가
return $true # 어긋나 스크립트 전체가 무효가 된 전례가 있다 (NOTES.md 참고).
$req = @{ Uri = "http://10.0.2.2:8099/artifact/$name"; Method = 'Post'; TimeoutSec = 120; Body = $bytes; ContentType = 'application/octet-stream' }
Invoke-RestMethod @req | Out-Null
Log " -> 호스트로 $name ($($bytes.Length) bytes)"
} catch { } catch {
Log "--- [$name] 실패: $($_.Exception.Message)" Log " -> $name 업로드 실패: $($_.Exception.Message)"
return $false
} }
} }
function Step($name, $block) {
$log = Join-Path $OutDir "$name.log"
Remove-Item $log -Force -ErrorAction SilentlyContinue
Log "--- [$name] 시작"
# StreamWriter + AutoFlush 로 한 줄씩 즉시 디스크에 쓴다. 중간에 예외로
# 죽어도 거기까지의 출력이 남아야 한다 (Tee-Object 는 그 보장이 없다).
$sw = [IO.StreamWriter]::new($log, $false, [Text.UTF8Encoding]::new($false))
$sw.AutoFlush = $true
$tail = [System.Collections.Generic.Queue[string]]::new()
$ok = $true
try {
& $block 2>&1 | ForEach-Object {
$line = "$_"
$sw.WriteLine($line)
$tail.Enqueue($line)
if ($tail.Count -gt 25) { [void]$tail.Dequeue() }
}
} catch {
$ok = $false
$sw.WriteLine("[step 예외] $($_.Exception.Message)")
}
$sw.Close()
foreach ($l in $tail) { Log " $l" }
if ($ok) { Log "--- [$name] 통과" } else { Log "--- [$name] 실패" }
Send-Artifact "$name.log" $log
return $ok
}
$ErrorActionPreference = 'Continue' $ErrorActionPreference = 'Continue'
try { try {
Sync-Path Sync-Path
@@ -251,16 +292,29 @@ try {
# 죽는다. 검증 스크립트가 한글을 찍으므로 UTF-8 로 고정한다. # 죽는다. 검증 스크립트가 한글을 찍으므로 UTF-8 로 고정한다.
$env:PYTHONIOENCODING = 'utf-8' $env:PYTHONIOENCODING = 'utf-8'
$env:PYTHONUTF8 = '1' $env:PYTHONUTF8 = '1'
$r = @{} # 출력을 자르지 않는다. 전문은 Step 이 호스트 results/<name>.log 로 올린다.
$r['pytest'] = Step 'pytest' { & $py -m pytest tests -q 2>&1 | Select-Object -Last 30 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } # 여기서 truncate 하면 실패 원인이 있는 트레이스백 본문이 날아간다.
$r['ruff'] = Step 'ruff' { & $py -m ruff check src tests scripts 2>&1 | Select-Object -Last 5 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } $r = [ordered]@{}
$r['windows경로'] = Step 'windows_smoke' { & $py packaging\windows_smoke.py 2>&1 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } $r['pytest'] = Step 'pytest' { & $py -m pytest tests -q -rf --tb=long; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
$r['exe빌드'] = Step 'pyinstaller' { & $py -m pip install pyinstaller --quiet; & $py -m PyInstaller packaging\livesub.spec --noconfirm 2>&1 | Select-Object -Last 4 | ForEach-Object { Log " $_" }; if (-not (Test-Path 'dist\LiveSub.exe')) { throw 'dist\LiveSub.exe 없음' }; Log " exe 크기 $([math]::Round((Get-Item 'dist\LiveSub.exe').Length/1MB,1)) MB" } $r['ruff'] = Step 'ruff' { & $py -m ruff check src tests scripts; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
$r['exe기동'] = Step 'exe 실행' { & $py packaging\windows_smoke.py --exe dist\LiveSub.exe 2>&1 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } $r['windows경로'] = Step 'windows_smoke' { & $py packaging\windows_smoke.py; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
$r['exe빌드'] = Step 'pyinstaller' { & $py -m pip install pyinstaller --quiet; & $py -m PyInstaller packaging\livesub.spec --noconfirm; if (-not (Test-Path 'dist\LiveSub.exe')) { throw 'dist\LiveSub.exe 없음' }; "exe 크기 $([math]::Round((Get-Item 'dist\LiveSub.exe').Length/1MB,1)) MB" }
$r['exe기동'] = Step 'exe_smoke' { & $py packaging\windows_smoke.py --exe dist\LiveSub.exe; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
Log "=== Windows 검증 결과 ===" # 판정 요약도 파일로 남긴다. 어떤 리비전을 검증한 결과인지 같이 박아야
foreach ($k in $r.Keys) { Log ("{0,-14} {1}" -f $k, $(if ($r[$k]) { 'PASS' } else { 'FAIL' })) } # 나중에 "언제 통과한 건지" 를 되짚을 수 있다.
Log "=== 검증 끝 (rev2: .gitignore 수정 반영) ===" $rev = (& git rev-parse --short HEAD 2>&1 | Select-Object -First 1)
$sum = Join-Path $OutDir 'summary.log'
$lines = @("rev=$rev", "utc=$([DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss'))")
Log "=== Windows 검증 결과 (rev $rev) ==="
foreach ($k in $r.Keys) {
$line = "{0,-14} {1}" -f $k, $(if ($r[$k]) { 'PASS' } else { 'FAIL' })
Log $line
$lines += $line
}
Set-Content -Path $sum -Value $lines -Encoding UTF8
Send-Artifact 'summary.log' $sum
Log "=== 검증 끝 ==="
Pop-Location Pop-Location
} catch { } catch {
Log "검증 준비 단계에서 실패: $($_.Exception.Message)" Log "검증 준비 단계에서 실패: $($_.Exception.Message)"

25
scripts/win-ci/sync.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
# 저장소의 win-ci 스크립트를 /home/claude/win-ci 로 반영한다.
#
# build.sh 를 다시 돌리면 안 된다. build.sh 의 fetch 는 지워둔 Windows ISO(4.8GB)를
# 다시 내려받는다. 설치가 끝난 VM 에 스크립트만 갱신할 때는 이걸 쓴다.
#
# stage2.ps1 은 게스트 agent 가 20초마다 해시를 비교해 받아가므로, 복사만 하면
# VM 재시작 없이 다음 폴링에 새 버전이 실행된다.
# serve-host.py 는 호스트 프로세스라 유닛을 재시작해야 반영된다.
set -euo pipefail
D="${WIN_CI_DIR:-/home/claude/win-ci}"
HERE="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$D/serve" "$D/results"
cp "$HERE/serve/stage2.ps1" "$D/serve/"
cp "$HERE"/*.sh "$HERE/serve-host.py" "$D/"
chmod +x "$D"/*.sh
if systemctl --user is-active --quiet win-ci-http.service; then
systemctl --user restart win-ci-http.service
echo "win-ci-http 재시작 (serve-host.py 반영)"
fi
echo "stage2.ps1 sha256 = $(sha256sum "$D/serve/stage2.ps1" | cut -c1-12) (게스트가 20초 안에 받아간다)"