fix: 무인 프로비저닝이 통째로 안 돌던 인코딩/파이프라인 결함 3건

실제 설치에서 드러난 것들이다. 셋 다 증상이 원인과 전혀 안 닮아서 기록해 둔다.

1) provision.ps1 이 1행부터 파싱 실패 - 'Missing closing }'
   Windows PowerShell 5.1 은 BOM 없는 .ps1 을 ANSI(CP1252)로 읽는다.
   한글 주석의 UTF-8 바이트가 CP1252 에서 스마트따옴표(0x93/0x94 = " ")로
   변하고, 파서가 그걸 문자열 시작으로 보고 뒤를 전부 삼킨다.
   -> build.sh 가 ISO 에 넣을 때 UTF-8 BOM 을 붙인다.

2) stage2.ps1 의 1행이 주석으로 인식되지 않음
   agent 가 받은 본문에 BOM 이 있으면 Set-Content -Encoding UTF8 이 BOM 을
   한 번 더 붙여 BOM 이 두 개가 된다. PowerShell 은 첫 개만 벗겨내고 남은
   U+FEFF 때문에 '#' 이 줄 맨 앞이 아니게 된다.
   -> agent 가 TrimStart(U+FEFF), 서버는 charset=utf-8 을 명시한다.

3) Git 설치가 'Cannot convert System.Object[] to System.String' 로 실패
   Log 가 Write-Output 이라 함수 안에서 부르면 그 함수의 반환값에 로그가
   섞인다. Get-File 이 배열을 돌려주니 Start-Process 가 죽었다.
   -> Log 를 Write-Host 로 바꿨다.

덤으로 시리얼/HTTP 로그의 한글이 '?' 로 깨지던 것도 고쳤다 (SerialPort 기본
인코딩이 ASCII). 그리고 러너 토큰이 없어도 워크플로와 같은 단계를 VM 안에서
직접 돌리는 검증 절차를 stage2 에 붙였다.
This commit is contained in:
EJClaw
2026-09-25 22:50:08 +09:00
parent e41d0cb441
commit ae295dc308
6 changed files with 172 additions and 5 deletions

View File

@@ -37,7 +37,18 @@ fetch "$VIRTIO_URL" "$D/iso/virtio-win.iso" 500
# 무인 설치 미디어. Windows Setup 이 붙어 있는 모든 드라이브 루트에서 # 무인 설치 미디어. Windows Setup 이 붙어 있는 모든 드라이브 루트에서
# autounattend.xml 을 찾으므로 CD 로 물려주면 된다. # autounattend.xml 을 찾으므로 CD 로 물려주면 된다.
cp "$HERE/unattend/autounattend.xml" "$D/unattend-root/" cp "$HERE/unattend/autounattend.xml" "$D/unattend-root/"
cp "$HERE/unattend/setup/"*.ps1 "$D/unattend-root/setup/"
# ⚠️ .ps1 은 반드시 UTF-8 BOM 으로 넣는다.
# Windows PowerShell 5.1 은 BOM 이 없으면 현재 ANSI 코드페이지(en-US -> CP1252)로
# 읽는다. 한글 주석의 UTF-8 바이트가 CP1252 에서 스마트따옴표(“ ” ‘)로 변하고,
# PowerShell 은 그걸 문자열 시작으로 보고 스크립트 뒤를 전부 삼킨다. 증상은
# 엉뚱하게도 "Missing closing '}' in statement block" 이다. 실제로 이것 때문에
# 첫 설치의 provision.ps1 이 통째로 안 돌았다.
for f in "$HERE/unattend/setup/"*.ps1; do
out="$D/unattend-root/setup/$(basename "$f")"
printf '\xef\xbb\xbf' > "$out"
cat "$f" >> "$out"
done
xorriso -as mkisofs -V UNATTEND -J -joliet-long -R -o "$D/unattend.iso" "$D/unattend-root/" xorriso -as mkisofs -V UNATTEND -J -joliet-long -R -o "$D/unattend.iso" "$D/unattend-root/"
# 호스트 제어 채널이 내려줄 스크립트 # 호스트 제어 채널이 내려줄 스크립트

View File

@@ -27,6 +27,14 @@ class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *a, **kw): def __init__(self, *a, **kw):
super().__init__(*a, directory=ROOT, **kw) super().__init__(*a, directory=ROOT, **kw)
def guess_type(self, path):
# .ps1 은 charset 을 반드시 명시한다. 없으면 PowerShell 쪽에서
# ISO-8859-1 로 디코딩해 한글 주석이 깨지고, 깨진 바이트가 CP1252 의
# 스마트따옴표가 되어 파서가 스크립트를 통째로 삼킨다.
if str(path).endswith(".ps1"):
return "text/plain; charset=utf-8"
return super().guess_type(path)
def do_GET(self): def do_GET(self):
if self.path.rstrip("/") == "/ping": if self.path.rstrip("/") == "/ping":
body = b"pong\n" body = b"pong\n"

