ci: .9 안 Windows CI VM 무인 구축 자동화

Windows 설치 화면을 사람이 클릭할 수 없으므로 전 과정을 무인으로 짰다.
autounattend.xml 이 설치와 OOBE 를 끝내고, provision.ps1 이 virtio 드라이버를
오프라인으로 넣어 네트워크를 살리고, 상주 agent.ps1 이 호스트 HTTP 에서
stage2.ps1 을 받아 Git/pwsh7/Python/Node/CMake/act_runner 를 설치한다.

설치 내용을 ISO 에 박지 않고 호스트가 HTTP 로 내려주는 구조로 한 이유는
ISO 를 한 번 구우면 못 고쳐서, 한 줄 고칠 때마다 Windows 재설치가 되기
때문이다. stage2.ps1 만 고치면 게스트가 20초 안에 반영한다.

- 시스템 디스크는 SATA. virtio-blk 로 하면 Setup 이 디스크를 못 봐서
  WinPE 드라이버 주입이 필요해진다
- VM 은 systemd --user 트랜지언트 유닛으로 띄워 봇 cgroup 밖에 둔다
  (MemoryHigh=16G 안에 8G VM 이 들어가면 봇이 OOM 으로 죽는 전례)
- Node 포함. act_runner host 모드가 JS 액션을 PATH 의 node 로 돌린다
This commit is contained in:
EJClaw
2026-09-25 22:13:55 +09:00
parent d0028abdac
commit e41d0cb441
13 changed files with 971 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
# win-ci VM 설치 스크립트 (호스트가 HTTP 로 내려준다 - agent.ps1 이 실행)
#
# 이 파일은 호스트의 /home/claude/win-ci/serve/stage2.ps1 이다. 고치면 게스트가
# 20초 안에 알아서 새로 받아 실행한다. Windows 재설치 필요 없다.
#
# 규칙: 반드시 idempotent. 이미 깔린 건 건너뛴다. 중간에 죽어도 다시 돌 수 있다.
#
# LTSC 2024 에는 winget(스토어)이 없다. 그래서 설치 파일을 직접 받는다.
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$Dl = 'C:\win-ci\dl'
New-Item -ItemType Directory -Force -Path $Dl | Out-Null
function Log($m) { Write-Output "$m" }
function Get-File($url, $name) {
$out = Join-Path $Dl $name
if ((Test-Path $out) -and (Get-Item $out).Length -gt 1mb) { Log "캐시 사용 $name"; return $out }
for ($i = 1; $i -le 3; $i++) {
try {
Log "다운로드 ($i/3) $name"
Invoke-WebRequest -Uri $url -OutFile $out -UseBasicParsing -TimeoutSec 600
return $out
} catch {
Log "실패: $($_.Exception.Message)"
if ($i -eq 3) { throw }
Start-Sleep -Seconds 10
}
}
}
function Sync-Path {
$m = [Environment]::GetEnvironmentVariable('Path', 'Machine')
$u = [Environment]::GetEnvironmentVariable('Path', 'User')
$env:Path = "$m;$u"
}
function Have($exe) { Sync-Path; return [bool](Get-Command $exe -ErrorAction SilentlyContinue) }
# ------------------------------------------------------------------ Git
# actions/checkout@v4 가 git 을 쓴다. 없으면 소스를 못 받는다.
if (Have 'git') {
Log "git 이미 있음: $(git --version)"
} else {
$f = Get-File 'https://github.com/git-for-windows/git/releases/download/v2.55.0.windows.5/Git-2.55.0.5-64-bit.exe' 'git.exe'
Log "Git 설치 중"
Start-Process $f -ArgumentList '/VERYSILENT', '/NORESTART', '/NOCANCEL', '/SP-' -Wait
if (-not (Have 'git')) { throw 'git 설치 실패' }
Log "git 설치 완료: $(git --version)"
}
# ------------------------------------------------------------------ pwsh 7
# 워크플로가 shell: pwsh 를 쓴다. Windows 기본은 5.1 이라 별도로 필요하다.
if (Have 'pwsh') {
Log "pwsh 이미 있음: $(pwsh --version)"
} else {
$f = Get-File 'https://github.com/PowerShell/PowerShell/releases/download/v7.4.6/PowerShell-7.4.6-win-x64.msi' 'pwsh.msi'
Log "PowerShell 7 설치 중"
Start-Process 'msiexec.exe' -ArgumentList '/i', "`"$f`"", '/qn', '/norestart', 'ADD_PATH=1' -Wait
if (-not (Have 'pwsh')) { throw 'pwsh 설치 실패' }
Log "pwsh 설치 완료: $(pwsh --version)"
}
# ------------------------------------------------------------------ Python 3.12
if (Have 'python') {
Log "python 이미 있음: $(python --version 2>&1)"
} else {
$f = Get-File 'https://www.python.org/ftp/python/3.12.10/python-3.12.10-amd64.exe' 'python.exe'
Log "Python 3.12 설치 중"
Start-Process $f -ArgumentList '/quiet', 'InstallAllUsers=1', 'PrependPath=1', `
'Include_test=0', 'Include_launcher=1', 'AssociateFiles=0' -Wait
if (-not (Have 'python')) { throw 'python 설치 실패' }
Log "python 설치 완료: $(python --version 2>&1)"
}
# ------------------------------------------------------------------ Node
# act_runner 의 host 모드는 JS 액션(checkout, upload-artifact)을 돌리려고
# PATH 의 node 를 쓴다. 없으면 그 단계들이 전부 실패한다.
if (Have 'node') {
Log "node 이미 있음: $(node --version)"
} else {
$f = Get-File 'https://nodejs.org/dist/v22.20.0/node-v22.20.0-x64.msi' 'node.msi'
Log "Node.js 설치 중"
Start-Process 'msiexec.exe' -ArgumentList '/i', "`"$f`"", '/qn', '/norestart' -Wait
if (-not (Have 'node')) { throw 'node 설치 실패' }
Log "node 설치 완료: $(node --version)"
}
# ------------------------------------------------------------------ CMake
# native\process_loopback 빌드용. 워크플로에서 continue-on-error 지만 있으면 검증된다.
if (Have 'cmake') {
Log "cmake 이미 있음: $(cmake --version | Select-Object -First 1)"
} else {
$f = Get-File 'https://github.com/Kitware/CMake/releases/download/v3.31.6/cmake-3.31.6-windows-x86_64.msi' 'cmake.msi'
Log "CMake 설치 중"
Start-Process 'msiexec.exe' -ArgumentList '/i', "`"$f`"", '/qn', '/norestart', 'ADD_CMAKE_TO_PATH=System' -Wait
Sync-Path
if (Have 'cmake') { Log "cmake 설치 완료: $(cmake --version | Select-Object -First 1)" }
else { Log "WARN cmake 설치 실패 - 워크플로의 native 빌드 단계는 건너뛰어진다" }
}
# ------------------------------------------------------------------ act_runner
$RunnerDir = 'C:\gitea-runner'
$RunnerExe = Join-Path $RunnerDir 'act_runner.exe'
New-Item -ItemType Directory -Force -Path $RunnerDir | Out-Null
if (Test-Path $RunnerExe) {
Log "act_runner 이미 있음"
} else {
Log "act_runner 다운로드"
Invoke-WebRequest -Uri 'https://dl.gitea.com/act_runner/0.2.13/act_runner-0.2.13-windows-amd64.exe' `
-OutFile $RunnerExe -UseBasicParsing -TimeoutSec 300
Log "act_runner 받음: $((Get-Item $RunnerExe).Length) bytes"
}
# GPU 러너와 달리 이건 CPU 러너지만, 같은 원칙으로 capacity 1 로 고정한다.
# 이 VM 은 vCPU 4개뿐이라 두 작업이 동시에 돌면 둘 다 느려지고 타임아웃 위험이 생긴다.
$cfg = Join-Path $RunnerDir 'config.yaml'
if (-not (Test-Path $cfg)) {
@'
log:
level: info
runner:
capacity: 1
timeout: 60m
shutdown_timeout: 3m
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
container:
network: ""
privileged: false
force_pull: false
host:
workdir_parent: C:\gitea-runner\work
'@ | Set-Content -Path $cfg -Encoding ASCII
Log "config.yaml 생성 (capacity 1)"
}
# ------------------------------------------- 러너 등록 (토큰이 호스트에 있을 때만)
# 호스트의 serve/runner-token.txt 에 등록 토큰을 넣어주면 여기서 등록한다.
# 없으면 이 단계만 건너뛰고 나머지는 전부 완료로 본다.
$registered = Test-Path (Join-Path $RunnerDir '.runner')
if ($registered) {
Log "러너 이미 등록됨"
} else {
$tok = $null
try {
$tok = (Invoke-WebRequest -Uri 'http://10.0.2.2:8099/runner-token.txt' -UseBasicParsing -TimeoutSec 10).Content
if ($tok -is [byte[]]) { $tok = [Text.Encoding]::ASCII.GetString($tok) }
$tok = $tok.Trim()
} catch { $tok = $null }
if ([string]::IsNullOrWhiteSpace($tok)) {
Log "등록 토큰 없음 (serve/runner-token.txt) - 러너 등록은 건너뜀. 사전 설치는 전부 끝났다."
} else {
Log "러너 등록 시도 (토큰 길이 $($tok.Length))"
Push-Location $RunnerDir
$out = & $RunnerExe register --no-interactive `
--instance 'https://git.tkrmagid.kr' `
--token $tok `
--name 'win-ci' `
--labels 'windows:host' 2>&1 | Out-String
Pop-Location
Log "register -> $($out.Trim())"
if (-not (Test-Path (Join-Path $RunnerDir '.runner'))) { throw "러너 등록 실패" }
# 서비스로 만들지 않는다. SYSTEM 권한이 되고, 데스크톱 세션이 없어
# GUI 테스트/스크린샷이 전부 실패한다. 로그온 예약 작업이어야 한다.
& schtasks.exe /Create /TN 'gitea-runner' /SC ONLOGON /RL HIGHEST /F `
/TR "cmd.exe /c cd /d C:\gitea-runner && act_runner.exe daemon --config config.yaml" | Out-Null
& schtasks.exe /Run /TN 'gitea-runner' | Out-Null
Log "러너 등록 + 로그온 작업 등록 완료, daemon 시작"
}
}
# ------------------------------------------------------------------ 요약
Sync-Path
Log "=== 설치 요약 ==="
foreach ($c in 'git', 'pwsh', 'python', 'node', 'cmake') {
$v = try { (& $c --version 2>&1 | Select-Object -First 1) } catch { '없음' }
Log ("{0,-8} {1}" -f $c, $v)
}
Log "act_runner $(if (Test-Path $RunnerExe) { 'OK' } else { '없음' }) 등록=$(Test-Path (Join-Path $RunnerDir '.runner'))"
# 모니터 2개가 실제로 잡혔는지 - virtio-gpu 드라이버가 들어갔는지 확인하는 지표다.
try {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
$screens = [System.Windows.Forms.Screen]::AllScreens
Log "화면 $($screens.Count)개: $(($screens | ForEach-Object { $_.Bounds.Width.ToString() + 'x' + $_.Bounds.Height }) -join ', ')"
$gpu = Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }
Log "비디오 어댑터: $($gpu -join ', ')"
} catch {
Log "화면 열거 실패: $($_.Exception.Message)"
}
Log "=== stage2 완료 ==="