mirror of
https://github.com/dockur/windows.git
synced 2026-07-27 21:42:36 +07:00
660 lines
19 KiB
YAML
660 lines
19 KiB
YAML
name: Installation
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
name:
|
|
description: Display name for the Windows version
|
|
required: true
|
|
type: string
|
|
|
|
version:
|
|
description: VERSION value passed to the container
|
|
required: true
|
|
type: string
|
|
|
|
runner:
|
|
description: GitHub Actions runner
|
|
required: false
|
|
default: ubuntu-24.04
|
|
type: string
|
|
|
|
image:
|
|
description: Container image to test
|
|
required: false
|
|
default: ghcr.io/dockur/windows:latest
|
|
type: string
|
|
|
|
callback:
|
|
description: Guest script type
|
|
required: false
|
|
default: powershell
|
|
type: string
|
|
|
|
cpu:
|
|
description: CPU model exposed to Windows
|
|
required: false
|
|
default: host
|
|
type: string
|
|
|
|
expected_caption:
|
|
description: Text expected in the Windows caption
|
|
required: true
|
|
type: string
|
|
|
|
expected_edition:
|
|
description: Expected Windows EditionID
|
|
required: false
|
|
default: ""
|
|
type: string
|
|
|
|
minimum_build:
|
|
description: Minimum acceptable Windows build number
|
|
required: true
|
|
type: number
|
|
|
|
platform:
|
|
description: Expected Windows platform
|
|
required: true
|
|
type: string
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
install:
|
|
name: ${{ inputs.name }}
|
|
runs-on: ${{ inputs.runner }}
|
|
timeout-minutes: 180
|
|
|
|
env:
|
|
CONTAINER: windows-test
|
|
IMAGE: ${{ inputs.image }}
|
|
|
|
steps:
|
|
- name: Check KVM
|
|
shell: bash
|
|
run: |
|
|
set -Eeuo pipefail
|
|
|
|
test -c /dev/kvm
|
|
|
|
- name: Free disk space
|
|
shell: bash
|
|
run: |
|
|
set -Eeuo pipefail
|
|
|
|
sudo rm -rf \
|
|
/usr/local/.ghcup \
|
|
/usr/local/lib/android \
|
|
/usr/local/lib/node_modules \
|
|
/usr/local/share/boost \
|
|
/usr/local/share/chromium \
|
|
/usr/local/share/powershell \
|
|
/usr/share/dotnet \
|
|
/usr/share/swift \
|
|
/opt/az \
|
|
/opt/ghc \
|
|
/opt/google \
|
|
/opt/hostedtoolcache \
|
|
/opt/microsoft \
|
|
/opt/pipx
|
|
|
|
sudo apt-get clean
|
|
sudo rm -rf \
|
|
/var/cache/apt/* \
|
|
/var/lib/apt/lists/*
|
|
|
|
docker system prune \
|
|
--all \
|
|
--force \
|
|
--volumes > /dev/null || true
|
|
|
|
available_kb="$(df --output=avail / | tail -1)"
|
|
available_gb="$((available_kb / 1024 / 1024))"
|
|
|
|
echo "Available disk space: ${available_gb} GB"
|
|
|
|
if (( available_gb < 40 )); then
|
|
echo "At least 40 GB of free space is required."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Prepare installation test
|
|
id: test
|
|
shell: bash
|
|
env:
|
|
CALLBACK: ${{ inputs.callback }}
|
|
run: |
|
|
set -Eeuo pipefail
|
|
|
|
case "$CALLBACK" in
|
|
powershell | legacy)
|
|
;;
|
|
*)
|
|
echo "Unsupported guest script type: $CALLBACK"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
token="$(cat /proc/sys/kernel/random/uuid)"
|
|
|
|
mkdir -p \
|
|
"$RUNNER_TEMP/data" \
|
|
"$RUNNER_TEMP/oem" \
|
|
"$RUNNER_TEMP/storage"
|
|
|
|
printf '%s\n' "$token" > "$RUNNER_TEMP/data/readme.txt"
|
|
|
|
case "$CALLBACK" in
|
|
powershell)
|
|
cat > "$RUNNER_TEMP/oem/ready.ps1" <<'POWERSHELL'
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Token
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
$windows = Get-CimInstance Win32_OperatingSystem
|
|
$registry = Get-ItemProperty `
|
|
"HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion"
|
|
|
|
$platform = switch (
|
|
$env:PROCESSOR_ARCHITECTURE.ToUpperInvariant()
|
|
) {
|
|
"AMD64" {
|
|
"x64"
|
|
}
|
|
"ARM64" {
|
|
"arm64"
|
|
}
|
|
"X86" {
|
|
"x86"
|
|
}
|
|
default {
|
|
$env:PROCESSOR_ARCHITECTURE.ToLowerInvariant()
|
|
}
|
|
}
|
|
|
|
while ($true) {
|
|
try {
|
|
$share = (
|
|
Get-Content `
|
|
-LiteralPath "\\host.lan\Data\readme.txt" `
|
|
-Raw
|
|
).Trim()
|
|
|
|
$result = @{
|
|
token = $Token
|
|
caption = [string]$windows.Caption
|
|
edition = [string]$registry.EditionID
|
|
version = [string]$windows.Version
|
|
build = [string]$windows.BuildNumber
|
|
platform = $platform
|
|
share = $share
|
|
}
|
|
|
|
$json = $result | ConvertTo-Json -Compress
|
|
$temporary = "\\host.lan\Data\windows.tmp"
|
|
$destination = "\\host.lan\Data\windows.json"
|
|
$encoding = New-Object System.Text.UTF8Encoding($false)
|
|
|
|
[System.IO.File]::WriteAllText(
|
|
$temporary,
|
|
$json,
|
|
$encoding
|
|
)
|
|
|
|
Move-Item `
|
|
-LiteralPath $temporary `
|
|
-Destination $destination `
|
|
-Force
|
|
|
|
break
|
|
}
|
|
catch {
|
|
Start-Sleep -Seconds 10
|
|
}
|
|
}
|
|
POWERSHELL
|
|
|
|
cat > "$RUNNER_TEMP/oem/install.bat" <<EOF
|
|
@echo off
|
|
powershell.exe -NoProfile -ExecutionPolicy Bypass ^
|
|
-File C:\OEM\ready.ps1 ^
|
|
-Token "$token"
|
|
EOF
|
|
;;
|
|
|
|
legacy)
|
|
cat > "$RUNNER_TEMP/oem/ready.vbs" <<'VBSCRIPT'
|
|
Option Explicit
|
|
|
|
Const ForReading = 1
|
|
Const ForWriting = 2
|
|
|
|
Dim arguments
|
|
Dim token
|
|
|
|
Set arguments = WScript.Arguments
|
|
|
|
If arguments.Count < 1 Then
|
|
WScript.Quit 1
|
|
End If
|
|
|
|
token = arguments(0)
|
|
|
|
Function EscapeJson(value)
|
|
Dim result
|
|
|
|
result = CStr(value)
|
|
result = Replace(result, "\", "\\")
|
|
result = Replace(result, Chr(34), "\" & Chr(34))
|
|
result = Replace(result, vbCr, "\r")
|
|
result = Replace(result, vbLf, "\n")
|
|
result = Replace(result, vbTab, "\t")
|
|
|
|
EscapeJson = result
|
|
End Function
|
|
|
|
Function ReadRegistry(shell, path)
|
|
Dim value
|
|
|
|
value = ""
|
|
|
|
On Error Resume Next
|
|
value = shell.RegRead(path)
|
|
|
|
If Err.Number <> 0 Then
|
|
Err.Clear
|
|
value = ""
|
|
End If
|
|
|
|
On Error GoTo 0
|
|
|
|
ReadRegistry = CStr(value)
|
|
End Function
|
|
|
|
Function ReadTextFile(filesystem, path)
|
|
Dim file
|
|
Dim value
|
|
|
|
value = ""
|
|
|
|
Set file = filesystem.OpenTextFile(path, ForReading, False)
|
|
value = file.ReadAll
|
|
file.Close
|
|
|
|
value = Replace(value, vbCr, "")
|
|
value = Replace(value, vbLf, "")
|
|
|
|
ReadTextFile = value
|
|
End Function
|
|
|
|
Function GetPlatform(shell)
|
|
Dim architecture
|
|
|
|
architecture = UCase(shell.ExpandEnvironmentStrings("%PROCESSOR_ARCHITECTURE%"))
|
|
|
|
Select Case architecture
|
|
Case "AMD64"
|
|
GetPlatform = "x64"
|
|
Case "ARM64"
|
|
GetPlatform = "arm64"
|
|
Case "X86"
|
|
GetPlatform = "x86"
|
|
Case Else
|
|
GetPlatform = LCase(architecture)
|
|
End Select
|
|
End Function
|
|
|
|
Dim filesystem
|
|
Dim shell
|
|
Dim locator
|
|
Dim service
|
|
Dim systems
|
|
Dim system
|
|
|
|
Dim caption
|
|
Dim edition
|
|
Dim version
|
|
Dim build
|
|
Dim platform
|
|
Dim share
|
|
|
|
Dim json
|
|
Dim temporary
|
|
Dim destination
|
|
Dim file
|
|
|
|
Set filesystem = CreateObject("Scripting.FileSystemObject")
|
|
Set shell = CreateObject("WScript.Shell")
|
|
Set locator = CreateObject("WbemScripting.SWbemLocator")
|
|
Set service = locator.ConnectServer(".", "root\cimv2")
|
|
Set systems = service.ExecQuery("SELECT Caption, Version, BuildNumber FROM Win32_OperatingSystem")
|
|
|
|
caption = ""
|
|
version = ""
|
|
build = ""
|
|
|
|
For Each system In systems
|
|
caption = CStr(system.Caption)
|
|
version = CStr(system.Version)
|
|
build = CStr(system.BuildNumber)
|
|
Exit For
|
|
Next
|
|
|
|
edition = ReadRegistry(shell, "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\EditionID")
|
|
platform = GetPlatform(shell)
|
|
|
|
temporary = "\\host.lan\Data\windows.tmp"
|
|
destination = "\\host.lan\Data\windows.json"
|
|
|
|
Do
|
|
On Error Resume Next
|
|
|
|
share = ReadTextFile(filesystem, "\\host.lan\Data\readme.txt")
|
|
|
|
If Err.Number = 0 Then
|
|
json = _
|
|
"{" & _
|
|
"""token"":""" & EscapeJson(token) & """," & _
|
|
"""caption"":""" & EscapeJson(caption) & """," & _
|
|
"""edition"":""" & EscapeJson(edition) & """," & _
|
|
"""version"":""" & EscapeJson(version) & """," & _
|
|
"""build"":""" & EscapeJson(build) & """," & _
|
|
"""platform"":""" & EscapeJson(platform) & """," & _
|
|
"""share"":""" & EscapeJson(share) & """" & _
|
|
"}"
|
|
|
|
Set file = filesystem.OpenTextFile(temporary, ForWriting, True)
|
|
file.Write json
|
|
file.Close
|
|
|
|
If filesystem.FileExists(destination) Then
|
|
filesystem.DeleteFile destination, True
|
|
End If
|
|
|
|
filesystem.MoveFile temporary, destination
|
|
End If
|
|
|
|
If Err.Number = 0 Then
|
|
On Error GoTo 0
|
|
Exit Do
|
|
End If
|
|
|
|
Err.Clear
|
|
On Error GoTo 0
|
|
|
|
WScript.Sleep 10000
|
|
Loop
|
|
VBSCRIPT
|
|
|
|
cat > "$RUNNER_TEMP/oem/install.bat" <<EOF
|
|
@echo off
|
|
cscript.exe //B //NoLogo C:\OEM\ready.vbs "$token"
|
|
EOF
|
|
;;
|
|
esac
|
|
|
|
echo "token=$token" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Pull image
|
|
shell: bash
|
|
run: |
|
|
set -Eeuo pipefail
|
|
|
|
docker pull "$IMAGE"
|
|
|
|
docker image inspect "$IMAGE" \
|
|
--format 'Digest: {{index .RepoDigests 0}}'
|
|
|
|
- name: Install and validate Windows
|
|
shell: bash
|
|
env:
|
|
CPU: ${{ inputs.cpu }}
|
|
EXPECTED_TOKEN: ${{ steps.test.outputs.token }}
|
|
EXPECTED_CAPTION: ${{ inputs.expected_caption }}
|
|
EXPECTED_EDITION: ${{ inputs.expected_edition }}
|
|
EXPECTED_PLATFORM: ${{ inputs.platform }}
|
|
MINIMUM_BUILD: ${{ inputs.minimum_build }}
|
|
VERSION: ${{ inputs.version }}
|
|
DISPLAY_NAME: ${{ inputs.name }}
|
|
run: |
|
|
set -Eeuo pipefail
|
|
|
|
docker run --detach \
|
|
--name "$CONTAINER" \
|
|
--device /dev/kvm \
|
|
--device /dev/net/tun \
|
|
--cap-add NET_ADMIN \
|
|
--stop-timeout 120 \
|
|
--env "VERSION=$VERSION" \
|
|
--env "RAM_SIZE=half" \
|
|
--env "CPU_CORES=half" \
|
|
--env "CPU_MODEL=$CPU" \
|
|
--env "DISK_SIZE=64G" \
|
|
--volume "$RUNNER_TEMP/data:/shared" \
|
|
--volume "$RUNNER_TEMP/oem:/oem:ro" \
|
|
--volume "$RUNNER_TEMP/storage:/storage" \
|
|
"$IMAGE"
|
|
|
|
echo
|
|
echo "Container log:"
|
|
echo "------------------------------------------------------------"
|
|
|
|
docker logs \
|
|
--follow \
|
|
--timestamps \
|
|
"$CONTAINER" &
|
|
|
|
logs_pid="$!"
|
|
|
|
stop_logs() {
|
|
kill "$logs_pid" 2>/dev/null || true
|
|
wait "$logs_pid" 2>/dev/null || true
|
|
}
|
|
|
|
trap stop_logs EXIT
|
|
|
|
deadline=$((SECONDS + 9000))
|
|
boot_loop_limit=5
|
|
|
|
while (( SECONDS < deadline )); do
|
|
state="$(
|
|
docker inspect \
|
|
--format '{{.State.Status}}' \
|
|
"$CONTAINER" 2>/dev/null || true
|
|
)"
|
|
|
|
if [[ "$state" != "running" ]]; then
|
|
echo
|
|
echo "------------------------------------------------------------"
|
|
echo "Container stopped before Windows became ready."
|
|
echo "Container state: ${state:-missing}"
|
|
exit 1
|
|
fi
|
|
|
|
container_log="$(docker logs "$CONTAINER" 2>&1 || true)"
|
|
|
|
if grep -Eqi \
|
|
'KVM internal error|KVM: entry failed|hardware error 0x[0-9a-f]+|Triple fault' \
|
|
<<< "$container_log"; then
|
|
echo
|
|
echo "------------------------------------------------------------"
|
|
echo "Detected a fatal QEMU or KVM error."
|
|
exit 1
|
|
fi
|
|
|
|
if grep -Fqi \
|
|
'CDBOOT: Cannot boot from CD - Code: 5' \
|
|
<<< "$container_log"; then
|
|
echo
|
|
echo "------------------------------------------------------------"
|
|
echo "Detected an unbootable installation medium."
|
|
exit 1
|
|
fi
|
|
|
|
bios_starts="$(
|
|
grep -Fci 'SeaBIOS (version ' <<< "$container_log" || true
|
|
)"
|
|
|
|
hard_disk_boots="$(
|
|
grep -Fci 'Booting from Hard Disk' <<< "$container_log" || true
|
|
)"
|
|
|
|
dvd_boots="$(
|
|
grep -Fci 'Booting from DVD/CD' <<< "$container_log" || true
|
|
)"
|
|
|
|
not_bootable_disk="$(
|
|
grep -Fci \
|
|
'Boot failed: not a bootable disk' \
|
|
<<< "$container_log" || true
|
|
)"
|
|
|
|
unreadable_boot_disk="$(
|
|
grep -Fci \
|
|
'Boot failed: could not read the boot disk' \
|
|
<<< "$container_log" || true
|
|
)"
|
|
|
|
no_bootable_device="$(
|
|
grep -Fci 'No bootable device.' <<< "$container_log" || true
|
|
)"
|
|
|
|
bootmgr_missing="$(
|
|
grep -Fci 'BOOTMGR is missing' <<< "$container_log" || true
|
|
)"
|
|
|
|
if (( bios_starts >= boot_loop_limit )) &&
|
|
(( hard_disk_boots >= boot_loop_limit ||
|
|
dvd_boots >= boot_loop_limit ||
|
|
not_bootable_disk >= boot_loop_limit ||
|
|
unreadable_boot_disk >= boot_loop_limit ||
|
|
no_bootable_device >= boot_loop_limit ||
|
|
bootmgr_missing >= boot_loop_limit )); then
|
|
echo
|
|
echo "------------------------------------------------------------"
|
|
echo "Detected a repeated BIOS boot loop."
|
|
echo "SeaBIOS starts: $bios_starts"
|
|
echo "Hard disk boots: $hard_disk_boots"
|
|
echo "DVD boots: $dvd_boots"
|
|
echo "Not-bootable disk failures: $not_bootable_disk"
|
|
echo "Unreadable boot-disk failures: $unreadable_boot_disk"
|
|
echo "No-bootable-device failures: $no_bootable_device"
|
|
echo "BOOTMGR failures: $bootmgr_missing"
|
|
exit 1
|
|
fi
|
|
|
|
response=""
|
|
|
|
if [[ -s "$RUNNER_TEMP/data/windows.json" ]]; then
|
|
response="$(cat "$RUNNER_TEMP/data/windows.json")"
|
|
fi
|
|
|
|
if [[ -n "$response" ]]; then
|
|
stop_logs
|
|
trap - EXIT
|
|
|
|
echo
|
|
echo "------------------------------------------------------------"
|
|
echo "Received response:"
|
|
|
|
if ! jq . <<< "$response"; then
|
|
echo "$response"
|
|
echo "The guest returned invalid JSON."
|
|
exit 1
|
|
fi
|
|
|
|
token="$(jq -r '.token // empty' <<< "$response")"
|
|
caption="$(jq -r '.caption // empty' <<< "$response")"
|
|
edition="$(jq -r '.edition // empty' <<< "$response")"
|
|
version="$(jq -r '.version // empty' <<< "$response")"
|
|
build="$(jq -r '.build // empty' <<< "$response")"
|
|
platform="$(jq -r '.platform // empty' <<< "$response")"
|
|
share="$(jq -r '.share // empty' <<< "$response")"
|
|
|
|
if [[ "$token" != "$EXPECTED_TOKEN" ]]; then
|
|
echo "The response token does not match."
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$share" != "$EXPECTED_TOKEN" ]]; then
|
|
echo "Failed to read \\\\host.lan\\Data\\readme.txt."
|
|
echo "Expected contents:"
|
|
echo " $EXPECTED_TOKEN"
|
|
echo "Received:"
|
|
echo " ${share:-empty}"
|
|
exit 1
|
|
fi
|
|
|
|
normalized_caption="${caption//\(R\)/}"
|
|
normalized_expected_caption="${EXPECTED_CAPTION//\(R\)/}"
|
|
|
|
if [[ "$normalized_caption" != *"$normalized_expected_caption"* ]]; then
|
|
echo "Expected caption containing:"
|
|
echo " $EXPECTED_CAPTION"
|
|
echo "Received:"
|
|
echo " $caption"
|
|
exit 1
|
|
fi
|
|
|
|
normalized_edition="${edition%Eval}"
|
|
normalized_expected_edition="${EXPECTED_EDITION%Eval}"
|
|
|
|
if [[ -n "$EXPECTED_EDITION" &&
|
|
"$normalized_edition" != "$normalized_expected_edition" ]]; then
|
|
echo "Expected edition: $EXPECTED_EDITION"
|
|
echo "Received edition: $edition"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
echo "Unexpected Windows version: $version"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ ! "$build" =~ ^[0-9]+$ ]]; then
|
|
echo "Invalid Windows build number: $build"
|
|
exit 1
|
|
fi
|
|
|
|
if (( build < MINIMUM_BUILD )); then
|
|
echo "Expected build $MINIMUM_BUILD or newer."
|
|
echo "Received build: $build"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$platform" != "$EXPECTED_PLATFORM" ]]; then
|
|
echo "Expected platform:"
|
|
echo " $EXPECTED_PLATFORM"
|
|
echo "Received platform:"
|
|
echo " $platform"
|
|
exit 1
|
|
fi
|
|
|
|
echo
|
|
echo "$DISPLAY_NAME installed successfully."
|
|
echo "Version: $version"
|
|
echo "Build: $build"
|
|
echo "Platform: $platform"
|
|
echo "Shared file: accessible"
|
|
exit 0
|
|
fi
|
|
|
|
sleep 10
|
|
done
|
|
|
|
echo
|
|
echo "------------------------------------------------------------"
|
|
echo "Timed out waiting for Windows installation."
|
|
exit 1
|
|
|
|
- name: Cleanup
|
|
if: always()
|
|
shell: bash
|
|
run: |
|
|
docker rm --force "$CONTAINER" 2>/dev/null || true
|