Private/Resolve-InPlaceUpgradeReadiness.ps1
|
function Resolve-InPlaceUpgradeReadiness { <# .SYNOPSIS Turn collected facts into a readiness decision (pure, no Azure calls) .DESCRIPTION Every rule the preflight applies lives here, in one place, testable with fabricated facts. Checks are evaluated cheapest first and never stop early, so the operator sees the full list of problems in one run instead of one per run. A single Fail makes the VM NotEligible; Warn is informational. A guest that already runs the target build is AlreadyAtTarget regardless of any other finding, because there is nothing to do. #> [CmdletBinding()] [OutputType('AzureInPlaceUpgrade.Readiness')] param( [Parameter(Mandatory)]$Target, [Parameter(Mandatory)]$ArmFacts, [Parameter()][AllowNull()]$GuestFacts, [Parameter()][AllowEmptyString()][string]$GuestError, [Parameter()][ValidateRange(1, 1000)][int]$MinimumFreeSpaceGB = 30, [Parameter()][AllowNull()]$Media ) $checks = [System.Collections.Generic.List[object]]::new() function Add-Check { param([string]$Name, [ValidateSet('Pass', 'Warn', 'Fail', 'Skip')][string]$Result, [string]$Detail) $checks.Add([pscustomobject]@{ PSTypeName = 'AzureInPlaceUpgrade.Check'; Name = $Name; Result = $Result; Detail = $Detail }) } $alreadyAtTarget = $false $source = $null $engine = $null # --- control plane ------------------------------------------------------------------------- if ($ArmFacts.PowerState -eq 'running') { Add-Check 'PowerState' 'Pass' 'VM is running.' } else { Add-Check 'PowerState' 'Fail' "VM power state is '$($ArmFacts.PowerState)'; it must be running." } if ($ArmFacts.OsType -ieq 'Windows') { Add-Check 'OsType' 'Pass' 'Windows OS disk.' } else { Add-Check 'OsType' 'Fail' "OS type is '$($ArmFacts.OsType)', not Windows." } if ($ArmFacts.OsDiskManaged) { Add-Check 'OsDiskManaged' 'Pass' 'Managed OS disk; a snapshot is possible.' } else { Add-Check 'OsDiskManaged' 'Fail' 'OS disk is unmanaged; migrate to managed disks first.' } if ($ArmFacts.OsDiskEphemeral) { Add-Check 'OsDiskEphemeral' 'Fail' 'Ephemeral OS disk; it cannot be snapshotted and resets on reallocation.' } else { Add-Check 'OsDiskEphemeral' 'Pass' 'Persistent OS disk.' } if ($ArmFacts.SecurityType -and $ArmFacts.SecurityType -in @('TrustedLaunch', 'ConfidentialVM')) { Add-Check 'SecurityType' 'Warn' "$($ArmFacts.SecurityType) VM; the media-disk path is not lab-verified for this security type yet." } else { Add-Check 'SecurityType' 'Pass' 'Standard security type.' } # --- guest --------------------------------------------------------------------------------- if ($ArmFacts.PowerState -ne 'running') { Add-Check 'GuestReachable' 'Skip' 'Guest checks skipped because the VM is not running.' } elseif ($null -eq $GuestFacts) { $detail = if ($GuestError) { "Guest probe failed: $GuestError" } else { 'Guest probe returned nothing.' } Add-Check 'GuestReachable' 'Fail' "$detail Verify the Azure Guest Agent and Run Command on this VM." } else { Add-Check 'GuestReachable' 'Pass' "Guest reports $($GuestFacts.ProductName) build $($GuestFacts.Build)." if ($GuestFacts.Build -eq $Target.TargetBuild) { $alreadyAtTarget = $true Add-Check 'SourceBuild' 'Skip' "Guest is already on build $($GuestFacts.Build) ($($Target.DisplayName)); nothing to do." } else { $source = $Target.Sources | Where-Object { $_.Build -eq $GuestFacts.Build } | Select-Object -First 1 if ($source) { $evidence = if ($source.Verified) { 'lab-verified in this project' } else { 'documented by Microsoft, not yet verified in this project' } Add-Check 'SourceBuild' 'Pass' "$($source.Name) (build $($source.Build)) to $($Target.DisplayName): $evidence." } else { $allowed = ($Target.Sources | ForEach-Object { "$($_.Name) ($($_.Build))" }) -join ', ' Add-Check 'SourceBuild' 'Fail' "Build $($GuestFacts.Build) is not a supported source for $($Target.Name). Supported: $allowed." } } # Server Core installs may report the edition with a Cor/Core suffix; the edition itself is # the same, the installation type is checked separately. $edition = [string]$GuestFacts.EditionId $normalizedEdition = $edition -replace '(Cor|Core)$', '' if ($Target.Editions -contains $normalizedEdition) { Add-Check 'Edition' 'Pass' "Edition $edition." } else { $hint = if ($edition -match 'Azure') { ' Windows Server Azure Edition is out of scope for in-place upgrades.' } else { '' } Add-Check 'Edition' 'Fail' "Edition '$edition' is not eligible; allowed: $($Target.Editions -join ', ').$hint" } if ($Target.InstallationTypes -contains $GuestFacts.InstallationType) { Add-Check 'InstallationType' 'Pass' "Installation type '$($GuestFacts.InstallationType)'." } else { Add-Check 'InstallationType' 'Fail' "Installation type '$($GuestFacts.InstallationType)' is not eligible; allowed: $($Target.InstallationTypes -join ', ')." } if ($GuestFacts.Architecture -ieq $Target.Architecture) { Add-Check 'Architecture' 'Pass' "$($GuestFacts.Architecture)." } else { Add-Check 'Architecture' 'Fail' "Architecture '$($GuestFacts.Architecture)'; the upgrade media is $($Target.Architecture)." } if ($GuestFacts.Language -ieq $Target.Language) { Add-Check 'Language' 'Pass' "OS language $($GuestFacts.Language)." } else { Add-Check 'Language' 'Fail' "OS language is '$($GuestFacts.Language)'; the upgrade media is $($Target.Language) only. Set the system language to $($Target.Language) or redeploy." } if ($GuestFacts.FreeGB -ge $MinimumFreeSpaceGB) { Add-Check 'FreeSpace' 'Pass' "$($GuestFacts.FreeGB) GB free on C: (minimum $MinimumFreeSpaceGB GB)." } else { Add-Check 'FreeSpace' 'Fail' "Only $($GuestFacts.FreeGB) GB free on C:; $MinimumFreeSpaceGB GB required. Expand the OS disk or clean up." } if ($GuestFacts.PendingReboot) { Add-Check 'PendingReboot' 'Fail' 'A reboot is pending in the guest; Setup would abort. Reboot first.' } else { Add-Check 'PendingReboot' 'Pass' 'No pending reboot.' } if ($GuestFacts.DomainRole -in @(4, 5)) { Add-Check 'DomainController' 'Fail' 'This is a domain controller. Microsoft advises against in-place upgrades of DCs; promote a new one instead.' } else { Add-Check 'DomainController' 'Pass' 'Not a domain controller.' } if ($GuestFacts.ClusterServicePresent) { Add-Check 'Cluster' 'Fail' 'Failover Clustering is installed. Use Cluster-Aware Updating or a cluster OS rolling upgrade.' } else { Add-Check 'Cluster' 'Pass' 'Not a cluster node.' } if ($GuestFacts.SetupRunning) { Add-Check 'SetupRunning' 'Fail' 'Windows Setup is already running in the guest.' } else { Add-Check 'SetupRunning' 'Pass' 'No Setup process running.' } # SoftwareLicensingProduct.LicenseStatus: 0 Unlicensed, 1 Licensed, 2 OOB grace, 3 OOT grace, # 4 non-genuine grace, 5 Notification, 6 extended grace. Only 1 is "activated". $channel = [string]$GuestFacts.ActivationChannel $licenseStatus = [int]$GuestFacts.LicenseStatus if ($channel -like 'Volume*') { if ($licenseStatus -eq 1) { Add-Check 'Activation' 'Pass' "Activation channel $channel, Windows is activated." } else { Add-Check 'Activation' 'Warn' "Activation channel $channel, but Windows is not activated yet (license status $licenseStatus). A freshly provisioned VM activates against Azure KMS within minutes; otherwise check outbound TCP 1688 to kms.core.windows.net." } } elseif ([string]::IsNullOrWhiteSpace($channel) -or $channel -ieq 'unknown') { Add-Check 'Activation' 'Warn' 'Activation channel could not be read; the upgrade media requires volume-license (KMS) activation.' } else { Add-Check 'Activation' 'Warn' "Activation channel is '$channel'; the upgrade media requires volume-license (KMS) activation. Install the KMS client setup key first." } } # --- media and engine ---------------------------------------------------------------------- $mediaOk = $null if ($null -ne $Media) { $mediaEngine = Get-PropertyOrDefault -InputObject $Target.Engines -Name 'MediaDisk' $imageName = if ($mediaEngine) { "$($mediaEngine.publisher)/$($mediaEngine.offer)/$($mediaEngine.sku)" } else { 'upgrade media' } if ($Media.Error) { $mediaOk = $false Add-Check 'MediaImage' 'Fail' "Could not look up $imageName in '$($ArmFacts.Location)': $($Media.Error)" } elseif ($Media.Version) { $mediaOk = $true Add-Check 'MediaImage' 'Pass' "$imageName version $($Media.Version) is available in '$($ArmFacts.Location)'." } else { $mediaOk = $false Add-Check 'MediaImage' 'Fail' "$imageName is not available in region '$($ArmFacts.Location)'." } } if ($source -and -not $alreadyAtTarget) { $candidates = @($source.Engines | Where-Object { $script:ImplementedEngines -contains $_ }) if ($candidates.Count -eq 0) { Add-Check 'Engine' 'Fail' "No implemented engine for this path (listed: $($source.Engines -join ', ')); implemented: $($script:ImplementedEngines -join ', ')." } elseif ($candidates -contains 'MediaDisk' -and $mediaOk -ne $false) { $engine = 'MediaDisk' if ($null -eq $mediaOk) { Add-Check 'Engine' 'Warn' 'MediaDisk selected; upgrade image availability was not checked.' } else { Add-Check 'Engine' 'Pass' 'MediaDisk engine selected.' } } elseif ($candidates -contains 'FeatureUpdate') { $engine = 'FeatureUpdate' Add-Check 'Engine' 'Warn' 'Upgrade media unavailable; FeatureUpdate selected. Needs Windows Update reachability and the required cumulative update (experimental).' } else { Add-Check 'Engine' 'Fail' 'MediaDisk is the only engine for this path and its upgrade image is unavailable.' } } # --- decision ------------------------------------------------------------------------------ $failures = @($checks | Where-Object { $_.Result -eq 'Fail' }).Count $warnings = @($checks | Where-Object { $_.Result -eq 'Warn' }).Count $decision = if ($alreadyAtTarget) { 'AlreadyAtTarget' } elseif ($failures -gt 0) { 'NotEligible' } else { 'Eligible' } [pscustomobject]@{ PSTypeName = 'AzureInPlaceUpgrade.Readiness' VMName = $ArmFacts.VMName ResourceGroupName = $ArmFacts.ResourceGroupName Location = $ArmFacts.Location Target = $Target.Name TargetBuild = $Target.TargetBuild SourceBuild = if ($GuestFacts) { $GuestFacts.Build } else { $null } SourceName = if ($source) { $source.Name } else { $null } Edition = if ($GuestFacts) { $GuestFacts.EditionId } else { $null } InstallationType = if ($GuestFacts) { $GuestFacts.InstallationType } else { $null } Language = if ($GuestFacts) { $GuestFacts.Language } else { $null } Engine = $engine MediaImageVersion = if ($Media) { $Media.Version } else { $null } Decision = $decision Eligible = ($decision -eq 'Eligible') Failures = $failures Warnings = $warnings Checks = $checks.ToArray() EvaluatedAt = (Get-Date).ToUniversalTime() } } |