ps/Modules/Alkami.PowerShell.IIS/Public/Install-AlkamiWebApplication.ps1

435 lines
21 KiB
PowerShell
Raw Permalink Normal View History

2023-05-30 22:51:22 -07:00
Function Install-AlkamiWebApplication {
<#
.SYNOPSIS
Install a Web Application to the appropriate place
.DESCRIPTION
Install a Web Application to the appropriate place.
Will ensure appropriate app pool exists
.PARAMETER WebAppName
[string] The name of the web application.
.PARAMETER SourcePath
[string] The folder that contains the files. Typically a chocolatey path.
.PARAMETER IsClient
[switch] Is this package installed to client?
.PARAMETER IsAdmin
[switch] Is this package installed to admin?
.PARAMETER IsLegacy
[switch] Is this package installed to the legacy site? (typically Default Web Site)
.PARAMETER NoManagedCode
[switch] Is this .net core, or Managed code?
.PARAMETER AppPoolName
[switch] App pool name. Required for .net core apps. That is, if -NoManagedCode is included.
.INPUTS
WebAppName and SourcePath are required.
Requires one of IsClient or IsAdmin or IsLegacy
.OUTPUTS
Various diagnostic information about the install process
.EXAMPLE
Install-AlkamiWebApplication -WebAppName BankService -SourcePath C:\Orb\BankService -IsLegacy
Various diagnostic information about the install process.
#>
## We could define a named set here as the default, but I would rather not. Let it fail if we don't pass in the param flag
[CmdletBinding(DefaultParameterSetName='IsClient')]
[OutputType([System.Collections.ArrayList])]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("IsClient", '', Justification="This is a Switch that goes with a parameter set. Function usage is confusing without this switch.", Scope = "Function")]
Param(
[Parameter(ParameterSetName='IsAdmin',Mandatory=$true, Position=0)]
[Parameter(ParameterSetName='IsClient',Mandatory=$true, Position=0)]
[Parameter(ParameterSetName='IsLegacy',Mandatory=$true, Position=0)]
[Parameter(ParameterSetName='IsDotNetCore',Mandatory=$true, Position=0)]
[string]$WebAppName,
[Parameter(ParameterSetName='IsAdmin',Mandatory=$true, Position=1)]
[Parameter(ParameterSetName='IsClient',Mandatory=$true, Position=1)]
[Parameter(ParameterSetName='IsLegacy',Mandatory=$true, Position=1)]
[Parameter(ParameterSetName='IsDotNetCore',Mandatory=$true, Position=1)]
[string]$SourcePath,
[Parameter(ParameterSetName='IsClient',Mandatory=$true)]
[Parameter(ParameterSetName='IsDotNetCore',Mandatory=$false)]
[switch]$IsClient,
[Parameter(ParameterSetName='IsAdmin',Mandatory=$true)]
[Parameter(ParameterSetName='IsDotNetCore',Mandatory=$false)]
[switch]$IsAdmin,
[Parameter(ParameterSetName='IsLegacy',Mandatory=$true)]
[Parameter(ParameterSetName='IsDotNetCore',Mandatory=$false)]
[switch]$IsLegacy,
[Parameter(ParameterSetName='IsDotNetCore',Mandatory=$true)]
[switch]$NoManagedCode,
[Parameter(ParameterSetName='IsDotNetCore',Mandatory=$true)]
[string]$AppPoolName
)
process {
$logLead = (Get-LogLeadName)
#region Invoke-CommandWithRetry subdefinitions
$icwrSeconds = 3 # 3 seconds between retries
$icwrJitterMin = 0 # 0 milliseconds, so we wait a minimum of $icwrSeconds before retrying
$icwrJitterMax = 5000 # 5000 milliseconds, so 5 seconds
$icwrRetryCount = 5 # 5 retries, between 3s and 8s each, for as many as 40s to complete.
#build an argument splat to save copy-paste readability
$icwrSplat = @{
Seconds = $icwrSeconds
JitterMin = $icwrJitterMin
JitterMax = $icwrJitterMax
MaxRetries = $icwrRetryCount
}
$setEnabledProtocolsScriptBlock= {
param ($sb_WebAppName, $sb_sitePath)
Write-Host "[Install-AlkamiWebApplication] : Invoke-CommandWithRetry : Set-ItemProperty(enabledProtocols)"
(Set-ItemProperty "IIS:\Sites\$sb_sitePath\$sb_WebAppName" -Name enabledProtocols -Value "http,net.tcp") | Out-Null
}
$setApplicationPoolScriptBlock = {
param ($sb_WebAppName, $sb_sitePath, $sb_webAppPoolName)
Write-Host "[Install-AlkamiWebApplication] : Invoke-CommandWithRetry : Set-ItemProperty(applicationPool)"
(Set-ItemProperty -Path "IIS:\Sites\$sb_sitePath\$sb_WebAppName" -Name applicationPool -Value $sb_webAppPoolName) | Out-Null
}
$setPreloadEnabledScriptBlock = {
param ($sb_WebAppName, $sb_sitePath)
Write-Host "[Install-AlkamiWebApplication] : Invoke-CommandWithRetry : Set-ItemProperty(preloadEnabled)"
(Set-ItemProperty "IIS:\Sites\$sb_sitePath\$sb_WebAppName" -Name preloadEnabled -Value "true") | Out-Null
}
# Used to modify .net CLR version to support .net core.
$setNoManagedCodeScriptBlock = {
param ($sb_webAppPoolName)
Write-Host "[Install-AlkamiWebApplication] : Invoke-CommandWithRetry : Set-ItemProperty(managedRuntimeVersion)"
(Set-ItemProperty "IIS:\AppPools\$sb_webAppPoolName" -Name managedRuntimeVersion -Value "") | Out-Null
}
$setAlkamiWebAppPoolConfigurationScriptBlock = {
param ($sb_webAppPoolName)
Write-Host "[Install-AlkamiWebApplication] : Invoke-CommandWithRetry : Set-AlkamiWebAppPoolConfiguration"
(Set-AlkamiWebAppPoolConfiguration $sb_webAppPoolName) | Out-Null
}
$newAlkamiWebAppPoolScriptBlock = {
param ($sb_webAppPoolName)
Write-Host "[Install-AlkamiWebApplication] : Invoke-CommandWithRetry : New-AlkamiWebAppPool"
return New-AlkamiWebAppPool -Name $sb_webAppPoolName
}
$newWebApplicationScriptBlock = {
param ($sb_WebAppName, $sb_sitePath, $sb_appPhysicalPath, $sb_webAppPoolName)
Write-Host "[Install-AlkamiWebApplication] : Invoke-CommandWithRetry : New-WebApplication"
# Use -Force to ensure it gets created correctly
return New-WebApplication -Name $sb_WebAppName -Site $sb_sitePath -PhysicalPath $sb_appPhysicalPath -ApplicationPool $sb_webAppPoolName -Force
}
# value is not consumed currently, so not returned here
$newWebVirtualDirectoryScriptBlock = {
param ($sb_sitePath, $sb_folder, $sb_emptyPath)
Write-Host "[Install-AlkamiWebApplication] : Invoke-CommandWithRetry : New-WebVirtualDirectory"
New-WebVirtualDirectory -Site $sb_sitePath -Name $sb_folder -PhysicalPath $sb_emptyPath
}
# not doing out-null so we can see it print in the logs that it got deleted
$removeWebApplicationScriptBlock = {
param ($sb_WebAppName, $sb_sitePath)
Write-Host "[Install-AlkamiWebApplication] : Invoke-CommandWithRetry : Remove-WebApplication"
Remove-WebApplication $sb_WebAppName -Site $sb_sitePath
}
# not doing out-null so we can see it print in the logs that it got deleted
$RemoveWebAppPoolScriptBlock = {
param ($sb_oldAppPoolName)
Write-Host "[Install-AlkamiWebApplication] : Invoke-CommandWithRetry : Remove-WebAppPool"
Remove-WebAppPool -Name $sb_oldAppPoolName
}
#endregion Invoke-CommandWithRetry subdefinitions
if (!(Test-Path $SourcePath)) {
Write-Warning "$logLead : The path $SourcePath was not found, application $WebAppName will not be created"
return
}
## validate path points to the choco folder unless it is a legacy application
if ($IsLegacy -and !($sourcePath.ToLower().Contains((Get-OrbPath).ToLower()))) {
Write-Warning "$logLead : $WebAppName is marked IsLegacy but $SourcePath doesn't seem to be in the expected ORB folder. This will be an Alkami.IOC issue."
} elseif (!$IsLegacy) {
$isPackagePathValidated = (Test-PathIsInApprovedPackageLocation $sourcePath)
if (!($isPackagePathValidated)) {
Write-Warning "$logLead : The source location at [$sourcePath] doesn't match the expected criteria of non-IsLegacy installs"
Write-Warning "$logLead : The expectation is that non-IsLegacy installs will be constrained to the Chocolatey lib folder install path"
#throw "can not finish install - bad path provided"
}
}
$siteList = @()
$webAppPoolName = $WebAppName.Replace('\','_').Replace('/','_')
$parentAppName = $webAppPoolName
$newRelicAppName = $webAppPoolName
$forceAppPoolsOnClientToUseWebClient = $false
if ($IsLegacy) {
## Use of this function ensures that the Default Web Site exists
$defaultWebsite = (Get-DefaultWebsite)
if ($null -eq $defaultWebsite) {
$defaultWebsite = Invoke-CommandWithRetry -ScriptBlock { return (New-DefaultWebsite) } @icwrSplat
}
## Ensure we add this to the list of sites we are going to be installing to
$siteList += $defaultWebsite
$parentAppName = $defaultWebsite.Name
} else {
## Test to make sure this is the valid site name. We can do this by looking for the site, if it doesn't exist, look for the path
## C:\Orb\$parentAppName and then find any sites for that.
## By process of elimination, if you weren't in parameter set IsAdmin or IsLegacy, you must be in IsClient.
$parentAppName = 'WebClient'
if ($IsAdmin) {
$parentAppName = 'WebClientAdmin'
}
# SRE-16828 - If you want to force apps to be under WebClient or WebClientAdmin this is the line to change
$forceAppPoolsOnClientToUseWebClient = $true
$targetFolderPath = (Join-Path (Get-OrbPath) $parentAppName)
$siteList = (Get-IISSitesByPath $targetFolderPath)
}
if (Test-IsCollectionNullOrEmpty $siteList) {
Write-Warning "$logLead : Can't find any sites to bind to for WebAppName: [$WebAppName] and ParentAppName: [$parentAppName]"
throw "$logLead : Can't find any sites to bind to for WebAppName: [$WebAppName] and ParentAppName: [$parentAppName]"
} else {
Write-Host "$logLead : Found these sites [`"$(($siteList).Name -join '`",`"')`"]"
}
# SRE-16828 - If you want to force apps to be under WebClient or WebClientAdmin this is the line to change
if ($forceAppPoolsOnClientToUseWebClient) {
$webAppPoolName = $ParentAppName
}
# NoManagedCode (that is, .net core apps) can't run on the same app pool as any other app, as opposed to ManagedCode that can
if($NoManagedCode){
$webAppPoolName = $AppPoolName
}
## Get the web app pool to make sure that's configured correctly before we start looping sites
$appPoolPath = (Join-Path "IIS:\AppPools" $webAppPoolName)
$appPool = (Get-Item $appPoolPath -ErrorAction SilentlyContinue)
## If we couldn't find the application pool for this app, need to create one to go with this application
## Else we need to ensure the app pool is properly configured
if ($null -eq $appPool) {
$icwrArguments = @{
ScriptBlock = $newAlkamiWebAppPoolScriptBlock
Arguments = @($webAppPoolName)
}
$appPool = Invoke-CommandWithRetry @icwrArguments @icwrSplat
} else {
if (!$forceAppPoolsOnClientToUseWebClient) {
$icwrArguments = @{
ScriptBlock = $setAlkamiWebAppPoolConfigurationScriptBlock
Arguments = @($webAppPoolName)
}
Invoke-CommandWithRetry @icwrArguments @icwrSplat
}
}
# Set managedRuntimeVersion to noManagedCode for .net core.
# This *ALWAYS* has to go after Set-WebPoolConfig
if($NoManagedCode){
Write-Host "$logLead : Setting Application Attribute .NET CLR Version to '' (noManagedCode) for AppPools\$WebAppPoolName"
$icwrArguments = @{
ScriptBlock = $setNoManagedCodeScriptBlock
Arguments = @($WebAppPoolName)
}
Invoke-CommandWithRetry @icwrArguments @icwrSplat
}
$oldAppPoolNames = @()
foreach ($site in $siteList) {
$sitePath = $site.Name
$virtualFolders = [System.Collections.ArrayList]($WebAppName.Split(@('/','\')))
## Foreach virtual path in our segmented list, we need to ensure we have a folder on disk to point to
## By having an empty folder when one doesn't exist, there can't be much data leakage, as there's no child folders
## Still a risk of ..\ links in whatever might get misconfigured, so we just need to be diligent
if ($virtualFolders.Count -gt 1) {
$depthCount = $virtualFolders.Count - 1
$WebAppName = $virtualFolders[-1]
$emptyPath = (Join-Path (Get-OrbPath) "Empty")
if (!(Test-Path $emptyPath)) {
(New-Item -Path $emptyPath -ItemType Directory -Force) | Out-Null
$emptyPathReadmePath = (Join-Path $emptyPath "readme.txt")
if(!(Test-Path $emptyPathReadmePath)) {
Set-Content -Path $emptyPathReadmePath -Value @"
This folder should remain empty except this file.
If this folder has directory browsing turned on, it should be turned off.
Please report any instance of this directory browsing turned on to security@alkamitech.com along with the URL where you found it.
Thank you for your assistance.
"@
}
}
for ($i = 0; $i -lt $depthCount; $i++) {
$folder = $virtualFolders[$i]
## Recursively create subfolders under the site.
## See https://stackoverflow.com/a/21187565/109749
if ($null -eq (Get-WebVirtualDirectory -Site $sitePath -Name $folder)) {
$icwrArguments = @{
ScriptBlock = $newWebVirtualDirectoryScriptBlock
Arguments = @($sitePath, $folder, $emptyPath)
}
Invoke-CommandWithRetry @icwrArguments @icwrSplat
}
## Keep appending the folder we just created to the existing $sitePath
## This gets injected below in the middle of the string
$sitePath = $sitePath + "\" + $folder
}
}
$appPhysicalPath = $SourcePath
$appPhysicalPathContent = (Join-Path $appPhysicalPath Content)
## Try to find the folder at c:\programdata\choco\lib\myapp\Content\App which should have views, images, etc in it.
if (Test-Path $appPhysicalPathContent) {
$appPhysicalPathContentTest = (Join-Path $appPhysicalPathContent 'App')
if (Test-Path $appPhysicalPathContentTest) {
$appPhysicalPath = $appPhysicalPathContentTest
} else {
## Try to find the folder at c:\programdata\choco\lib\myapp\Content\* which has files in it, guessing that's what we want instead, even though it breaks the pattern
if (Test-Path $appPhysicalPathContent) {
$appPhysicalPathContentTest = (Get-ChildItem $appPhysicalPathContent) | Where-Object { $_.PSIsContainer } | Select-Object -First 1
if (($null -ne $appPhysicalPathContentTest) -and ($null -ne (Get-ChildItem $appPhysicalPathContentTest))) {
$appPhysicalPath = $appPhysicalPathContentTest
} elseif ($null -ne (Get-ChildItem $appPhysicalPathContent)) {
## Sigh, just use the c:\programdata\choco\lib\myapp\Content cos there's stuff in there, who knows anymore, life is bleak
$appPhysicalPath = $appPhysicalPathContent
}
}
}
}
$existingApp = Get-WebApplication $WebAppName -Site $sitePath
if ($null -ne $existingApp) {
## Ensure the path matches the expected location in case it has moved, see SRE-12829
if ($existingApp.PhysicalPath -ne $appPhysicalPath) {
$icwrArguments = @{
ScriptBlock = $removeWebApplicationScriptBlock
Arguments = @($WebAppName, $sitePath)
}
Invoke-CommandWithRetry @icwrArguments @icwrSplat
$existingApp = $null ## force it to null so the next value kicks in
}
}
$existingApp = Get-WebApplication $WebAppName -Site $sitePath
if ($null -eq $existingApp) {
Write-Host "Creating [$WebAppName] for [$sitePath]"
$icwrArguments = @{
ScriptBlock = $newWebApplicationScriptBlock
Arguments = @($WebAppName, $sitePath, $appPhysicalPath, $webAppPoolName)
}
$existingApp = Invoke-CommandWithRetry @icwrArguments @icwrSplat
} else {
Write-Host "Found existing Web Application for [$WebAppName] on [$sitePath]"
}
## If the name of the application pool matches the one that's on the existing webapplication then groovy
## Otherwise we have to re-point the web-app to use the app pool with the same name.
## We already made sure we have such an app pool that matches the name, so we just need to do the attaching.
if ( $existingApp.applicationPool -ne $webAppPoolName) {
## We have a valid app pool that uses the same name as the web app, and we are here because they didn't match.
## Random bug catch, eep
if (![string]::IsNullOrWhiteSpace($existingApp.applicationPool)) {
## Store the old name so we can delete any unused app pools later
Write-Verbose "Adding existing application pool that doesn't match the expected name to the delete-list $($existingApp.applicationPool)"
$oldAppPoolNames += $existingApp.applicationPool
}
## Let's assign the new app pool to the web application
$icwrArguments = @{
ScriptBlock = $setApplicationPoolScriptBlock
Arguments = @($WebAppName, $sitePath, $webAppPoolName)
}
Invoke-CommandWithRetry @icwrArguments @icwrSplat
}
if ($IsLegacy) {
Write-Host "$logLead : Setting Application Attribute EnabledProtocols [http,net.tcp] for $sitePath\$WebAppName"
$icwrArguments = @{
ScriptBlock = $setEnabledProtocolsScriptBlock
Arguments = @($WebAppName, $sitePath)
}
Invoke-CommandWithRetry @icwrArguments @icwrSplat
}
Write-Host "$logLead : Setting Application Attribute preloadEnabled to True for $sitePath\$WebAppName"
$icwrArguments = @{
ScriptBlock = $setPreloadEnabledScriptBlock
Arguments = @($WebAppName, $sitePath)
}
Invoke-CommandWithRetry @icwrArguments @icwrSplat
}
$environmentUserPrefix = (Get-AppSetting "Environment.Name" -SuppressWarnings)
if ($null -ne $environmentUserPrefix) {
## Find the config file path. This is a web app, so we default to web.config as the file name and in the same root folder as the content
## If we had a non-web-app we might have to look for another file but this is Install-AlkamiWebApplication.
$ConfigFilePath = (Join-Path $appPhysicalPath "web.config")
if (!(Test-Path $ConfigFilePath)) {
Write-Warning "Can't find a web.config under the folder [$ConfigFilePath]"
}
else {
## ensure NR name set in web.config with Environment.Name + AppName from config
## This function will get the application label for us so we don't have to manually fetch it
Set-NewRelicAppNameConfigFileValue $environmentUserPrefix $ConfigFilePath $newRelicAppName
}
}
foreach($oldAppPoolName in $oldAppPoolNames) {
## The same "wrong" app pool could have been used on other apps so only remove them if there are no referenced apps for this pool
$appPoolPath = (Join-Path "IIS:\AppPools" $oldAppPoolName)
$existingApp = (Get-Item $appPoolPath -ErrorAction SilentlyContinue)
if ($null -ne $existingApp) {
$existingAppApplicationCount = (Get-IISAppPoolChildApplicationsCount $oldAppPoolName)
## The count of that outdated application pool is 0, indicating that apppool isn't in use. Delete it.
if ($existingAppApplicationCount -eq 0) {
Write-Verbose "$logLead : Removing identified and unused Web AppPool [$oldAppPoolName]"
$icwrArguments = @{
ScriptBlock = $RemoveWebAppPoolScriptBlock
Arguments = @($oldAppPoolName)
}
Invoke-CommandWithRetry @icwrArguments @icwrSplat
} else {
Write-Verbose "$logLead : Web AppPool [$oldAppPoolName] still has things attached to it. Not deleting."
}
}
}
}
}