Files
EJClaw ae295dc308 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 에 붙였다.
2026-09-25 22:50:08 +09:00

97 lines
4.6 KiB
PowerShell

# Windows CI VM 상주 에이전트
#
# 하는 일은 하나다: 호스트가 주는 stage2.ps1 을 받아서 돌린다.
#
# 왜 이렇게 하나 - ISO 는 한 번 구우면 못 고친다. 설치 스크립트를 ISO 에
# 박아두면 한 줄 고칠 때마다 Windows 를 다시 설치해야 한다. 그래서 ISO 에는
# 이 껍데기만 넣고, 실제 설치 내용은 호스트의 HTTP 로 가져온다.
# QEMU user networking 에서 호스트는 항상 10.0.2.2 다.
#
# 로그는 COM1(호스트 serial.log) + 호스트 HTTP(POST /log) + 로컬 파일.
$ErrorActionPreference = 'Continue'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$Host_ = 'http://10.0.2.2:8099'
$WorkDir = 'C:\win-ci'
$Stage2 = Join-Path $WorkDir 'stage2.ps1'
$HashFile = Join-Path $WorkDir 'stage2.hash'
$LogFile = Join-Path $WorkDir 'agent.log'
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
function Say([string]$m) {
$line = "[agent $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $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 -TimeoutSec 5 `
-Body ([Text.Encoding]::UTF8.GetBytes($line)) -ContentType "text/plain; charset=utf-8" | Out-Null
} catch {}
}
# 같은 에이전트가 두 개 돌면 설치가 겹친다. 뮤텍스로 하나만 남긴다.
$mutex = New-Object System.Threading.Mutex($false, 'Global\win-ci-agent')
if (-not $mutex.WaitOne(0)) { Say "이미 다른 agent 가 돌고 있다. 종료."; exit 0 }
Say "agent 시작. user=$env:USERNAME"
# 네트워크가 올라오길 기다린다. virtio-net 드라이버 설치 직후라 몇 초 걸린다.
$netOk = $false
for ($i = 0; $i -lt 60; $i++) {
try {
Invoke-WebRequest -Uri "$Host_/ping" -TimeoutSec 4 -UseBasicParsing | Out-Null
$netOk = $true; break
} catch { Start-Sleep -Seconds 5 }
}
if ($netOk) { Say "호스트 연결 OK ($Host_)" }
else { Say "ERROR 5분 동안 호스트에 못 붙었다. virtio-net 드라이버를 확인해야 한다." }
# 이후로는 계속 돌면서 stage2 가 바뀔 때마다 실행한다.
while ($true) {
try {
# 반드시 바이트로 받아 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))
).Replace('-', '')
$prev = if (Test-Path $HashFile) { (Get-Content $HashFile -Raw).Trim() } else { '' }
if ($sha -ne $prev) {
Say "stage2 새 버전 감지 ($($sha.Substring(0,12))) - 실행"
Set-Content -Path $Stage2 -Value $body -Encoding UTF8
try {
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $Stage2 2>&1 |
ForEach-Object { Say "stage2| $_" }
if ($LASTEXITCODE -eq 0 -or $null -eq $LASTEXITCODE) {
# 성공했을 때만 해시를 저장한다. 중간에 죽었으면 다음 턴에 다시 돈다.
Set-Content -Path $HashFile -Value $sha
Say "stage2 성공"
} else {
Say "stage2 실패 exit=$LASTEXITCODE - 다음 폴링에 재시도"
}
} catch {
Say "stage2 예외: $($_.Exception.Message) - 다음 폴링에 재시도"
}
}
} catch {
Say "폴링 실패: $($_.Exception.Message)"
}
Start-Sleep -Seconds 20
}