有些 IIS 設定要靠改 web.config 完成,有些環境較一致,可以預先寫好覆寫即可,但如果更新的 web.config 有多個且內容不同,最無腦的做法是寫成操作指示請相關人員執行:「打開 web.conf,找到 system.webSesrver/httpProtocol/customHeaders 節點(若沒有請新增),新增一個 <add name="X-Frame-Options" value="NONE" />」。人工作業的最大好處是可以隨機應變,靠簡單描述完成複雜操作,且能處理非預期情境。

但凡是人工作業,就免不了眼花手滑腦抽風的可能,執行品質會因操作者素質(菜鳥 vs 老司機)、身心狀態(加班到快往生或剛被老闆洗完臉)而異;遇上批次大量執行的場合,人工作業效率較差會是另一項問題。先前研究過 用 PowerShell 實現 IIS 安裝、網站設定自動化,現在來試試把「web.config 修改」這項手工活也自動化。

我的設計構想如下:

  1. 為簡化設定語法並保留更大客製彈性,我不打算用 XML-Document-Transform 形式定義異動項目,而是計劃提供一些方便的函式簡化,讓開發人員或 DevOps 工程師用寫程式的方式完成 web.config 修改。(我總覺得寫成一行一行指令還比像咒語般的 XML 直覺好讀多了,也算個人偏好)
  2. 主動保存修改前 web.config 備份以及修改後結果,並側錄執行過程(使用 Start-Transcript),方便稽核與問題追查。
  3. 借用 git diff 產生異動對照表,可經過人工複核再更新,多一層保險機制(但也可以省略)
  4. 由站台名稱及網站應用程式名稱找到 web.config 位置,也支援子目錄 web.config 更新
  5. 以 XPath 指定對象,提供設定 Attribute、附加 Attribute 值、刪除 Element 之快速指令
  6. 允許存取 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 # 不需確認直接更新

執行結果:

補充說明:

  1. 指定站台及網站應用程式名稱,系統找到 web.config 所在位置 (因為有呼叫 Get-WebApplication,需以管理者身分執行)
  2. 開啟前先備份原有 web.config (若放棄更新會刪除備份資料夾)
  3. 啟動轉譯將執行過程寫成 Log
  4. 顯示更新細節
  5. 使用 git diff 對照修改處
  6. 確認後才更新
  7. 進階應用:讀取現值計算後決定新值
  8. 直接換掉整個 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)

Post a comment