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:
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