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:
197
scripts/win-ci/serve/stage2.ps1
Normal file
197
scripts/win-ci/serve/stage2.ps1
Normal 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 완료 ==="
|
||||
Reference in New Issue
Block a user