############################################################################### #### windows-container-smoke-tests.ps1 — Verify Windows container health #### #### Checks Docker/containerd service, container lifecycle, isolation #### #### modes, NAT networking, port mapping, volumes, and event logs. #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version: 1.0 #### #### #### #### Usage: .\windows-container-smoke-tests.ps1 #### #### .\windows-container-smoke-tests.ps1 -ContainerRuntime docker #### #### .\windows-container-smoke-tests.ps1 -OutputFormat tap #### #### #### #### See -Help for all options. #### ############################################################################### [CmdletBinding()] param( [ValidateSet("docker","containerd")] [string]$ContainerRuntime = "docker", [string]$TestImage = "mcr.microsoft.com/windows/nanoserver:ltsc2022", [ValidateSet("text","tap")] [string]$OutputFormat = "text", [switch]$NoColor, [switch]$Help ) $ErrorActionPreference = "Continue" # ============================================================================ # HELP # ============================================================================ if ($Help) { @" Usage: .\windows-container-smoke-tests.ps1 [OPTIONS] Smoke-test Windows container infrastructure. PowerShell 5.1+. Designed for Windows Server or Windows 10/11 with containers enabled. Parameters: -ContainerRuntime RT Container runtime: docker (default), containerd -TestImage IMAGE Test image (default: mcr.microsoft.com/windows/nanoserver:ltsc2022) -OutputFormat FORMAT Output: text (default), tap -NoColor Disable coloured output -Verbose Show debug output -Help Show this help Examples: .\windows-container-smoke-tests.ps1 .\windows-container-smoke-tests.ps1 -ContainerRuntime containerd .\windows-container-smoke-tests.ps1 -TestImage mcr.microsoft.com/windows/servercore:ltsc2022 .\windows-container-smoke-tests.ps1 -OutputFormat tap -NoColor "@ exit 0 } # ============================================================================ # STATE # ============================================================================ $script:Pass = 0 $script:Fail = 0 $script:Skip = 0 $script:Total = 0 $script:Results = @() $script:StartTime = Get-Date # ============================================================================ # COLORS # ============================================================================ function Write-Color { param([string]$Text, [string]$Color = "White") if ($NoColor) { Write-Host $Text } else { Write-Host $Text -ForegroundColor $Color } } function Write-Log { param([string]$Msg) Write-Color "[INFO] $Msg" "Cyan" } function Write-Warn { param([string]$Msg) Write-Color "[WARN] $Msg" "Yellow" } function Write-Err { param([string]$Msg) Write-Color "[ERROR] $Msg" "Red" } # ============================================================================ # TEST RESULT RECORDING # ============================================================================ function Record-Pass { param([string]$Name, [string]$Detail = "") $script:Pass++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="PASS"; Name=$Name; Detail=$Detail } if ($OutputFormat -eq "tap") { Write-Host "ok $($script:Total) - $Name$(if($Detail){" ($Detail)"})" } else { $mark = if ($NoColor) { "[PASS]" } else { [char]0x2713 } $msg = " $mark $Name" if ($Detail) { $msg += " - $Detail" } Write-Color $msg "Green" } } function Record-Fail { param([string]$Name, [string]$Detail = "") $script:Fail++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="FAIL"; Name=$Name; Detail=$Detail } if ($OutputFormat -eq "tap") { Write-Host "not ok $($script:Total) - $Name" if ($Detail) { Write-Host " # $Detail" } } else { $mark = if ($NoColor) { "[FAIL]" } else { [char]0x2717 } $msg = " $mark $Name" if ($Detail) { $msg += " - $Detail" } Write-Color $msg "Red" } } function Record-Skip { param([string]$Name, [string]$Reason = "") $script:Skip++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="SKIP"; Name=$Name; Detail=$Reason } if ($OutputFormat -eq "tap") { Write-Host "ok $($script:Total) - $Name # SKIP $Reason" } else { $mark = if ($NoColor) { "[SKIP]" } else { [char]0x2298 } $msg = " $mark $Name" if ($Reason) { $msg += " - $Reason" } Write-Color $msg "Yellow" } } # ============================================================================ # HELPERS # ============================================================================ function Test-CommandExists { param([string]$Command) $null -ne (Get-Command $Command -ErrorAction SilentlyContinue) } function Write-Section { param([string]$Name) if ($OutputFormat -eq "text") { Write-Host "" Write-Color $Name "White" } } function Remove-TestContainer { param([string]$Name) try { docker rm -f $Name 2>&1 | Out-Null } catch {} } function Test-DockerReady { param([string]$TestName, [switch]$NeedImage) if (-not (Test-CommandExists "docker")) { Record-Skip $TestName "docker command not found"; return $false } if ($NeedImage -and -not $script:ImageAvailable) { Record-Skip $TestName "test image not available"; return $false } return $true } $script:ImageAvailable = $false $script:TestContainerPrefix = "smoketest-container" # ============================================================================ # TESTS # ============================================================================ # -- 1. Container Service Running ------------------------------------------- function Test-ContainerService { Write-Section "Service" $serviceName = if ($ContainerRuntime -eq "containerd") { "containerd" } else { "docker" } try { $svc = Get-Service -Name $serviceName -ErrorAction Stop if ($svc.Status -eq "Running") { Record-Pass "Container service running" "$serviceName ($($svc.Status))" } else { Record-Fail "Container service running" "$serviceName is $($svc.Status)" } } catch { Record-Fail "Container service running" "$serviceName service not found" } } # -- 2. Daemon Responding --------------------------------------------------- function Test-DaemonHealth { if ($ContainerRuntime -eq "containerd") { if (Test-CommandExists "ctr") { try { $output = ctr version 2>&1 | Out-String if ($output -match "Version:" -or $output -match "Revision:") { Record-Pass "Daemon responding" "containerd responding" } else { Record-Fail "Daemon responding" "ctr version returned unexpected output" } } catch { Record-Fail "Daemon responding" "ctr error - $($_.Exception.Message)" } } else { Record-Fail "Daemon responding" "ctr command not found" } return } if (-not (Test-CommandExists "docker")) { Record-Fail "Daemon responding" "docker command not found" return } try { $output = docker info --format '{{.ServerVersion}}' 2>&1 | Out-String if ($LASTEXITCODE -eq 0 -and $output -match "\d+\.\d+") { Record-Pass "Daemon responding" "Docker Engine v$($output.Trim())" } else { Record-Fail "Daemon responding" "Docker daemon not responding" } } catch { Record-Fail "Daemon responding" "docker error - $($_.Exception.Message)" } } # -- 3. Container Runtime Mode ---------------------------------------------- function Test-RuntimeMode { Write-Section "Runtime" if ($ContainerRuntime -eq "containerd") { Record-Skip "Container runtime mode" "mode detection not applicable to containerd" return } if (-not (Test-DockerReady "Container runtime mode")) { return } try { $info = docker info 2>&1 | Out-String if ($info -match "OSType:\s*windows") { Record-Pass "Container runtime mode" "Windows containers" } elseif ($info -match "OSType:\s*linux") { Record-Fail "Container runtime mode" "Linux containers mode - switch to Windows containers" } else { Record-Fail "Container runtime mode" "unable to determine container mode" } } catch { Record-Fail "Container runtime mode" "docker info error - $($_.Exception.Message)" } } # -- 4. Image Pull ----------------------------------------------------------- function Test-ImagePull { Write-Section "Images" if (-not (Test-DockerReady "Image pull")) { return } try { # Check if image already exists locally $existing = docker images --format '{{.Repository}}:{{.Tag}}' 2>&1 | Out-String if ($existing -match [regex]::Escape($TestImage)) { $script:ImageAvailable = $true Record-Pass "Image pull" "$TestImage (already present)" return } # Attempt pull $pullOutput = docker pull $TestImage 2>&1 | Out-String if ($LASTEXITCODE -eq 0) { $script:ImageAvailable = $true Record-Pass "Image pull" $TestImage } elseif ($pullOutput -match "timeout|network|unreachable|no match") { Record-Skip "Image pull" "network unavailable or image not found" } else { Record-Fail "Image pull" "pull failed - $($pullOutput.Trim())" } } catch { Record-Skip "Image pull" "pull error - $($_.Exception.Message)" } } # -- 5. Container Lifecycle -------------------------------------------------- function Test-ContainerLifecycle { Write-Section "Lifecycle" if (-not (Test-DockerReady "Container lifecycle" -NeedImage)) { return } $name = "$($script:TestContainerPrefix)-lifecycle" Remove-TestContainer $name try { # Create docker create --name $name $TestImage cmd /c "echo smoketest" 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { Record-Fail "Container lifecycle" "create failed" Remove-TestContainer $name return } docker start $name 2>&1 | Out-Null Start-Sleep -Seconds 2 docker stop $name -t 5 2>&1 | Out-Null docker rm $name 2>&1 | Out-Null if ($LASTEXITCODE -eq 0) { Record-Pass "Container lifecycle" "create, start, stop, remove" } else { Record-Fail "Container lifecycle" "remove failed" } } catch { Record-Fail "Container lifecycle" $_.Exception.Message Remove-TestContainer $name } } # -- 6. Process Isolation Mode ----------------------------------------------- function Test-ProcessIsolation { Write-Section "Isolation" if (-not (Test-DockerReady "Process isolation mode" -NeedImage)) { return } $name = "$($script:TestContainerPrefix)-process" Remove-TestContainer $name try { $output = docker run --rm --isolation=process --name $name $TestImage cmd /c "echo process-ok" 2>&1 | Out-String if ($LASTEXITCODE -eq 0 -and $output -match "process-ok") { Record-Pass "Process isolation mode" "process isolation available" } elseif ($output -match "not supported|not available|HCS") { Record-Skip "Process isolation mode" "not supported on this host (requires Windows Server)" } else { Record-Fail "Process isolation mode" "unexpected result - $($output.Trim())" } } catch { Record-Fail "Process isolation mode" $_.Exception.Message } finally { Remove-TestContainer $name } } # -- 7. Hyper-V Isolation Mode ----------------------------------------------- function Test-HyperVIsolation { if (-not (Test-DockerReady "Hyper-V isolation mode" -NeedImage)) { return } # Check if Hyper-V is installed (client vs server detection) $hypervEnabled = $false try { $hyperv = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -ErrorAction Stop $hypervEnabled = ($hyperv.State -eq "Enabled") } catch { try { $hypervRole = Get-WindowsFeature -Name Hyper-V -ErrorAction Stop $hypervEnabled = $hypervRole.Installed } catch {} } if (-not $hypervEnabled) { Record-Skip "Hyper-V isolation mode" "Hyper-V not installed" return } $name = "$($script:TestContainerPrefix)-hyperv" Remove-TestContainer $name try { $output = docker run --rm --isolation=hyperv --name $name $TestImage cmd /c "echo hyperv-ok" 2>&1 | Out-String if ($LASTEXITCODE -eq 0 -and $output -match "hyperv-ok") { Record-Pass "Hyper-V isolation mode" "Hyper-V isolation available" } elseif ($output -match "not supported|not available|HCS") { Record-Skip "Hyper-V isolation mode" "Hyper-V isolation not available" } else { Record-Fail "Hyper-V isolation mode" "unexpected result - $($output.Trim())" } } catch { Record-Fail "Hyper-V isolation mode" $_.Exception.Message } finally { Remove-TestContainer $name } } # -- 8. NAT Network Exists --------------------------------------------------- function Test-NATNetwork { Write-Section "Networking" if (-not (Test-DockerReady "NAT network exists")) { return } try { $networks = docker network ls --format '{{.Name}}' 2>&1 | Out-String if ($networks -match "(?m)^nat$") { Record-Pass "NAT network exists" "default nat network present" } else { Record-Fail "NAT network exists" "nat network not found" } } catch { Record-Fail "NAT network exists" $_.Exception.Message } } # -- 9. Outbound Connectivity ----------------------------------------------- function Test-OutboundConnectivity { if (-not (Test-DockerReady "Container outbound connectivity" -NeedImage)) { return } $name = "$($script:TestContainerPrefix)-outbound" Remove-TestContainer $name try { $output = docker run --rm --name $name $TestImage cmd /c "ping -n 1 8.8.8.8" 2>&1 | Out-String if ($LASTEXITCODE -eq 0 -and $output -match "Reply from|bytes=") { Record-Pass "Container outbound connectivity" "ping to 8.8.8.8 succeeded" } elseif ($output -match "timed out|unreachable|could not find|General failure") { Record-Fail "Container outbound connectivity" "no outbound connectivity" } else { Record-Fail "Container outbound connectivity" "exit code $LASTEXITCODE" } } catch { Record-Fail "Container outbound connectivity" $_.Exception.Message } finally { Remove-TestContainer $name } } # -- 10. Port Mapping -------------------------------------------------------- function Test-PortMapping { if (-not (Test-DockerReady "Port mapping" -NeedImage)) { return } $name = "$($script:TestContainerPrefix)-portmap" $hostPort = 48199 Remove-TestContainer $name try { docker run -d --rm --name $name -p "${hostPort}:80" $TestImage cmd /c "ping -n 30 127.0.0.1 >nul" 2>&1 | Out-Null Start-Sleep -Seconds 3 $state = docker inspect --format '{{.State.Running}}' $name 2>&1 | Out-String if ($state.Trim() -eq "true") { $portInfo = docker port $name 2>&1 | Out-String if ($portInfo -match "$hostPort" -or $portInfo -match "80/tcp") { Record-Pass "Port mapping" "port $hostPort mapped to container" } else { Record-Pass "Port mapping" "container running with port binding" } } else { Record-Fail "Port mapping" "container did not stay running for port test" } } catch { Record-Fail "Port mapping" $_.Exception.Message } finally { Remove-TestContainer $name } } # -- 11. Volume Mount -------------------------------------------------------- function Test-VolumeMount { Write-Section "Storage" if (-not (Test-DockerReady "Volume mount" -NeedImage)) { return } $name = "$($script:TestContainerPrefix)-volume" $tempDir = Join-Path $env:TEMP "container-smoke-test-vol" Remove-TestContainer $name try { if (Test-Path $tempDir) { Remove-Item $tempDir -Recurse -Force } New-Item -ItemType Directory -Path $tempDir -Force | Out-Null Set-Content -Path (Join-Path $tempDir "testfile.txt") -Value "smoke-test-data" $output = docker run --rm --name $name -v "${tempDir}:C:\testmount" $TestImage cmd /c "type C:\testmount\testfile.txt" 2>&1 | Out-String if ($LASTEXITCODE -eq 0 -and $output -match "smoke-test-data") { Record-Pass "Volume mount" "bind mount read successful" } else { Record-Fail "Volume mount" "bind mount failed - $($output.Trim())" } } catch { Record-Fail "Volume mount" $_.Exception.Message } finally { Remove-TestContainer $name if (Test-Path $tempDir) { Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue } } } # -- 12. Container Logs ------------------------------------------------------ function Test-ContainerLogs { Write-Section "Logging" if (-not (Test-DockerReady "Container logs" -NeedImage)) { return } $name = "$($script:TestContainerPrefix)-logs" Remove-TestContainer $name try { docker run --name $name $TestImage cmd /c "echo log-output-test" 2>&1 | Out-Null Start-Sleep -Seconds 1 $logs = docker logs $name 2>&1 | Out-String if ($logs -match "log-output-test") { Record-Pass "Container logs" "docker logs returned expected output" } else { Record-Fail "Container logs" "docker logs did not return expected output" } } catch { Record-Fail "Container logs" $_.Exception.Message } finally { Remove-TestContainer $name } } # -- 13. Image List ---------------------------------------------------------- function Test-ImageList { if (-not (Test-DockerReady "Image list")) { return } try { $images = docker images --format '{{.Repository}}:{{.Tag}}' 2>&1 $imageCount = ($images | Where-Object { $_ -match "\S" -and $_ -notmatch "ERROR" } | Measure-Object).Count if ($imageCount -gt 0) { Record-Pass "Image list" "$imageCount image(s)" } else { Record-Fail "Image list" "no images found" } } catch { Record-Fail "Image list" $_.Exception.Message } } # -- 14. Event Log ----------------------------------------------------------- function Test-EventLog { Write-Section "Event Log" try { $containerErrors = @() foreach ($logName in @('System', 'Application')) { try { $events = Get-WinEvent -FilterHashtable @{ LogName = $logName Level = 2 # Error StartTime = (Get-Date).AddHours(-24) } -ErrorAction Stop | Where-Object { $_.ProviderName -match "docker|container|Hyper-V" -or $_.Message -match "container|docker|containerd" } if ($events) { $containerErrors += $events } } catch [Exception] { if ($_.Exception.Message -notmatch "No events were found") { Write-Verbose "$logName log query error: $($_.Exception.Message)" } } } $errorCount = ($containerErrors | Measure-Object).Count if ($errorCount -eq 0) { Record-Pass "Event log" "no container errors in last 24h" } else { $latest = $containerErrors | Sort-Object TimeCreated -Descending | Select-Object -First 1 $detail = "$errorCount error(s) in last 24h - latest: $($latest.ProviderName) - $($latest.Message.Substring(0, [Math]::Min(80, $latest.Message.Length)))" Record-Fail "Event log" $detail } } catch { Record-Skip "Event log" "unable to query event logs - $($_.Exception.Message)" } } # ============================================================================ # OUTPUT # ============================================================================ function Write-Header { if ($OutputFormat -eq "tap") { Write-Host "TAP version 13" } else { Write-Host "" Write-Color "Windows Container Smoke Tests" "White" Write-Host "Runtime: $ContainerRuntime" Write-Host "Image: $TestImage" Write-Host "Time: $(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ')" } } function Write-Summary { $duration = [math]::Floor(((Get-Date) - $script:StartTime).TotalSeconds) if ($OutputFormat -eq "tap") { Write-Host "1..$($script:Total)" Write-Host "# pass $($script:Pass)" Write-Host "# fail $($script:Fail)" Write-Host "# skip $($script:Skip)" } else { Write-Host "" $separator = [string]::new([char]0x2500, 40) Write-Color $separator "White" Write-Color "Summary $ContainerRuntime ($TestImage)" "White" $summaryLine = " $($script:Pass) passed $($script:Fail) failed $($script:Skip) skipped (${duration}s)" Write-Host $summaryLine Write-Color $separator "White" if ($script:Fail -eq 0) { Write-Color "All tests passed." "Green" } else { Write-Color "$($script:Fail) test(s) failed." "Red" } } } # ============================================================================ # MAIN # ============================================================================ Write-Header # Run all tests Test-ContainerService Test-DaemonHealth Test-RuntimeMode Test-ImagePull Test-ContainerLifecycle Test-ProcessIsolation Test-HyperVIsolation Test-NATNetwork Test-OutboundConnectivity Test-PortMapping Test-VolumeMount Test-ContainerLogs Test-ImageList Test-EventLog Write-Summary if ($script:Fail -eq 0) { exit 0 } else { exit 1 }