View File

@@ -13,7 +13,11 @@ $ErrorActionPreference = 'Stop'
$Dl = 'C:\win-ci\dl' $Dl = 'C:\win-ci\dl'
New-Item -ItemType Directory -Force -Path $Dl | Out-Null New-Item -ItemType Directory -Force -Path $Dl | Out-Null
function Log($m) { Write-Output "$m" } # Write-Output 를 쓰면 안 된다. 함수 안에서 부르면 그 함수의 반환값에
# 로그 문자열이 섞여 들어가 $f 가 배열이 되고, Start-Process 가
# "Cannot convert System.Object[] to System.String" 으로 죽는다.
# 실제로 이것 때문에 Git 설치가 실패했다.
function Log($m) { Write-Host "$m" }
function Get-File($url, $name) { function Get-File($url, $name) {
$out = Join-Path $Dl $name $out = Join-Path $Dl $name
@@ -195,3 +199,66 @@ try {
Log "화면 열거 실패: $($_.Exception.Message)" Log "화면 열거 실패: $($_.Exception.Message)"
} }
Log "=== stage2 완료 ===" Log "=== stage2 완료 ==="
# ==================================================================
# Windows 전용 경로 실제 검증
#
# 러너 등록 토큰이 없어도 여기서 워크플로와 똑같은 단계를 직접 돌린다.
# 이게 VM 을 만든 진짜 이유다 - 리눅스에서 못 돌리던 경로(WASAPI, 전역
# 단축키, 모니터 열거, PyInstaller exe)가 실제로 통과하는지 본다.
#
# 통째로 try/catch 로 감싼다. 검증이 실패해도 stage2 자체는 성공이어야
# agent 가 무한 재시도에 빠지지 않는다. 결과는 로그로 남는다.
# ==================================================================
$Repo = 'C:\win-ci\live-app-translator'
function Step($name, $block) {
Log "--- [$name] 시작"
try {
& $block
Log "--- [$name] 통과"
return $true
} catch {
Log "--- [$name] 실패: $($_.Exception.Message)"
return $false
}
}
$ErrorActionPreference = 'Continue'
try {
Sync-Path
if (Test-Path $Repo) {
Log "저장소 갱신"
Push-Location $Repo; & git fetch --all -q; & git reset --hard origin/main -q; Pop-Location
} else {
Log "저장소 clone"
& git clone -q https://git.tkrmagid.kr/tkrmagid/live-app-translator $Repo
}
Push-Location $Repo
$py = Join-Path $Repo '.venv-ci\Scripts\python.exe'
if (-not (Test-Path $py)) { & python -m venv .venv-ci }
& $py -m pip install --upgrade pip --quiet
& $py -m pip install -r packaging\requirements-portable.txt --quiet
& $py -m pip install pytest ruff --quiet
# 워크플로와 같은 조건인지 - torch 가 없어야 "포터블에서 1~3티어가 된다"를
# 실제로 검증한 셈이 된다.
& $py -c "import importlib.util,sys; sys.exit(0 if importlib.util.find_spec('torch') is None else 1)"
Log "포터블 조건(torch 없음) = $($LASTEXITCODE -eq 0)"
$env:PYTHONPATH = 'src'
$r = @{}
$r['pytest'] = Step 'pytest' { & $py -m pytest tests -q 2>&1 | Select-Object -Last 8 | 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" } }
Log "=== Windows 검증 결과 ==="
foreach ($k in $r.Keys) { Log ("{0,-14} {1}" -f $k, $(if ($r[$k]) { 'PASS' } else { 'FAIL' })) }
Log "=== 검증 끝 ==="
Pop-Location
} catch {
Log "검증 준비 단계에서 실패: $($_.Exception.Message)"
try { Pop-Location } catch {}
}

67
scripts/win-ci/type.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/bin/bash
# VM 게스트에 키보드 입력을 보낸다 (QMP send-key).
#
# ./type.sh text "hello world" 문자열 입력
# ./type.sh key ret 단일 키 (qcode)
# ./type.sh key alt-f4 조합키 (하이픈으로 연결)
#
# 게스트에 네트워크가 없을 때 유일한 제어 수단이므로 지우지 말 것.
set -euo pipefail
python3 - "$@" <<'PY'
import json, socket, sys, time
MODE = sys.argv[1]
ARG = sys.argv[2]
s = socket.socket(socket.AF_UNIX); s.settimeout(30)
s.connect("/home/claude/win-ci/qmp.sock"); f = s.makefile("rwb"); f.readline()
def cmd(o):
f.write((json.dumps(o) + "\n").encode()); f.flush()
while True:
m = json.loads(f.readline())
if "return" in m or "error" in m:
if "error" in m: print("ERR", m, file=sys.stderr)
return m
cmd({"execute": "qmp_capabilities"})
def send(qcodes):
cmd({"execute": "send-key", "arguments": {
"keys": [{"type": "qcode", "data": q} for q in qcodes]}})
time.sleep(0.04)
SHIFTED = {
'!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', '&': '7',
'*': '8', '(': '9', ')': '0', '_': 'minus', '+': 'equal', '{': 'bracket_left',
'}': 'bracket_right', '|': 'backslash', ':': 'semicolon', '"': 'apostrophe',
'<': 'comma', '>': 'dot', '?': 'slash', '~': 'grave_accent',
}
PLAIN = {
' ': 'spc', '-': 'minus', '=': 'equal', '[': 'bracket_left',
']': 'bracket_right', '\\': 'backslash', ';': 'semicolon',
"'": 'apostrophe', ',': 'comma', '.': 'dot', '/': 'slash',
'`': 'grave_accent', '\n': 'ret', '\t': 'tab',
}
def char_keys(c):
if c.isalpha():
q = c.lower()
return ['shift', q] if c.isupper() else [q]
if c.isdigit():
return [c]
if c in SHIFTED:
return ['shift', SHIFTED[c]]
if c in PLAIN:
return [PLAIN[c]]
raise SystemExit("unsupported char: %r" % c)
if MODE == "text":
for c in ARG:
send(char_keys(c))
elif MODE == "key":
send(ARG.split("-"))
else:
raise SystemExit("usage: type.sh text <str> | type.sh key <qcode>")
print("ok")
PY

View File

@@ -24,10 +24,13 @@ function Say([string]$m) {
try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 } catch {} try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 } catch {}
try { try {
$p = New-Object System.IO.Ports.SerialPort 'COM1', 115200, 'None', 8, 'One' $p = New-Object System.IO.Ports.SerialPort 'COM1', 115200, 'None', 8, 'One'
# 기본 인코딩이 ASCII 라 한글이 전부 '?' 가 된다
$p.Encoding = [Text.Encoding]::UTF8
$p.Open(); $p.WriteLine($line); $p.Close() $p.Open(); $p.WriteLine($line); $p.Close()
} catch {} } catch {}
try { try {
Invoke-RestMethod -Uri "$Host_/log" -Method Post -Body $line -TimeoutSec 5 | Out-Null Invoke-RestMethod -Uri "$Host_/log" -Method Post -TimeoutSec 5 `
-Body ([Text.Encoding]::UTF8.GetBytes($line)) -ContentType "text/plain; charset=utf-8" | Out-Null
} catch {} } catch {}
} }
@@ -51,8 +54,17 @@ else { Say "ERROR 5분 동안 호스트에 못 붙었다. virtio-net 드
# 이후로는 계속 돌면서 stage2 가 바뀔 때마다 실행한다. # 이후로는 계속 돌면서 stage2 가 바뀔 때마다 실행한다.
while ($true) { while ($true) {
try { try {
$body = (Invoke-WebRequest -Uri "$Host_/stage2.ps1" -TimeoutSec 20 -UseBasicParsing).Content # 반드시 바이트로 받아 UTF-8 로 직접 디코딩한다. Invoke-WebRequest 의
if ($body -is [byte[]]) { $body = [Text.Encoding]::UTF8.GetString($body) } # .Content 는 Content-Type 에 charset 이 없으면 ISO-8859-1 로 디코딩해
# 한글 주석이 깨지고, 깨진 바이트가 CP1252 의 스마트따옴표(0x93/0x94)가
# 되어 PowerShell 파서가 그 뒤를 전부 문자열로 삼킨다. 실제로 겪은 사고다.
$wc = New-Object Net.WebClient
$body = [Text.Encoding]::UTF8.GetString($wc.DownloadData("$Host_/stage2.ps1"))
# 받은 쪽에 BOM 이 있으면 문자열 맨 앞에 U+FEFF 가 남는다. 아래에서
# Set-Content -Encoding UTF8 이 BOM 을 또 붙이므로 BOM 이 두 개가 되고,
# PowerShell 은 첫 개만 벗겨내 남은 U+FEFF 때문에 1행이 주석으로
# 인식되지 않는다. 그래서 여기서 반드시 떼어낸다.
$body = $body.TrimStart([char]0xFEFF)
$sha = [BitConverter]::ToString( $sha = [BitConverter]::ToString(
[Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($body)) [Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($body))

View File

@@ -16,6 +16,8 @@ New-Item -ItemType Directory -Force -Path 'C:\win-ci' | Out-Null
$script:Port = $null $script:Port = $null
try { try {
$script:Port = New-Object System.IO.Ports.SerialPort 'COM1', 115200, 'None', 8, 'One' $script:Port = New-Object System.IO.Ports.SerialPort 'COM1', 115200, 'None', 8, 'One'
# 기본 인코딩이 ASCII 라 한글이 전부 '?' 가 된다
$script:Port.Encoding = [Text.Encoding]::UTF8
$script:Port.Open() $script:Port.Open()
} catch { $script:Port = $null } } catch { $script:Port = $null }