部署自動化 - web.config PowerShell 更新函式庫
1 |
有些 IIS 設定要靠改 web.config 完成,有些環境較一致,可以預先寫好覆寫即可,但如果更新的 web.config 有多個且內容不同,最無腦的做法是寫成操作指示請相關人員執行:「打開 web.conf,找到 system.webSesrver/httpProtocol/customHeaders 節點(若沒有請新增),新增一個 <add name="X-Frame-Options" value="NONE" />」。人工作業的最大好處是可以隨機應變,靠簡單描述完成複雜操作,且能處理非預期情境。
但凡是人工作業,就免不了眼花手滑腦抽風的可能,執行品質會因操作者素質(菜鳥 vs 老司機)、身心狀態(加班到快往生或剛被老闆洗完臉)而異;遇上批次大量執行的場合,人工作業效率較差會是另一項問題。先前研究過 用 PowerShell 實現 IIS 安裝、網站設定自動化,現在來試試把「web.config 修改」這項手工活也自動化。
我的設計構想如下:
- 為簡化設定語法並保留更大客製彈性,我不打算用 XML-Document-Transform 形式定義異動項目,而是計劃提供一些方便的函式簡化,讓開發人員或 DevOps 工程師用寫程式的方式完成 web.config 修改。(我總覺得寫成一行一行指令還比像咒語般的 XML 直覺好讀多了,也算個人偏好)
- 主動保存修改前 web.config 備份以及修改後結果,並側錄執行過程(使用 Start-Transcript),方便稽核與問題追查。
- 借用 git diff 產生異動對照表,可經過人工複核再更新,多一層保險機制(但也可以省略)
- 由站台名稱及網站應用程式名稱找到 web.config 位置,也支援子目錄 web.config 更新
- 以 XPath 指定對象,提供設定 Attribute、附加 Attribute 值、刪除 Element 之快速指令
- 允許存取 XmlElement,可直接操作 XML 物件,以滿足更複雜的應用情境
先來看它用起來像什麼樣子,我寫了一個測試,分別修改 Default Web Site 的 ConfLab 網站應用程式 web.config 及其子目錄 SubFolder/web.config。函式庫放在 WebConfLib.ps1,更新腳本先引用它,用 SetConfPath 指定站台及網站應用程式名稱,此時會開一個 BAK-yyyyMMddHHmmss 資料夾備份原有 web.config 及執行 Log。測試涵蓋了在現有 Attribute 值附加內容、更新 Attrubte 值、移除 XmlElement、新增 XmlElement 並設定 Attribute、依現有 Attribute 值決定新值、直接指定 InnerXML 等應用情境,最後呼叫 CommitConfChanges 確認修改內容及更新。
. ..\WebConfLib.ps1
# 測試前置作業
function PrepareTest() {
$appPath = (Get-WebApplication -Site 'Default Web Site' -Name 'ConfLab').PhysicalPath
Copy-Item "$appPath\web.sample.config" "$appPath\web.config" -Force
Copy-Item "$appPath\SubFolder\web.sample.config" "$appPath\SubFolder\web.config" -Force
}
PrepareTest
# 測試一
SetConfPath 'Default Web Site' 'ConfLab'
# 在現有 Attribute 值附加內容
AppendXmlElementAttrs 'appSettings/add[@key="ClientIps"]' @{ value = ";192.168.1.1" }
# 更新 Attrubte 值
SetXmlElementAttrs 'appSettings/add[@key="FOO"]' @{ value = "BAR" }
# 移除 XmlElement
RemoveXmlElement 'appSettings/add[@key="Garbage"]'
# 新增 XmlElement 並設定 Attribute
SetXmlElementAttrs 'system.webServer/httpProtocol/customHeaders/add[@name="X-Frame-Options"]' @{ value = 'SAMEORIGIN' }
# 新增 XmlElement 並設定多項 Attribute
SetXmlElementAttrs 'system.web/httpCookies' @{ requireSSL = 'false'; httpOnlyCookies = 'true' }
CommitConfChanges
# 測試二 進階應用
SetConfPath 'Default Web Site' 'ConfLab' 'SubFolder'
# 依現有 Attribute 值決定新值
$newNum = [int]::Parse((GetXmlElementAttr 'appSettings/add[@key="Number"]' 'value')) + 1
SetXmlElementAttrs 'appSettings/add[@key="Number"]' @{ value = $newNum }
# 執行 XPATH 查詢,依現況決定更新方式
[System.Xml.XmlElement]$node = GetXmlNode 'system.web'
if ($node -and $node.SelectSingleNode('compilation[@debug="true"]')) {
# 置換 XML
SetXmlElementInnerXml 'system.web/authorization' @"
<allow users="*" />
"@
}
CommitConfChanges -Force # 不需確認直接更新
執行結果:
補充說明:
- 指定站台及網站應用程式名稱,系統找到 web.config 所在位置 (因為有呼叫 Get-WebApplication,需以管理者身分執行)
- 開啟前先備份原有 web.config (若放棄更新會刪除備份資料夾)
- 啟動轉譯將執行過程寫成 Log
- 顯示更新細節
- 使用 git diff 對照修改處
- 確認後才更新
- 進階應用:讀取現值計算後決定新值
- 直接換掉整個 XML 內容
這些函式已能符合我絕大部分的 web.config 修改需求,還有不足的地方就等未來再改良了。
關於 git.exe,我採用較彈性的做法,若伺服器有安裝 Git,用 where git 找到的位置,否則會尋找 WebConfLib.ps1 同目錄下的可攜版 PortableGit,若都沒有就停用差異對照功能。
附上 WebConfLib.ps1 雛型給大家參考:
$ErrorActionPreference = 'STOP'
# 全域變數
[string]$configPath = ''
[string]$bakPath = ''
[System.Xml.XmlDocument]$xmlDoc = New-Object System.Xml.XmlDocument
# 自動偵測 git.exe 路徑
$gitPath = ''
. where.exe git | ForEach-Object {
if ($_.EndsWith('git.exe')) { $gitPath = $_ }
}
if ([string]::IsNullOrEmpty($gitPath) -and (Test-Path $PSScriptRoot\PortableGit\bin\git.exe)) {
$gitPath = Resolve-Path $PSScriptRoot\PortableGit\bin\git.exe
}
function SetConfPath {
param (
[Parameter(Mandatory = $true)][string]$site,
[Parameter(Mandatory = $true)][string]$appName,
[string]$folder = ''
)
$app = Get-WebApplication -Site $site -Name $appName
if (!$app) { throw "$site/$appName not found" }
$configPath = [IO.Path]::Combine($app.PhysicalPath, $folder, 'web.config')
Write-Host "### 設定檔路徑 = $configPath" -ForegroundColor Yellow
# 依時間產生備份資料夾路徑
$bakPath = Join-Path (Get-Location).Path ('BAK-' + (Get-Date -Format 'yyyyMMddHHmmss'))
# 備份原檔
[IO.Directory]::CreateDirectory($bakPath) | Out-Null
if (Test-Path $configPath) {
Write-Host "備份舊版 $configPath" -ForegroundColor Cyan
Copy-Item $configPath (Join-Path $bakPath 'web.orig.config')
}
else {
Write-Host "原本無設定檔" -ForegroundColor Cyan
'' | Out-File (Join-Path $bakPath 'web.orig.config')
# 產生空白 web.config
@"
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
</configuration>
"@ | Out-File $configPath -Encoding utf8
}
$xmlDoc.Load($configPath)
Set-Variable -Name configPath -Value $configPath -Scope 1
Set-Variable -Name bakPath -Value $bakPath -Scope 1
Start-Transcript -Path (Join-Path $bakPath "Update.log")
}
function GetXmlNode([string]$xpath, [bool]$autoCreate = $false) {
$node = $xmlDoc.DocumentElement.SelectSingleNode($xpath)
if ($node) { return $node }
if (!$autoCreate) { return $null }
$currNode = $xmlDoc.DocumentElement
$xpath.Split('/') | ForEach-Object {
$elName = $_
$m = [System.Text.RegularExpressions.Regex]::Match($elName, "[a-zA-z]+\[@(?<n>.+)=`"(?<v>.+)`"]")
if ($m.Success) {
$keyAttrName = $m.Groups['n'].Value
$keyAttrVal = $m.Groups['v'].Value.Trim("'", "`"")
$elName = $_.Split('[')[0]
}
if (!$currNode.SelectSingleNode($_)) {
$node = $xmlDoc.CreateElement($elName)
if ($keyAttrName) { $node.SetAttribute($keyAttrName, $keyAttrVal) }
$currNode = $currNode.AppendChild($node)
}
else {
$currNode = $currNode.SelectSingleNode($_)
}
}
return $currNode
}
function SetXmlElementAttrs([string]$xpath, [Hashtable]$attrs) {
$node = (GetXmlNode $xpath $true)
Write-Host "* 設定屬性:$xpath " -ForegroundColor Yellow
$attrs.Keys | ForEach-Object {
Write-Host " $_ = $($attrs[$_])" -ForegroundColor White
$node.SetAttribute([string]$_, [string]$attrs[$_])
}
}
function GetXmlElementAttr([string]$xpath, [string]$attrName) {
$node = GetXmlNode $xpath
if (!$node) { return '' }
return $node.GetAttribute($attrName)
}
function SetXmlElementInnerXml([string]$xpath, [string]$xml) {
$node = (GetXmlNode $xpath $true)
Write-Host "* 設定XML:$xpath" -ForegroundColor Yellow
Write-Host " $xml" -ForegroundColor White
$node.InnerXML = $xml
}
function AppendXmlElementAttrs([string]$xpath, [Hashtable]$attrs) {
$node = (GetXmlNode $xpath $true)
Write-Host "* 附加屬性 $xpath" -ForegroundColor Yellow
$attrs.Keys | ForEach-Object {
$old = $node.GetAttribute($_)
$new = $old + [string]$attrs[$_]
Write-Host " $old => $new" -ForegroundColor White
$node.SetAttribute([string]$_, $new)
}
}
function RemoveXmlElement([string]$xpath) {
$node = GetXmlNode $xpath
if ($node) {
Write-Host "移除 $xpath" -ForegroundColor Yellow
$node.ParentNode.RemoveChild($node) | Out-Null
}
}
function ShowDiff([string]$origFile, [string]$newFile) {
if ([string]::IsNullOrEmpty($gitPath)) {
Write-Host '系統未安裝 Git 或 PortableGit,無法提供修改確認' -ForegroundColor Red
return;
}
"Git [$gitPath]"
Write-Host "本次異動對照如下,請確認:" -ForegroundColor Yellow
$header = $true
. $gitPath diff --no-index $origFile $newFile 2>&1 | ForEach-Object {
if ($header) {
if ($_.StartsWith('@@ ')) { $header = $false }
}
else {
$color = 'Cyan'
if ($_.StartsWith('+')) {
$color = 'Green'
}
elseif ($_.StartsWith('-')) {
$color = 'Red'
}
Write-Host $_ -ForegroundColor $color
}
}
}
function CommitConfChanges([switch][bool]$force) {
$newConfPath = Join-Path $bakPath 'web.new.config'
$origConfPath = Join-Path $bakPath 'web.orig.config'
$xmlDoc.Save($newConfPath)
ShowDiff $origConfPath $newConfPath
if ($force -or (Read-Host "確定要更新?Y/N") -ieq 'Y') {
Copy-Item $newConfPath $configPath
Write-Host "已更新 $configPath" -ForegroundColor Cyan
Stop-Transcript
}
else {
Write-Host "放棄修改" -ForegroundColor Red
Stop-Transcript
. cmd.exe /c "rmdir `"$bakPath`"" /s /q"
}
}
My PowerShell library for web.config modification automation.
Comments
# by Terence
之前工作的地方是用octopus deploy在deploy到iis時加入一個step去執行powershell,打開configurationManager來修改web.config, 這樣一來每個環境也可傳入不同參數. 但如果是deploy到app service則有點限制. (az powershell只能override app service上的app setting和connection string, 而不能直接修改web.config)