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:
@@ -37,7 +37,18 @@ fetch "$VIRTIO_URL" "$D/iso/virtio-win.iso" 500
|
||||
# 무인 설치 미디어. Windows Setup 이 붙어 있는 모든 드라이브 루트에서
|
||||
# autounattend.xml 을 찾으므로 CD 로 물려주면 된다.
|
||||
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/"
|
||||
|
||||
# 호스트 제어 채널이 내려줄 스크립트
|
||||
|
||||
@@ -27,6 +27,14 @@ class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *a, **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):
|
||||
if self.path.rstrip("/") == "/ping":
|
||||
body = b"pong\n"
|
||||
|
||||
@@ -13,7 +13,11 @@ $ErrorActionPreference = 'Stop'
|
||||
$Dl = 'C:\win-ci\dl'
|
||||
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) {
|
||||
$out = Join-Path $Dl $name
|
||||
@@ -195,3 +199,66 @@ try {
|
||||
Log "화면 열거 실패: $($_.Exception.Message)"
|
||||
}
|
||||
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
67
scripts/win-ci/type.sh
Executable 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
|
||||
@@ -24,10 +24,13 @@ function Say([string]$m) {
|
||||
try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 } catch {}
|
||||
try {
|
||||
$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()
|
||||
} catch {}
|
||||
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 {}
|
||||
}
|
||||
|
||||
@@ -51,8 +54,17 @@ else { Say "ERROR 5분 동안 호스트에 못 붙었다. virtio-net 드
|
||||
# 이후로는 계속 돌면서 stage2 가 바뀔 때마다 실행한다.
|
||||
while ($true) {
|
||||
try {
|
||||
$body = (Invoke-WebRequest -Uri "$Host_/stage2.ps1" -TimeoutSec 20 -UseBasicParsing).Content
|
||||
if ($body -is [byte[]]) { $body = [Text.Encoding]::UTF8.GetString($body) }
|
||||
# 반드시 바이트로 받아 UTF-8 로 직접 디코딩한다. Invoke-WebRequest 의
|
||||
# .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(
|
||||
[Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($body))
|
||||
|
||||
@@ -16,6 +16,8 @@ New-Item -ItemType Directory -Force -Path 'C:\win-ci' | Out-Null
|
||||
$script:Port = $null
|
||||
try {
|
||||
$script:Port = New-Object System.IO.Ports.SerialPort 'COM1', 115200, 'None', 8, 'One'
|
||||
# 기본 인코딩이 ASCII 라 한글이 전부 '?' 가 된다
|
||||
$script:Port.Encoding = [Text.Encoding]::UTF8
|
||||
$script:Port.Open()
|
||||
} catch { $script:Port = $null }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user