Public/Steps/Invoke-HDTInstallRolesStep.ps1
|
function Invoke-HDTInstallRolesStep { <# .SYNOPSIS Installs Windows Server roles and features. .DESCRIPTION DESIGN 10.2, wrapping Install-WindowsFeature behind IFeatureService so the whole step runs under Pester with no server. - name: Install Roles type: InstallRoles features: [Web-Server, Web-Mgmt-Console, NET-Framework-45-Core] includeManagementTools: true source: "%HDTDeployRoot%\Sources\SxS" A TYPO'D NAME FAILS BEFORE ANYTHING IS INSTALLED. DESIGN 10.2: "a typo'd feature name should fail fast with the list of valid names, not halfway through a server build" - halfway through being the expensive place to find out, because the machine is then neither the old thing nor the new one. Every name is checked against the flat listing first, and only then is anything installed. THE MESSAGE OFFERS NAMES, NOT A CATALOGUE. A real server knows several hundred features, and printing all of them tells an administrator nothing. The refusal lists the ones that share the typo's leading segment - 'Web-Srever' offers every Web- feature - and falls back to the COUNT when nothing looks similar, because a number is honest and a wall of names is not. ALREADY-INSTALLED FEATURES ARE LEFT OUT. Install-WindowsFeature would tolerate being asked again, but this step has to be re-runnable across the reboot a role asks for, and reinstalling what is already there on every leg is how a server build takes an hour longer than it needs to. Removed is NOT installed: it is the .NET 3.5 case, where the payload is gone from the image, and it is exactly the one that needs source:. ONE CALL, NOT ONE PER FEATURE. Install-WindowsFeature takes the whole list and resolves the dependency graph itself; calling it per feature would be slower and would leave a partial state visible between calls. source: RESOLVES THROUGH THE CONTENT PROVIDER when it is relative, so the same sequence works from a share and from standalone media (DESIGN 6). A rooted path is passed through as it stands. A RESTART REQUEST IS A PLAIN RebootRequested, not a re-entering one. Unlike InstallApplications this step installs its whole list in a single call, so there is no position in a list to come back to - the normal restart and resume path is all it needs. .PARAMETER Step A flattened step from Import-HDTSequenceDocument. .PARAMETER Context A New-HDTExecutionContext context. Its Service catalog must carry a Feature service. .OUTPUTS A New-HDTStepResult. Data carries requested, installed and skipped. .EXAMPLE Invoke-HDTInstallRolesStep -Step $step -Context $context #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory = $true)] [ValidateNotNull()] [object] $Step, [Parameter(Mandatory = $true)] [ValidateNotNull()] [object] $Context ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $fail = { param([string] $Message, [System.Collections.IDictionary] $Data) $payload = $Data if ($null -eq $payload) { $payload = [ordered] @{} } Write-HDTLog -Context $Context.Log -Message $Message -Severity Error -Event step.fail ` -Component 'InstallRoles' -Data $payload return (New-HDTStepResult -Status Failed -Message $Message -Data $payload) } # -- what was asked for --------------------------------------------------- $property = $Step.Property $requested = @() if ($null -ne $property -and $property.Contains('features')) { $raw = $property['features'] if ($raw -is [System.Collections.IList] -and -not ($raw -is [string])) { $requested = @(@($raw) | ForEach-Object { ([string] $_).Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } else { $requested = @(@(([string] $raw) -split '[,;\r\n]') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } } if (@($requested).Count -eq 0) { return (& $fail ("step '{0}' declares no features. An InstallRoles step names at least one." -f $Step.Name) ` ([ordered] @{ errorId = 'HDTConfigurationError' })) } try { $featureService = $Context.Service.GetRequired('Feature', 'InstallRoles') } catch { return (& $fail ([string] $_.Exception.Message) $null) } # -- what the OS knows ---------------------------------------------------- try { $known = @($featureService.GetFeature()) } catch { return (& $fail ("the installed features could not be listed: {0}" -f [string] $_.Exception.Message) $null) } $stateByName = @{} foreach ($row in $known) { $stateByName[[string] $row.Name] = [string] $row.InstallState } foreach ($name in $requested) { if ($stateByName.ContainsKey($name)) { continue } # The leading segment is the part a typo usually gets right - 'Web-Srever' # is still recognisably a Web- feature - so it is what the suggestion is # built from. $prefix = @($name -split '-')[0] $candidate = @() if (-not [string]::IsNullOrWhiteSpace($prefix)) { $candidate = [string[]] @(@($known | Where-Object { ([string] $_.Name).StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { [string] $_.Name })) [array]::Sort($candidate, [System.StringComparer]::Ordinal) } if (@($candidate).Count -gt 0) { return (& $fail ("'{0}' is not a feature this operating system knows. Did you mean one of: {1}?" -f $name, (@($candidate) -join ', ')) ([ordered] @{ errorId = 'HDTConfigurationError' feature = $name })) } return (& $fail ("'{0}' is not a feature this operating system knows, and nothing in its {1} feature(s) looks similar. Check the name against Get-WindowsFeature on the target OS." -f $name, @($known).Count) ([ordered] @{ errorId = 'HDTConfigurationError' feature = $name })) } # -- what still has to be installed --------------------------------------- $toInstall = New-Object -TypeName System.Collections.ArrayList $skipped = New-Object -TypeName System.Collections.ArrayList foreach ($name in $requested) { if ([string] $stateByName[$name] -eq 'Installed') { [void] $skipped.Add($name) continue } [void] $toInstall.Add($name) } $data = [ordered] @{ requested = [string[]] @($requested) installed = [string[]] @() skipped = [string[]] @($skipped) } if ($toInstall.Count -eq 0) { $message = 'every requested feature is already installed: {0}.' -f (@($skipped) -join ', ') Write-HDTLog -Context $Context.Log -Message $message -Component 'InstallRoles' -Data $data return (New-HDTStepResult -Status Completed -Message $message -Data $data) } # -- the options ---------------------------------------------------------- $includeManagementTools = $false if ($null -ne $property -and $property.Contains('includeManagementTools')) { $includeManagementTools = [bool] $property['includeManagementTools'] } $source = '' if ($null -ne $property -and $property.Contains('source')) { try { $source = [string] (Get-HDTStepProperty -Step $Step -Name 'source' -Context $Context -Expand -As String) } catch { return (& $fail ([string] $_.Exception.Message) ([ordered] @{ errorId = 'HDTConfigurationError' })) } if ((-not [string]::IsNullOrWhiteSpace($source)) -and (-not [System.IO.Path]::IsPathRooted($source)) -and ($null -ne $Context.Service.Content)) { # DESIGN 6: a relative source is content, and content is the # provider's business. A rooted path is passed through as it stands - # an administrator who typed a drive letter meant that drive letter. try { $source = [string] $Context.Service.Content.ResolveContent($source) } catch { return (& $fail ("the side-by-side source '{0}' could not be resolved: {1}" -f $source, [string] $_.Exception.Message) ([ordered] @{ errorId = 'HDTConfigurationError' })) } } } # -- the install ---------------------------------------------------------- Write-HDTLog -Context $Context.Log -Event 'native.exec' -Component 'InstallRoles' ` -Message ('installing feature(s): {0}' -f (@($toInstall) -join ', ')) ` -Data ([ordered] @{ feature = [string[]] @($toInstall) includeManagementTools = $includeManagementTools source = $source }) try { $result = $featureService.InstallFeature([string[]] @($toInstall), $includeManagementTools, $source) } catch { return (& $fail ("installing {0} failed: {1}" -f (@($toInstall) -join ', '), [string] $_.Exception.Message) $data) } $data['installed'] = [string[]] @($toInstall) $data['exitCode'] = [int] $result.ExitCode if (-not [bool] $result.Success) { return (& $fail ("installing {0} did not succeed (exit code {1}). {2}" -f (@($toInstall) -join ', '), [int] $result.ExitCode, [string] $result.Message).Trim() $data) } if ([bool] $result.RestartNeeded) { $message = 'installed {0} and the server must restart.' -f (@($toInstall) -join ', ') Write-HDTLog -Context $Context.Log -Message $message -Event 'native.exec' ` -Component 'InstallRoles' -Data $data return (New-HDTStepResult -Status RebootRequested -ExitCode ([int] $result.ExitCode) ` -Message $message -Data $data) } $message = 'installed {0}.' -f (@($toInstall) -join ', ') if (@($skipped).Count -gt 0) { $message = '{0} {1} were already installed.' -f $message, (@($skipped) -join ', ') } Write-HDTLog -Context $Context.Log -Message $message -Event 'native.exec' -Component 'InstallRoles' -Data $data return (New-HDTStepResult -Status Completed -ExitCode ([int] $result.ExitCode) -Message $message -Data $data) } |