From a66eedee8b4ac8bdc22ba05e41c26c4b34aaeb07 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Fri, 25 Sep 2026 23:38:48 +0900 Subject: [PATCH] =?UTF-8?q?ci:=20win-ci=20=EA=B2=80=EC=A6=9D=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=EC=9D=84=20=ED=98=B8=EC=8A=A4=ED=8A=B8=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=A1=9C=20=ED=9A=8C=EC=88=98=ED=95=9C=EB=8B=A4=20(?= =?UTF-8?q?=EC=A6=9D=EA=B1=B0=20=EA=B8=B0=EC=A4=80=EC=84=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VM 을 만든 이유가 "리눅스에서 통과한 게 Windows 에서 깨지는 것을 재현 가능하게 본다" 인데, 정작 실패 트레이스백이 호스트에 한 줄도 안 남았다. 로그 채널은 마지막 몇 줄만 흘렸고(Select-Object -Last 30), serial.log 는 VM 이 뜰 때마다 truncate 되고, guest.log 는 stage2 가 다시 돌 때만 채워진다. 그래서 실패 내용을 화면에서 읽어 옮겨 적는 상태였고 — 고친 뒤에 고쳐졌다는 것을 증명할 기준선이 없었다. - serve-host.py: POST /artifact/ 추가. results/ 에 파일로 저장 (경로는 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)로 실제 원인 두 개를 특정했다. --- scripts/win-ci/serve-host.py | 48 +++++++++++++++--- scripts/win-ci/serve/stage2.ps1 | 86 +++++++++++++++++++++++++++------ scripts/win-ci/sync.sh | 25 ++++++++++ 3 files changed, 136 insertions(+), 23 deletions(-) create mode 100755 scripts/win-ci/sync.sh diff --git a/scripts/win-ci/serve-host.py b/scripts/win-ci/serve-host.py index 2975cec..5717ff0 100755 --- a/scripts/win-ci/serve-host.py +++ b/scripts/win-ci/serve-host.py @@ -4,23 +4,32 @@ 게스트(Windows)는 QEMU user networking 안에 있어서 호스트를 10.0.2.2 로 본다. 이 서버는 그 반대편이다. - GET /ping 게스트가 네트워크 올라왔는지 확인용 - GET /stage20.ps1 게스트가 실행할 설치 스크립트 (serve/ 안의 파일 그대로) - GET / serve/ 안의 아무 파일 (러너 등록 토큰 등) - POST /log 게스트 로그 -> guest.log 에 append + GET /ping 게스트가 네트워크 올라왔는지 확인용 + GET /stage2.ps1 게스트가 실행할 설치 스크립트 (serve/ 안의 파일 그대로) + GET / serve/ 안의 아무 파일 (러너 등록 토큰 등) + POST /log 게스트 로그 -> guest.log 에 append + POST /artifact/ 게스트 명령 출력 원본 -> results/ 에 파일로 저장 + +/artifact 가 필요한 이유: 게스트 검증 결과를 사람이 화면에서 읽고 옮겨 적으면 +"고쳤다"를 증명할 기준선이 안 남는다. serial.log 는 VM 이 뜰 때마다 truncate 되고 +로그 채널은 마지막 몇 줄만 흘린다. pytest 트레이스백처럼 전문이 필요한 출력은 +반드시 이 엔드포인트로 호스트 파일에 떨어뜨린다. 127.0.0.1 만 바인딩한다. slirp 의 10.0.2.2 는 호스트 루프백으로 들어오므로 외부에 열 필요가 없다. """ import http.server import os +import posixpath import socketserver import sys from datetime import datetime ROOT = "/home/claude/win-ci/serve" LOG = "/home/claude/win-ci/guest.log" +RESULTS = "/home/claude/win-ci/results" PORT = 8099 +MAX_ARTIFACT = 32 * 1024 * 1024 class Handler(http.server.SimpleHTTPRequestHandler): @@ -47,9 +56,15 @@ class Handler(http.server.SimpleHTTPRequestHandler): super().do_GET() 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) - return + + def _recv_log(self): n = int(self.headers.get("Content-Length") or 0) data = self.rfile.read(n).decode("utf-8", "replace").strip() with open(LOG, "a", encoding="utf-8") as f: @@ -57,9 +72,27 @@ class Handler(http.server.SimpleHTTPRequestHandler): self.send_response(204) 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): # 접근 로그는 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() @@ -70,6 +103,7 @@ class Server(socketserver.ThreadingTCPServer): if __name__ == "__main__": os.makedirs(ROOT, exist_ok=True) + os.makedirs(RESULTS, exist_ok=True) 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) httpd.serve_forever() diff --git a/scripts/win-ci/serve/stage2.ps1 b/scripts/win-ci/serve/stage2.ps1 index aeb6f6d..d707222 100644 --- a/scripts/win-ci/serve/stage2.ps1 +++ b/scripts/win-ci/serve/stage2.ps1 @@ -211,18 +211,59 @@ Log "=== stage2 완료 ===" # agent 가 무한 재시도에 빠지지 않는다. 결과는 로그로 남는다. # ================================================================== $Repo = 'C:\win-ci\live-app-translator' -function Step($name, $block) { - Log "--- [$name] 시작" +$OutDir = 'C:\win-ci\out' +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 { - & $block - Log "--- [$name] 통과" - return $true + $bytes = [IO.File]::ReadAllBytes($path) + # 백틱 줄바꿈을 쓰지 않는다. LF 로 저장된 스크립트에서 토크나이저가 + # 어긋나 스크립트 전체가 무효가 된 전례가 있다 (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 { - Log "--- [$name] 실패: $($_.Exception.Message)" - return $false + Log " -> $name 업로드 실패: $($_.Exception.Message)" } } +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' try { Sync-Path @@ -251,16 +292,29 @@ try { # 죽는다. 검증 스크립트가 한글을 찍으므로 UTF-8 로 고정한다. $env:PYTHONIOENCODING = 'utf-8' $env:PYTHONUTF8 = '1' - $r = @{} - $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" } } - $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['windows경로'] = Step 'windows_smoke' { & $py packaging\windows_smoke.py 2>&1 | ForEach-Object { Log " $_" }; 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['exe기동'] = Step 'exe 실행' { & $py packaging\windows_smoke.py --exe dist\LiveSub.exe 2>&1 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } + # 출력을 자르지 않는다. 전문은 Step 이 호스트 results/.log 로 올린다. + # 여기서 truncate 하면 실패 원인이 있는 트레이스백 본문이 날아간다. + $r = [ordered]@{} + $r['pytest'] = Step 'pytest' { & $py -m pytest tests -q -rf --tb=long; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } + $r['ruff'] = Step 'ruff' { & $py -m ruff check src tests scripts; 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 } catch { Log "검증 준비 단계에서 실패: $($_.Exception.Message)" diff --git a/scripts/win-ci/sync.sh b/scripts/win-ci/sync.sh new file mode 100755 index 0000000..e6f6ba7 --- /dev/null +++ b/scripts/win-ci/sync.sh @@ -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초 안에 받아간다)"