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:
@@ -142,6 +142,68 @@ IOMMU 그룹 수 0개
|
|||||||
넣은 뒤 GPU를 `vfio-pci`에 묶어야 하는데, 그러면 **`.9`가 GPU를 잃습니다.**
|
넣은 뒤 GPU를 `vfio-pci`에 묶어야 하는데, 그러면 **`.9`가 GPU를 잃습니다.**
|
||||||
결국 "GPU를 누가 가질 것인가" 문제로 되돌아오므로 중첩으로는 해결되지 않습니다.
|
결국 "GPU를 누가 가질 것인가" 문제로 되돌아오므로 중첩으로는 해결되지 않습니다.
|
||||||
|
|
||||||
|
#### 만드는 법 — 스크립트 한 줄, 사람 손 없음
|
||||||
|
|
||||||
|
`scripts/win-ci/` 에 전부 들어 있습니다. Windows 설치 화면을 사람이 클릭할
|
||||||
|
방법이 없으므로 **무인 설치**로 짰습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/win-ci/build.sh # ISO 받기 + 무인설치 ISO 굽기 + 디스크 생성
|
||||||
|
/home/claude/win-ci/start-vm.sh # VM + 제어 서버 기동
|
||||||
|
/home/claude/win-ci/boot-cd.sh reset # CD 부팅 프롬프트 넘기기 (최초 1회)
|
||||||
|
/home/claude/win-ci/status.sh # 진행 상황
|
||||||
|
/home/claude/win-ci/shot.sh a.png # 화면 스냅샷
|
||||||
|
```
|
||||||
|
|
||||||
|
네 부분이 이어달리기를 합니다.
|
||||||
|
|
||||||
|
| 파일 | 어디서 도나 | 하는 일 |
|
||||||
|
|---|---|---|
|
||||||
|
| `unattend/autounattend.xml` | Windows Setup | 디스크 분할·설치·OOBE 건너뛰기·`ci` 자동 로그온 |
|
||||||
|
| `unattend/setup/provision.ps1` | 첫 로그온 1회 | virtio 드라이버(네트워크·화면), 화면꺼짐/잠금 해제 |
|
||||||
|
| `unattend/setup/agent.ps1` | 로그온마다 상주 | 호스트에서 `stage2.ps1` 을 받아 실행 |
|
||||||
|
| `serve/stage2.ps1` | 호스트가 HTTP 로 제공 | Git·pwsh7·Python·Node·CMake·act_runner 설치 |
|
||||||
|
|
||||||
|
**왜 설치 내용을 ISO 에 안 넣었나** — ISO 는 한 번 구우면 못 고칩니다. 설치
|
||||||
|
스크립트를 ISO 에 박으면 한 줄 고칠 때마다 Windows 를 다시 깔아야 합니다.
|
||||||
|
그래서 ISO 에는 껍데기(agent)만 넣고, 실제 설치 내용은 호스트의 HTTP 로
|
||||||
|
가져옵니다. `serve/stage2.ps1` 을 고치면 게스트가 20초 안에 알아서 새로
|
||||||
|
받아 실행합니다.
|
||||||
|
|
||||||
|
호스트와 게스트를 잇는 통로는 두 개입니다.
|
||||||
|
|
||||||
|
| 방향 | 통로 | 왜 |
|
||||||
|
|---|---|---|
|
||||||
|
| 호스트 → 게스트 | HTTP `10.0.2.2:8099` (QEMU user networking) | 스크립트·러너 토큰 전달 |
|
||||||
|
| 게스트 → 호스트 | COM1 → `serial.log`, HTTP POST → `guest.log` | 네트워크가 **없는** 드라이버 설치 단계도 봐야 하므로 시리얼이 필수 |
|
||||||
|
|
||||||
|
접속 정보: 계정 `ci` / 비밀번호는 `~/.config/ejclaw/secrets.json` 의
|
||||||
|
`win-ci.9` 항목. RDP 는 호스트 `127.0.0.1:13389`, VNC 는 `127.0.0.1:5919`.
|
||||||
|
|
||||||
|
##### 무인 설치에서 실제로 걸린 것들
|
||||||
|
|
||||||
|
- **`Press any key to boot from CD`** — UEFI 는 이걸 5초 안에 안 누르면 CD 를
|
||||||
|
포기합니다. 무인인데 누를 사람이 없어서 QMP `send-key` 로 밀어 넣습니다.
|
||||||
|
단, **설치가 시작된 뒤에는 절대 키를 더 보내면 안 됩니다.** 남는 Enter 가
|
||||||
|
설치 화면의 `Cancel` 을 눌러 "Are you sure you want to quit?" 를 띄웁니다.
|
||||||
|
반대로 설치가 끝난 뒤 재부팅 때는 아무 키도 안 누르는 게 정답입니다.
|
||||||
|
그래야 CD 를 건너뛰고 디스크로 부팅합니다.
|
||||||
|
- **시스템 디스크는 SATA(AHCI)** 로 둡니다. virtio-blk/scsi 로 하면 Windows
|
||||||
|
Setup 이 디스크 자체를 못 봐서 WinPE 단계 드라이버 주입이 필요해집니다.
|
||||||
|
네트워크·화면만 virtio 로 가고, 그 드라이버는 설치 후 ISO 에서 오프라인으로
|
||||||
|
넣습니다.
|
||||||
|
- **Windows ISO 는 UDF** 입니다. ISO9660 층에는 `README.TXT` 하나뿐이라
|
||||||
|
`xorriso` 로 열면 비어 보입니다. `mount -t udf` 로 봐야 합니다.
|
||||||
|
- `install.wim` 의 **인덱스 1** 이 `Windows 11 Enterprise LTSC 2024 Evaluation`
|
||||||
|
입니다(이미지 2개). `wiminfo` 로 확인하고 박았습니다.
|
||||||
|
- **LTSC 에는 winget 이 없습니다.** 스토어가 빠져 있어서 설치 파일을 직접
|
||||||
|
받습니다.
|
||||||
|
- **Node.js 가 필요합니다.** `act_runner` 의 host 모드는 `actions/checkout`,
|
||||||
|
`upload-artifact` 같은 JS 액션을 PATH 의 `node` 로 돌립니다. 빠뜨리면 그
|
||||||
|
단계들만 조용히 실패합니다.
|
||||||
|
- **러너는 서비스가 아니라 로그온 예약 작업**이어야 합니다. 서비스로 돌리면
|
||||||
|
데스크톱 세션이 없어 GUI 테스트와 스크린샷이 전부 실패합니다.
|
||||||
|
|
||||||
#### VM 에 얼마를 줄까 — 실측 근거
|
#### VM 에 얼마를 줄까 — 실측 근거
|
||||||
|
|
||||||
**vCPU 4개 / RAM 8GB / 디스크 80GB.** 추측이 아니라 재보고 정한 값입니다.
|
**vCPU 4개 / RAM 8GB / 디스크 80GB.** 추측이 아니라 재보고 정한 값입니다.
|
||||||
|
|||||||
44
scripts/win-ci/boot-cd.sh
Executable file
44
scripts/win-ci/boot-cd.sh
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# "Press any key to boot from CD or DVD" 를 넘긴다.
|
||||||
|
#
|
||||||
|
# UEFI 부팅 시 Windows ISO 는 매번 이 프롬프트를 띄우고 약 5초 안에 키가
|
||||||
|
# 없으면 CD 부팅을 포기한다. 무인 설치인데 사람이 눌러줄 수 없으므로
|
||||||
|
# 리셋 직후부터 키를 계속 밀어 넣는다.
|
||||||
|
#
|
||||||
|
# ⚠️ 설치가 시작된 뒤에는 절대 실행하지 말 것. 남는 Enter 가 설치 화면의
|
||||||
|
# Cancel 버튼을 눌러 "Are you sure you want to quit?" 가 뜬다 (실제로 겪음).
|
||||||
|
# 설치가 끝난 뒤 재부팅 때는 키를 안 누르는 게 정답이다 - 그래야 CD 를
|
||||||
|
# 건너뛰고 디스크로 부팅한다.
|
||||||
|
#
|
||||||
|
# 사용법: ./boot-cd.sh [reset] - reset 을 주면 VM 을 먼저 리셋한다
|
||||||
|
set -euo pipefail
|
||||||
|
MODE="${1:-}"
|
||||||
|
python3 - "$MODE" <<'PY'
|
||||||
|
import json, socket, sys, time
|
||||||
|
mode = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||||
|
s = socket.socket(socket.AF_UNIX); s.settimeout(20)
|
||||||
|
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:
|
||||||
|
line = f.readline()
|
||||||
|
if not line: raise SystemExit("qmp closed")
|
||||||
|
m = json.loads(line)
|
||||||
|
if "return" in m or "error" in m: return m
|
||||||
|
cmd({"execute": "qmp_capabilities"})
|
||||||
|
if mode == "reset":
|
||||||
|
print(cmd({"execute": "system_reset"}))
|
||||||
|
time.sleep(3)
|
||||||
|
# 프롬프트 창은 5초뿐이지만 펌웨어 POST 시간이 들쭉날쭉하다. 25초면 충분하고,
|
||||||
|
# 그 이상 밀면 설치 화면까지 Enter 가 새어 들어간다.
|
||||||
|
deadline = time.time() + 25
|
||||||
|
n = 0
|
||||||
|
while time.time() < deadline:
|
||||||
|
cmd({"execute": "send-key",
|
||||||
|
"arguments": {"keys": [{"type": "qcode", "data": "ret"}]}})
|
||||||
|
n += 1
|
||||||
|
time.sleep(0.4)
|
||||||
|
print("sent", n, "keys")
|
||||||
|
PY
|
||||||
53
scripts/win-ci/build.sh
Executable file
53
scripts/win-ci/build.sh
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# win-ci Windows VM 을 처음부터 만든다 (.9 안 중첩 KVM, 무인 설치).
|
||||||
|
#
|
||||||
|
# ./build.sh ISO 내려받기 + 무인설치 ISO 굽기 + 디스크 생성
|
||||||
|
# ./start-vm.sh VM + 제어 서버 기동 (systemd 트랜지언트 유닛)
|
||||||
|
# ./boot-cd.sh reset "Press any key to boot from CD" 넘기기 (최초 1회만)
|
||||||
|
# ./status.sh 진행 상황
|
||||||
|
# ./shot.sh out.png 화면 스냅샷
|
||||||
|
#
|
||||||
|
# 사람이 클릭할 일은 없다. autounattend.xml 이 설치를 끝내고, provision.ps1 이
|
||||||
|
# virtio 드라이버를 넣고, agent.ps1 이 호스트에서 stage2.ps1 을 받아 개발도구와
|
||||||
|
# act_runner 를 깐다.
|
||||||
|
#
|
||||||
|
# 필요 패키지: qemu-system-x86 ovmf swtpm swtpm-tools xorriso
|
||||||
|
# 그리고 실행 계정이 kvm 그룹에 있어야 한다 (없으면 sg kvm -c '...' 로 감싼다).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
D="${WIN_CI_DIR:-/home/claude/win-ci}"
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
WIN_ISO_URL='https://go.microsoft.com/fwlink/p/?linkid=2289029' # Win11 Ent LTSC 2024 평가판(90일, 키 불필요)
|
||||||
|
VIRTIO_URL='https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso'
|
||||||
|
|
||||||
|
mkdir -p "$D/iso" "$D/serve" "$D/swtpm" "$D/unattend-root/setup"
|
||||||
|
|
||||||
|
fetch() { # url dest 최소크기MB
|
||||||
|
if [ -f "$2" ] && [ "$(stat -c%s "$2")" -gt $(( $3 * 1024 * 1024 )) ]; then
|
||||||
|
echo "이미 있음: $2"; return
|
||||||
|
fi
|
||||||
|
echo "다운로드: $2"
|
||||||
|
curl -fL --retry 3 -o "$2" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch "$WIN_ISO_URL" "$D/iso/win11-ltsc.iso" 4000
|
||||||
|
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/"
|
||||||
|
xorriso -as mkisofs -V UNATTEND -J -joliet-long -R -o "$D/unattend.iso" "$D/unattend-root/"
|
||||||
|
|
||||||
|
# 호스트 제어 채널이 내려줄 스크립트
|
||||||
|
cp "$HERE/serve/stage2.ps1" "$D/serve/"
|
||||||
|
cp "$HERE"/*.sh "$HERE"/serve-host.py "$D/"
|
||||||
|
chmod +x "$D"/*.sh
|
||||||
|
|
||||||
|
[ -f "$D/win-ci.qcow2" ] || qemu-img create -f qcow2 "$D/win-ci.qcow2" 80G
|
||||||
|
# SecureBoot 용 - MS 키가 미리 들어 있는 변수 이미지여야 Windows 부트로더가 통과한다
|
||||||
|
[ -f "$D/OVMF_VARS.fd" ] || cp /usr/share/OVMF/OVMF_VARS_4M.ms.fd "$D/OVMF_VARS.fd"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "준비 끝. 다음: $D/start-vm.sh 그리고 $D/boot-cd.sh reset"
|
||||||
28
scripts/win-ci/click.sh
Executable file
28
scripts/win-ci/click.sh
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# VM 화면의 좌표를 클릭한다 (usb-tablet 절대좌표).
|
||||||
|
# 사용법: ./click.sh <x> <y> [화면폭 화면높이] 기본 1280x800
|
||||||
|
set -euo pipefail
|
||||||
|
python3 - "$@" <<'PY'
|
||||||
|
import json, socket, sys, time
|
||||||
|
x, y = int(sys.argv[1]), int(sys.argv[2])
|
||||||
|
W = int(sys.argv[3]) if len(sys.argv) > 3 else 1280
|
||||||
|
H = int(sys.argv[4]) if len(sys.argv) > 4 else 800
|
||||||
|
s = socket.socket(socket.AF_UNIX); s.settimeout(20)
|
||||||
|
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: return m
|
||||||
|
cmd({"execute": "qmp_capabilities"})
|
||||||
|
ax, ay = int(x / W * 32767), int(y / H * 32767)
|
||||||
|
ev = [{"type": "abs", "data": {"axis": "x", "value": ax}},
|
||||||
|
{"type": "abs", "data": {"axis": "y", "value": ay}}]
|
||||||
|
print(cmd({"execute": "input-send-event", "arguments": {"events": ev}}))
|
||||||
|
time.sleep(0.3)
|
||||||
|
print(cmd({"execute": "input-send-event", "arguments": {"events": [
|
||||||
|
{"type": "btn", "data": {"down": True, "button": "left"}}]}}))
|
||||||
|
time.sleep(0.15)
|
||||||
|
print(cmd({"execute": "input-send-event", "arguments": {"events": [
|
||||||
|
{"type": "btn", "data": {"down": False, "button": "left"}}]}}))
|
||||||
|
PY
|
||||||
55
scripts/win-ci/run-vm.sh
Executable file
55
scripts/win-ci/run-vm.sh
Executable file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# win-ci Windows VM 기동 (.9 안 중첩 KVM)
|
||||||
|
#
|
||||||
|
# 이 스크립트를 직접 실행하지 말 것. start-vm.sh 가 systemd 트랜지언트 유닛으로
|
||||||
|
# 띄운다. 에이전트 턴에서 그냥 qemu 를 띄우면 ejclaw.service 의 cgroup 안에서
|
||||||
|
# 돌아 MemoryHigh=16G 상한을 쳐서 봇이 OOM 으로 죽는다 (2026-07-26 전례).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
D=/home/claude/win-ci
|
||||||
|
cd "$D"
|
||||||
|
|
||||||
|
# TPM 2.0 - Windows 11 요구사항
|
||||||
|
if [ ! -S "$D/swtpm/sock" ]; then
|
||||||
|
swtpm socket --tpmstate "dir=$D/swtpm" \
|
||||||
|
--ctrl "type=unixio,path=$D/swtpm/sock" --tpm2 -d
|
||||||
|
for i in $(seq 1 20); do [ -S "$D/swtpm/sock" ] && break; sleep 0.3; done
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$D/qmp.sock"
|
||||||
|
|
||||||
|
exec qemu-system-x86_64 \
|
||||||
|
-name win-ci,process=win-ci \
|
||||||
|
-machine q35,smm=on,vmport=off \
|
||||||
|
-accel kvm \
|
||||||
|
-cpu host,hv_relaxed,hv_spinlocks=0x1fff,hv_vapic,hv_time \
|
||||||
|
-smp 4,sockets=1,cores=4,threads=1 \
|
||||||
|
-m 8192 \
|
||||||
|
-rtc base=localtime,driftfix=slew \
|
||||||
|
-global driver=cfi.pflash01,property=secure,value=on \
|
||||||
|
-global ICH9-LPC.disable_s3=1 \
|
||||||
|
-drive if=pflash,format=raw,unit=0,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.secboot.fd \
|
||||||
|
-drive if=pflash,format=raw,unit=1,file="$D/OVMF_VARS.fd" \
|
||||||
|
-chardev "socket,id=chrtpm,path=$D/swtpm/sock" \
|
||||||
|
-tpmdev emulator,id=tpm0,chardev=chrtpm \
|
||||||
|
-device tpm-tis,tpmdev=tpm0 \
|
||||||
|
-device virtio-vga,max_outputs=2,xres=1920,yres=1080 \
|
||||||
|
-audiodev none,id=aud0 \
|
||||||
|
-device intel-hda \
|
||||||
|
-device hda-duplex,audiodev=aud0 \
|
||||||
|
-netdev user,id=net0,hostfwd=tcp:127.0.0.1:13389-:3389 \
|
||||||
|
-device virtio-net-pci,netdev=net0 \
|
||||||
|
-device qemu-xhci,id=xhci \
|
||||||
|
-device usb-kbd -device usb-tablet \
|
||||||
|
-drive "file=$D/win-ci.qcow2,if=none,id=sysdisk,format=qcow2,cache=writeback,discard=unmap,aio=threads" \
|
||||||
|
-device ide-hd,drive=sysdisk,bus=ide.0,bootindex=2 \
|
||||||
|
-drive "file=$D/iso/win11-ltsc.iso,if=none,id=cdwin,media=cdrom,readonly=on" \
|
||||||
|
-device ide-cd,drive=cdwin,bus=ide.1,bootindex=1 \
|
||||||
|
-drive "file=$D/iso/virtio-win.iso,if=none,id=cdvirtio,media=cdrom,readonly=on" \
|
||||||
|
-device ide-cd,drive=cdvirtio,bus=ide.2 \
|
||||||
|
-drive "file=$D/unattend.iso,if=none,id=cdunattend,media=cdrom,readonly=on" \
|
||||||
|
-device ide-cd,drive=cdunattend,bus=ide.3 \
|
||||||
|
-serial "file:$D/serial.log" \
|
||||||
|
-qmp "unix:$D/qmp.sock,server=on,wait=off" \
|
||||||
|
-vnc 127.0.0.1:19 \
|
||||||
|
-boot menu=off,strict=on
|
||||||
67
scripts/win-ci/serve-host.py
Executable file
67
scripts/win-ci/serve-host.py
Executable file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""win-ci VM 과의 통신 채널.
|
||||||
|
|
||||||
|
게스트(Windows)는 QEMU user networking 안에 있어서 호스트를 10.0.2.2 로 본다.
|
||||||
|
이 서버는 그 반대편이다.
|
||||||
|
|
||||||
|
GET /ping 게스트가 네트워크 올라왔는지 확인용
|
||||||
|
GET /stage20.ps1 게스트가 실행할 설치 스크립트 (serve/ 안의 파일 그대로)
|
||||||
|
GET /<file> serve/ 안의 아무 파일 (러너 등록 토큰 등)
|
||||||
|
POST /log 게스트 로그 -> guest.log 에 append
|
||||||
|
|
||||||
|
127.0.0.1 만 바인딩한다. slirp 의 10.0.2.2 는 호스트 루프백으로 들어오므로
|
||||||
|
외부에 열 필요가 없다.
|
||||||
|
"""
|
||||||
|
import http.server
|
||||||
|
import os
|
||||||
|
import socketserver
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
ROOT = "/home/claude/win-ci/serve"
|
||||||
|
LOG = "/home/claude/win-ci/guest.log"
|
||||||
|
PORT = 8099
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
def __init__(self, *a, **kw):
|
||||||
|
super().__init__(*a, directory=ROOT, **kw)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.rstrip("/") == "/ping":
|
||||||
|
body = b"pong\n"
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/plain")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
return
|
||||||
|
super().do_GET()
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path.rstrip("/") != "/log":
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
n = int(self.headers.get("Content-Length") or 0)
|
||||||
|
data = self.rfile.read(n).decode("utf-8", "replace").strip()
|
||||||
|
with open(LOG, "a", encoding="utf-8") as f:
|
||||||
|
f.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} {data}\n")
|
||||||
|
self.send_response(204)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
# 접근 로그는 stdout 으로만. journald 가 받는다.
|
||||||
|
sys.stdout.write("%s %s\n" % (self.address_string(), fmt % args))
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class Server(socketserver.ThreadingTCPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
os.makedirs(ROOT, exist_ok=True)
|
||||||
|
with Server(("127.0.0.1", PORT), Handler) as httpd:
|
||||||
|
print(f"win-ci control server on 127.0.0.1:{PORT}, root={ROOT}", flush=True)
|
||||||
|
httpd.serve_forever()
|
||||||
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 완료 ==="
|
||||||
31
scripts/win-ci/shot.sh
Executable file
31
scripts/win-ci/shot.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# win-ci VM 화면 스냅샷. QMP screendump 로 찍는다.
|
||||||
|
# 사용법: ./shot.sh [출력경로.png]
|
||||||
|
set -euo pipefail
|
||||||
|
OUT="${1:-/home/claude/EJClaw/data/attachments/generated/win-ci-$(date +%H%M%S).png}"
|
||||||
|
mkdir -p "$(dirname "$OUT")"
|
||||||
|
python3 - "$OUT" <<'PY'
|
||||||
|
import json, socket, sys, time
|
||||||
|
out = sys.argv[1]
|
||||||
|
s = socket.socket(socket.AF_UNIX)
|
||||||
|
s.settimeout(20)
|
||||||
|
s.connect("/home/claude/win-ci/qmp.sock")
|
||||||
|
f = s.makefile("rwb")
|
||||||
|
f.readline() # greeting
|
||||||
|
def cmd(o):
|
||||||
|
f.write((json.dumps(o) + "\n").encode()); f.flush()
|
||||||
|
while True:
|
||||||
|
line = f.readline()
|
||||||
|
if not line: raise SystemExit("qmp closed")
|
||||||
|
m = json.loads(line)
|
||||||
|
if "return" in m or "error" in m: return m
|
||||||
|
cmd({"execute": "qmp_capabilities"})
|
||||||
|
r = cmd({"execute": "screendump", "arguments": {"filename": out, "format": "png"}})
|
||||||
|
if "error" in r:
|
||||||
|
# 구버전 qemu 는 format 인자를 모른다 -> ppm 으로 받는다
|
||||||
|
r = cmd({"execute": "screendump", "arguments": {"filename": out + ".ppm"}})
|
||||||
|
print(json.dumps(r))
|
||||||
|
else:
|
||||||
|
print("ok", out)
|
||||||
|
PY
|
||||||
|
ls -l "$OUT"* 2>/dev/null
|
||||||
33
scripts/win-ci/start-vm.sh
Executable file
33
scripts/win-ci/start-vm.sh
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# win-ci VM + 호스트 제어 서버를 systemd 트랜지언트 유닛으로 띄운다.
|
||||||
|
#
|
||||||
|
# 반드시 봇(ejclaw.service) cgroup 밖에서 돌아야 한다. 안에서 돌면 8GB VM 이
|
||||||
|
# MemoryHigh=16G 를 쳐서 봇이 OOM 으로 죽는다. --user 유닛으로 띄우면
|
||||||
|
# systemd --user 의 자식이 되어 app.slice 형제로 빠지고, 에이전트 턴이
|
||||||
|
# 끝나도 계속 살아 있는다.
|
||||||
|
#
|
||||||
|
# CPUQuota=400% / CPUWeight=50 - 물리 호스트가 i7-11700(16스레드)뿐이고 .5 에서
|
||||||
|
# 운영 컨테이너 19개가 돈다. 4코어를 넘기면 .5 응답이 +140% 느려진다(실측).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
systemctl --user reset-failed win-ci.service win-ci-http.service 2>/dev/null || true
|
||||||
|
|
||||||
|
# 게스트 <-> 호스트 제어 채널 (게스트는 10.0.2.2:8099 로 본다)
|
||||||
|
if ! systemctl --user is-active --quiet win-ci-http.service; then
|
||||||
|
systemd-run --user --unit=win-ci-http \
|
||||||
|
--description="win-ci guest control channel" \
|
||||||
|
-p Restart=always \
|
||||||
|
/usr/bin/python3 /home/claude/win-ci/serve-host.py
|
||||||
|
fi
|
||||||
|
|
||||||
|
# VM
|
||||||
|
systemd-run --user --unit=win-ci \
|
||||||
|
--description="win-ci Windows CI VM (nested KVM)" \
|
||||||
|
-p MemoryMax=10G \
|
||||||
|
-p CPUQuota=400% \
|
||||||
|
-p CPUWeight=50 \
|
||||||
|
-p WorkingDirectory=/home/claude/win-ci \
|
||||||
|
/bin/bash /home/claude/win-ci/run-vm.sh
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
systemctl --user --no-pager status win-ci-http.service win-ci.service | head -40
|
||||||
19
scripts/win-ci/status.sh
Executable file
19
scripts/win-ci/status.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# win-ci VM 현재 상태를 한 번에 본다.
|
||||||
|
cd /home/claude/win-ci
|
||||||
|
echo "=== 유닛 ==="
|
||||||
|
systemctl --user is-active win-ci.service win-ci-http.service | paste -sd' ' -
|
||||||
|
systemctl --user show win-ci.service -p MemoryCurrent -p CPUUsageNSec --value | paste -sd' ' -
|
||||||
|
echo
|
||||||
|
echo "=== 디스크 사용량 (설치가 진행되면 커진다) ==="
|
||||||
|
du -h --apparent-size win-ci.qcow2 2>/dev/null | cut -f1
|
||||||
|
du -h win-ci.qcow2 | cut -f1
|
||||||
|
echo
|
||||||
|
echo "=== 게스트 로그 (HTTP, 최근 25줄) ==="
|
||||||
|
tail -n 25 guest.log 2>/dev/null || echo "(아직 없음 - 네트워크 올라오기 전)"
|
||||||
|
echo
|
||||||
|
echo "=== 시리얼 로그 (최근 25줄) ==="
|
||||||
|
tail -n 25 serial.log 2>/dev/null | tr -d '\r' || echo "(아직 없음)"
|
||||||
|
echo
|
||||||
|
echo "=== 러너 등록 토큰 ==="
|
||||||
|
if [ -s serve/runner-token.txt ]; then echo "있음 ($(wc -c < serve/runner-token.txt) bytes)"; else echo "없음 - serve/runner-token.txt 에 넣으면 게스트가 20초 안에 자동 등록"; fi
|
||||||
190
scripts/win-ci/unattend/autounattend.xml
Normal file
190
scripts/win-ci/unattend/autounattend.xml
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
||||||
|
|
||||||
|
<settings pass="windowsPE">
|
||||||
|
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<SetupUILanguage>
|
||||||
|
<UILanguage>en-US</UILanguage>
|
||||||
|
</SetupUILanguage>
|
||||||
|
<InputLocale>0409:00000409</InputLocale>
|
||||||
|
<SystemLocale>en-US</SystemLocale>
|
||||||
|
<UILanguage>en-US</UILanguage>
|
||||||
|
<UserLocale>en-US</UserLocale>
|
||||||
|
</component>
|
||||||
|
|
||||||
|
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
|
||||||
|
<!-- Win11 하드웨어 검사 우회. TPM/SecureBoot 는 실제로 제공하지만
|
||||||
|
중첩 가상화라 CPU/스토리지 검사가 튕길 수 있으므로 안전망으로 둔다. -->
|
||||||
|
<RunSynchronous>
|
||||||
|
<RunSynchronousCommand wcm:action="add">
|
||||||
|
<Order>1</Order>
|
||||||
|
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path>
|
||||||
|
</RunSynchronousCommand>
|
||||||
|
<RunSynchronousCommand wcm:action="add">
|
||||||
|
<Order>2</Order>
|
||||||
|
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path>
|
||||||
|
</RunSynchronousCommand>
|
||||||
|
<RunSynchronousCommand wcm:action="add">
|
||||||
|
<Order>3</Order>
|
||||||
|
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassRAMCheck /t REG_DWORD /d 1 /f</Path>
|
||||||
|
</RunSynchronousCommand>
|
||||||
|
<RunSynchronousCommand wcm:action="add">
|
||||||
|
<Order>4</Order>
|
||||||
|
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassCPUCheck /t REG_DWORD /d 1 /f</Path>
|
||||||
|
</RunSynchronousCommand>
|
||||||
|
<RunSynchronousCommand wcm:action="add">
|
||||||
|
<Order>5</Order>
|
||||||
|
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassStorageCheck /t REG_DWORD /d 1 /f</Path>
|
||||||
|
</RunSynchronousCommand>
|
||||||
|
</RunSynchronous>
|
||||||
|
|
||||||
|
<DiskConfiguration>
|
||||||
|
<WillShowUI>OnError</WillShowUI>
|
||||||
|
<Disk wcm:action="add">
|
||||||
|
<DiskID>0</DiskID>
|
||||||
|
<WillWipeDisk>true</WillWipeDisk>
|
||||||
|
<CreatePartitions>
|
||||||
|
<CreatePartition wcm:action="add">
|
||||||
|
<Order>1</Order>
|
||||||
|
<Type>EFI</Type>
|
||||||
|
<Size>512</Size>
|
||||||
|
</CreatePartition>
|
||||||
|
<CreatePartition wcm:action="add">
|
||||||
|
<Order>2</Order>
|
||||||
|
<Type>MSR</Type>
|
||||||
|
<Size>16</Size>
|
||||||
|
</CreatePartition>
|
||||||
|
<CreatePartition wcm:action="add">
|
||||||
|
<Order>3</Order>
|
||||||
|
<Type>Primary</Type>
|
||||||
|
<Extend>true</Extend>
|
||||||
|
</CreatePartition>
|
||||||
|
</CreatePartitions>
|
||||||
|
<ModifyPartitions>
|
||||||
|
<ModifyPartition wcm:action="add">
|
||||||
|
<Order>1</Order>
|
||||||
|
<PartitionID>1</PartitionID>
|
||||||
|
<Label>System</Label>
|
||||||
|
<Format>FAT32</Format>
|
||||||
|
</ModifyPartition>
|
||||||
|
<ModifyPartition wcm:action="add">
|
||||||
|
<Order>2</Order>
|
||||||
|
<PartitionID>2</PartitionID>
|
||||||
|
</ModifyPartition>
|
||||||
|
<ModifyPartition wcm:action="add">
|
||||||
|
<Order>3</Order>
|
||||||
|
<PartitionID>3</PartitionID>
|
||||||
|
<Label>Windows</Label>
|
||||||
|
<Format>NTFS</Format>
|
||||||
|
<Letter>C</Letter>
|
||||||
|
</ModifyPartition>
|
||||||
|
</ModifyPartitions>
|
||||||
|
</Disk>
|
||||||
|
</DiskConfiguration>
|
||||||
|
|
||||||
|
<ImageInstall>
|
||||||
|
<OSImage>
|
||||||
|
<InstallFrom>
|
||||||
|
<MetaData wcm:action="add">
|
||||||
|
<Key>/IMAGE/INDEX</Key>
|
||||||
|
<Value>1</Value>
|
||||||
|
</MetaData>
|
||||||
|
</InstallFrom>
|
||||||
|
<InstallTo>
|
||||||
|
<DiskID>0</DiskID>
|
||||||
|
<PartitionID>3</PartitionID>
|
||||||
|
</InstallTo>
|
||||||
|
<WillShowUI>OnError</WillShowUI>
|
||||||
|
</OSImage>
|
||||||
|
</ImageInstall>
|
||||||
|
|
||||||
|
<UserData>
|
||||||
|
<AcceptEula>true</AcceptEula>
|
||||||
|
<FullName>ci</FullName>
|
||||||
|
<Organization>livesub</Organization>
|
||||||
|
</UserData>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
|
||||||
|
<settings pass="specialize">
|
||||||
|
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<ComputerName>WIN-CI</ComputerName>
|
||||||
|
<TimeZone>Korea Standard Time</TimeZone>
|
||||||
|
</component>
|
||||||
|
|
||||||
|
<!-- RDP 로 붙을 수 있게 열어둔다. 호스트에서 127.0.0.1:13389 로 포워딩된다. -->
|
||||||
|
<component name="Microsoft-Windows-TerminalServices-LocalSessionManager" processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<fDenyTSConnections>false</fDenyTSConnections>
|
||||||
|
</component>
|
||||||
|
<component name="Microsoft-Windows-TerminalServices-RDP-WinStationExtensions" processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<UserAuthentication>0</UserAuthentication>
|
||||||
|
</component>
|
||||||
|
<component name="Networking-MPSSVC-Svc" processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<FirewallGroups>
|
||||||
|
<FirewallGroup wcm:action="add" wcm:keyValue="RemoteDesktop">
|
||||||
|
<Active>true</Active>
|
||||||
|
<Group>@FirewallAPI.dll,-28752</Group>
|
||||||
|
<Profile>all</Profile>
|
||||||
|
</FirewallGroup>
|
||||||
|
</FirewallGroups>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
|
||||||
|
<settings pass="oobeSystem">
|
||||||
|
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<OOBE>
|
||||||
|
<HideEULAPage>true</HideEULAPage>
|
||||||
|
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
|
||||||
|
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
|
||||||
|
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
|
||||||
|
<NetworkLocation>Work</NetworkLocation>
|
||||||
|
<ProtectYourPC>3</ProtectYourPC>
|
||||||
|
<SkipMachineOOBE>true</SkipMachineOOBE>
|
||||||
|
<SkipUserOOBE>true</SkipUserOOBE>
|
||||||
|
</OOBE>
|
||||||
|
|
||||||
|
<UserAccounts>
|
||||||
|
<LocalAccounts>
|
||||||
|
<LocalAccount wcm:action="add">
|
||||||
|
<Name>ci</Name>
|
||||||
|
<DisplayName>ci</DisplayName>
|
||||||
|
<Group>Administrators</Group>
|
||||||
|
<Password>
|
||||||
|
<Value>LiveSubCI!2026</Value>
|
||||||
|
<PlainText>true</PlainText>
|
||||||
|
</Password>
|
||||||
|
</LocalAccount>
|
||||||
|
</LocalAccounts>
|
||||||
|
</UserAccounts>
|
||||||
|
|
||||||
|
<AutoLogon>
|
||||||
|
<Username>ci</Username>
|
||||||
|
<Enabled>true</Enabled>
|
||||||
|
<LogonCount>999</LogonCount>
|
||||||
|
<Password>
|
||||||
|
<Value>LiveSubCI!2026</Value>
|
||||||
|
<PlainText>true</PlainText>
|
||||||
|
</Password>
|
||||||
|
</AutoLogon>
|
||||||
|
|
||||||
|
<FirstLogonCommands>
|
||||||
|
<SynchronousCommand wcm:action="add">
|
||||||
|
<Order>1</Order>
|
||||||
|
<Description>provision windows ci runner</Description>
|
||||||
|
<RequiresUserInput>false</RequiresUserInput>
|
||||||
|
<CommandLine>cmd.exe /c "for %d in (D E F G H I J K L M) do @if exist %d:\setup\provision.ps1 start /wait powershell.exe -NoProfile -ExecutionPolicy Bypass -File %d:\setup\provision.ps1"</CommandLine>
|
||||||
|
</SynchronousCommand>
|
||||||
|
</FirstLogonCommands>
|
||||||
|
|
||||||
|
<TimeZone>Korea Standard Time</TimeZone>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
</unattend>
|
||||||
84
scripts/win-ci/unattend/setup/agent.ps1
Normal file
84
scripts/win-ci/unattend/setup/agent.ps1
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# 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'
|
||||||
|
$p.Open(); $p.WriteLine($line); $p.Close()
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
Invoke-RestMethod -Uri "$Host_/log" -Method Post -Body $line -TimeoutSec 5 | 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 {
|
||||||
|
$body = (Invoke-WebRequest -Uri "$Host_/stage2.ps1" -TimeoutSec 20 -UseBasicParsing).Content
|
||||||
|
if ($body -is [byte[]]) { $body = [Text.Encoding]::UTF8.GetString($body) }
|
||||||
|
|
||||||
|
$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
|
||||||
|
}
|
||||||
108
scripts/win-ci/unattend/setup/provision.ps1
Normal file
108
scripts/win-ci/unattend/setup/provision.ps1
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Windows CI VM 최초 부팅 프로비저닝 (무인)
|
||||||
|
#
|
||||||
|
# 이 스크립트는 autounattend.xml 의 FirstLogonCommands 로 한 번만 실행된다.
|
||||||
|
# 네트워크가 아직 없는 상태에서 시작하므로, 순서가 중요하다.
|
||||||
|
# 1) virtio 드라이버 설치 (ISO 에서 오프라인) -> 이걸 해야 네트워크가 생긴다
|
||||||
|
# 2) 화면 꺼짐/잠금 끄기 (GUI 테스트가 잠금화면에서 실패하지 않게)
|
||||||
|
# 3) agent.ps1 을 C:\win-ci 에 심고 로그온 예약 작업으로 등록
|
||||||
|
# 4) agent 를 바로 실행 -> 이후 모든 설치는 호스트가 주는 stage2.ps1 이 한다
|
||||||
|
#
|
||||||
|
# 로그는 COM1(호스트의 serial.log)과 C:\win-ci\provision.log 양쪽에 남긴다.
|
||||||
|
# 네트워크가 없는 1) 단계를 호스트에서 관찰할 수 있어야 하기 때문이다.
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
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'
|
||||||
|
$script:Port.Open()
|
||||||
|
} catch { $script:Port = $null }
|
||||||
|
|
||||||
|
function Say([string]$m) {
|
||||||
|
$line = "[provision $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $m"
|
||||||
|
try { Add-Content -Path 'C:\win-ci\provision.log' -Value $line -Encoding UTF8 } catch {}
|
||||||
|
if ($script:Port) { try { $script:Port.WriteLine($line) } catch {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
Say "start. user=$env:USERNAME computer=$env:COMPUTERNAME"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 1) virtio
|
||||||
|
# virtio-win ISO 가 몇 번째 드라이브로 잡힐지 모른다. 전부 훑어 찾는다.
|
||||||
|
$virtio = $null
|
||||||
|
foreach ($d in [char[]](68..77)) { # D..M
|
||||||
|
$p = "${d}:\virtio-win-guest-tools.exe"
|
||||||
|
if (Test-Path $p) { $virtio = "${d}:"; break }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $virtio) {
|
||||||
|
Say "ERROR virtio-win ISO 를 못 찾음. 네트워크/화면 드라이버 없이 계속한다."
|
||||||
|
} else {
|
||||||
|
Say "virtio ISO = $virtio"
|
||||||
|
|
||||||
|
# 드라이버 스토어에 직접 넣는 쪽을 먼저 한다. 설치 프로그램보다 확실하다.
|
||||||
|
$infs = @(
|
||||||
|
"$virtio\NetKVM\w11\amd64\netkvm.inf", # 네트워크 - 가장 중요
|
||||||
|
"$virtio\viogpudo\w11\amd64\viogpudo.inf", # 화면 - 모니터 2개에 필요
|
||||||
|
"$virtio\Balloon\w11\amd64\balloon.inf",
|
||||||
|
"$virtio\vioserial\w11\amd64\vioser.inf",
|
||||||
|
"$virtio\viostor\w11\amd64\viostor.inf",
|
||||||
|
"$virtio\vioscsi\w11\amd64\vioscsi.inf",
|
||||||
|
"$virtio\vioinput\w11\amd64\vioinput.inf"
|
||||||
|
)
|
||||||
|
foreach ($inf in $infs) {
|
||||||
|
if (Test-Path $inf) {
|
||||||
|
$out = & pnputil.exe /add-driver $inf /install 2>&1 | Out-String
|
||||||
|
Say "pnputil $(Split-Path $inf -Leaf) -> $($out.Trim() -replace '\s+', ' ')"
|
||||||
|
} else {
|
||||||
|
Say "skip (없음) $inf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# guest-tools 는 balloon 서비스/qemu-ga 까지 붙여준다. 실패해도 위에서 드라이버는 들어갔다.
|
||||||
|
try {
|
||||||
|
Say "virtio-win-guest-tools 설치 시작"
|
||||||
|
$p = Start-Process -FilePath "$virtio\virtio-win-guest-tools.exe" `
|
||||||
|
-ArgumentList '/install', '/quiet', '/norestart' -Wait -PassThru
|
||||||
|
Say "virtio-win-guest-tools exit=$($p.ExitCode)"
|
||||||
|
} catch {
|
||||||
|
Say "virtio-win-guest-tools 실패: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------- 2) 화면 꺼짐 / 잠금 끄기
|
||||||
|
# 러너가 GUI 테스트와 스크린샷을 찍으므로 데스크톱이 살아 있어야 한다.
|
||||||
|
& powercfg.exe /change monitor-timeout-ac 0
|
||||||
|
& powercfg.exe /change standby-timeout-ac 0
|
||||||
|
& powercfg.exe /change disk-timeout-ac 0
|
||||||
|
& powercfg.exe /change hibernate-timeout-ac 0
|
||||||
|
Say "powercfg 적용 (모니터/대기/디스크/최대절전 전부 끔)"
|
||||||
|
|
||||||
|
# 화면 보호기 + 잠금화면
|
||||||
|
reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f | Out-Null
|
||||||
|
reg add "HKCU\Control Panel\Desktop" /v ScreenSaverIsSecure /t REG_SZ /d 0 /f | Out-Null
|
||||||
|
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization" /v NoLockScreen /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
# 절전 시 암호 요구 끔
|
||||||
|
reg add "HKLM\SOFTWARE\Policies\Microsoft\Power\PowerSettings\0e796bdb-100d-47d6-a2d5-f7d2daa51f51" /v ACSettingIndex /t REG_DWORD /d 0 /f | Out-Null
|
||||||
|
Say "잠금화면/화면보호기 끔"
|
||||||
|
|
||||||
|
# 첫 부팅 때 뜨는 방해 요소들
|
||||||
|
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer" /v NoDriveTypeAutoRun /t REG_DWORD /d 255 /f | Out-Null
|
||||||
|
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v NoAutoRebootWithLoggedOnUsers /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
Say "자동실행/업데이트 자동재부팅 끔"
|
||||||
|
|
||||||
|
# ------------------------------------------- 3) agent 설치 + 로그온 예약 작업
|
||||||
|
$src = $PSScriptRoot
|
||||||
|
Copy-Item -Path (Join-Path $src 'agent.ps1') -Destination 'C:\win-ci\agent.ps1' -Force
|
||||||
|
Say "agent.ps1 복사 완료"
|
||||||
|
|
||||||
|
$taskCmd = 'powershell.exe'
|
||||||
|
$taskArg = '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\win-ci\agent.ps1'
|
||||||
|
& schtasks.exe /Create /TN 'win-ci-agent' /SC ONLOGON /RL HIGHEST /TR "$taskCmd $taskArg" /F 2>&1 | Out-String | ForEach-Object { Say "schtasks -> $($_.Trim())" }
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- 4) agent 기동
|
||||||
|
Say "agent 시작 - 이후 로그는 [agent] 접두어로 나온다"
|
||||||
|
Start-Process -FilePath $taskCmd -ArgumentList $taskArg -WindowStyle Hidden
|
||||||
|
|
||||||
|
Say "provision 끝"
|
||||||
|
if ($script:Port) { try { $script:Port.Close() } catch {} }
|
||||||
Reference in New Issue
Block a user