前面 23 天,我們已經累積了不少 Function。
像是:
Write-Log
Get-CPUHealth
Get-MemoryHealth
Get-DiskHealth
Get-ServiceHealth
Get-ServerHealth
Get-HealthEventType
Save-HealthState
一開始把 Function 直接放在:
ServerHealthCheck.ps1
沒有問題。
但後來又有:
Collect-ServerHealth.ps1
Collect-ADAudit.ps1
New-DailyHtmlReport.ps1
Send-HealthAlert.ps1
StateTracking.ps1
這時候很容易開始出現:
ServerHealthCheck.ps1
└── Write-Log
ADAudit.ps1
└── Write-Log
Send-Alert.ps1
└── Write-Log
StateTracking.ps1
└── Write-Log
也就是:
同一個 Function 被複製到很多 Script。
今天如果 Write-Log 有 Bug:
要改 4 份
明天想新增:
DEBUG Level
又要:
改 4 份
過幾個月就可能變成:
Write-Log Version A
Write-Log Version B
Write-Log Version C
誰都不知道哪一份才是最新版。
所以 Day 24 要正式把前面累積的 Function 整理成:
PowerShell Module
最後我們希望可以直接:
Import-Module SysAdminToolkit
然後:
Get-ServerHealth
Write-Log
Get-HealthEventType
就像使用:
Import-Module ActiveDirectory
一樣。
Script 跟 Module 有什麼不同?
目前我們主要使用:
.ps1
例如:
ServerHealthCheck.ps1
它比較像:
我要執行的一個工作流程。
例如:
載入 Config
↓
巡檢 Server
↓
輸出 CSV
↓
寄信
↓
Exit
而 Module 比較像:
可以被其他 Script 重複使用的工具集合。
例如:
SysAdminToolkit
│
├── Write-Log
├── Get-CPUHealth
├── Get-MemoryHealth
├── Get-DiskHealth
├── Get-ServerHealth
├── Get-HealthEventType
└── Save-HealthState
所以可以簡單分成:
Module
→ 提供 Tool
Script
→ 使用 Tool 完成 Workflow
以前的架構
可能:
ServerHealthCheck.ps1
│
├── function Write-Log
├── function Get-CPUHealth
├── function Get-MemoryHealth
├── function Get-DiskHealth
├── function Get-ServerHealth
│
└── Main
其他 Script 又重新複製。
今天要變成
SysAdminToolkit
↓
提供共用 Functions
↓
┌───────────────┬────────────────┐
↓ ↓ ↓
HealthCheck ADAudit StateTracking
.ps1 .ps1 .ps1
所有 Script 都:
Import-Module SysAdminToolkit
共用同一套 Function。
PowerShell Module 最基本的兩個檔案
我們會建立:
SysAdminToolkit/
│
├── SysAdminToolkit.psm1
└── SysAdminToolkit.psd1
其中:
.psm1
Script Module。
主要放:
Function
Logic
.psd1
Module Manifest。
主要描述:
Module 名稱
版本
作者
PowerShell Version
Root Module
Export 哪些 Function
程式本體
Module 的說明書與設定
先建立 Module 資料夾
例如:
$ModuleRoot =
"C:\Automation\Modules\SysAdminToolkit"
New-Item -Path $ModuleRoot
-ItemType Directory `
-Force
建立:
C:\Automation
└── Modules
└── SysAdminToolkit
建立第一個 SysAdminToolkit.psm1
先不要一次搬全部。
第一版我們放三個 Function:
Write-Log
Get-HealthEventType
Get-ServerHealth
建立:
C:\Automation\Modules\SysAdminToolkit
└── SysAdminToolkit.psm1
內容:
function Write-Log {
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[string]
$Path,
[Parameter(Mandatory)]
[string]
$Message,
[ValidateSet(
"INFO",
"WARNING",
"ERROR"
)]
[string]
$Level = "INFO"
)
$Folder =
Split-Path `
-Path $Path `
-Parent
if (
$Folder -and
-not (Test-Path $Folder)
) {
New-Item `
-Path $Folder `
-ItemType Directory `
-Force |
Out-Null
}
$Time =
Get-Date `
-Format "yyyy-MM-dd HH:mm:ss"
$LogMessage =
"$Time [$Level] $Message"
Add-Content `
-Path $Path `
-Value $LogMessage `
-Encoding UTF8
}
function Get-HealthEventType {
[CmdletBinding()]
param (
[AllowNull()]
[string]
$PreviousStatus,
[Parameter(Mandatory)]
[ValidateSet(
"Healthy",
"Warning",
"Critical",
"Unknown"
)]
[string]
$CurrentStatus
)
if (
[string]::IsNullOrWhiteSpace(
$PreviousStatus
)
) {
if (
$CurrentStatus -eq
"Healthy"
) {
return "InitialHealthy"
}
return "InitialIssue"
}
if (
$PreviousStatus -eq
$CurrentStatus
) {
if (
$CurrentStatus -eq
"Healthy"
) {
return "NoChange"
}
return "ExistingIssue"
}
if (
$CurrentStatus -eq
"Unknown"
) {
return "VisibilityLost"
}
if (
$PreviousStatus -eq
"Unknown"
) {
if (
$CurrentStatus -eq
"Healthy"
) {
return "Recovery"
}
return "StateRestoredWithIssue"
}
if (
$PreviousStatus -eq
"Healthy"
) {
return "NewAlert"
}
if (
$CurrentStatus -eq
"Healthy"
) {
return "Recovery"
}
if (
$PreviousStatus -eq "Warning" -and
$CurrentStatus -eq "Critical"
) {
return "Escalated"
}
if (
$PreviousStatus -eq "Critical" -and
$CurrentStatus -eq "Warning"
) {
return "Improved"
}
return "Changed"
}
function Get-ServerHealth {
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[string]
$ComputerName,
[int]
$CPUWarning = 80,
[int]
$CPUCritical = 90,
[int]
$MemoryWarning = 80,
[int]
$MemoryCritical = 90
)
try {
Write-Verbose `
"Testing WinRM: $ComputerName"
Test-WSMan `
-ComputerName $ComputerName `
-ErrorAction Stop |
Out-Null
$Result =
Invoke-Command `
-ComputerName $ComputerName `
-ErrorAction Stop `
-ScriptBlock {
param (
$CPUWarning,
$CPUCritical,
$MemoryWarning,
$MemoryCritical
)
# ======================
# CPU
# ======================
$CPUUsage = [math]::Round(
(
Get-CimInstance `
Win32_Processor |
Measure-Object `
-Property LoadPercentage `
-Average
).Average,
2
)
if (
$CPUUsage -ge
$CPUCritical
) {
$CPUStatus =
"Critical"
}
elseif (
$CPUUsage -ge
$CPUWarning
) {
$CPUStatus =
"Warning"
}
else {
$CPUStatus =
"Normal"
}
# ======================
# Memory
# ======================
$OS =
Get-CimInstance `
Win32_OperatingSystem
$TotalMemory =
$OS.TotalVisibleMemorySize
$FreeMemory =
$OS.FreePhysicalMemory
$UsedMemory =
$TotalMemory -
$FreeMemory
$MemoryUsage =
[math]::Round(
(
$UsedMemory /
$TotalMemory
) * 100,
2
)
if (
$MemoryUsage -ge
$MemoryCritical
) {
$MemoryStatus =
"Critical"
}
elseif (
$MemoryUsage -ge
$MemoryWarning
) {
$MemoryStatus =
"Warning"
}
else {
$MemoryStatus =
"Normal"
}
# ======================
# Uptime
# ======================
$LastBootTime =
$OS.LastBootUpTime
$UptimeDays =
[math]::Floor(
(
(Get-Date) -
$LastBootTime
).TotalDays
)
# ======================
# Overall
# ======================
if (
$CPUStatus -eq "Critical" -or
$MemoryStatus -eq "Critical"
) {
$OverallStatus =
"Critical"
}
elseif (
$CPUStatus -eq "Warning" -or
$MemoryStatus -eq "Warning"
) {
$OverallStatus =
"Warning"
}
else {
$OverallStatus =
"Healthy"
}
[PSCustomObject]@{
ComputerName =
$env:COMPUTERNAME
Connection =
"Success"
CPUUsage =
$CPUUsage
CPUStatus =
$CPUStatus
MemoryUsage =
$MemoryUsage
MemoryStatus =
$MemoryStatus
LastBootTime =
$LastBootTime
UptimeDays =
$UptimeDays
OverallStatus =
$OverallStatus
CheckTime =
Get-Date
}
} `
-ArgumentList `
$CPUWarning,
$CPUCritical,
$MemoryWarning,
$MemoryCritical
return $Result
}
catch {
return [PSCustomObject]@{
ComputerName =
$ComputerName
Connection =
"Failed"
CPUUsage =
$null
CPUStatus =
"Unknown"
MemoryUsage =
$null
MemoryStatus =
"Unknown"
LastBootTime =
$null
UptimeDays =
$null
OverallStatus =
"Unknown"
CheckTime =
Get-Date
ErrorMessage =
$_.Exception.Message
}
}
}
為什麼 Write-Log 改成要求 Path?
前面的 Script 裡,我們常寫:
$LogFile =
"C:\Automation\Logs\Test.log"
然後:
function Write-Log {
Add-Content `
-Path $LogFile
}
在同一個 .ps1 裡沒什麼問題。
但 Function 搬進 Module 後,開始牽涉:
Scope
Module 有自己的:
Module Scope
不要假設:
$LogFile
在呼叫 Script 裡存在,Module 就一定應該偷偷使用它。
比較清楚的是:
Write-Log -Path $LogFile
-Message "Health Check started."
也就是:
Function 需要什麼,就明確傳進去。
這樣 Function 比較容易:
重複使用
測試
閱讀
除錯
[CmdletBinding()] 是什麼?
今天 Function 開始多:
[CmdletBinding()]
例如:
function Get-ServerHealth {
[CmdletBinding()]
param (...)
}
它會讓 Function 更接近 PowerShell 原生 Cmdlet 的行為。
例如可以使用 Common Parameters:
-Verbose
-Debug
-ErrorAction
-WarningAction
所以我們可以:
Get-ServerHealth -ComputerName SERVER01
-Verbose
然後 Function 裡:
Write-Verbose `
"Testing WinRM: SERVER01"
只有使用:
-Verbose
時才會顯示。
這比 Script 裡到處:
Write-Host
更適合做正式工具。
Module 裡的 Remoting 還有一個坑
Day 11 提過:
本機定義的 Function 不會自動出現在 Remote Session 裡。
今天變 Module 之後仍然一樣。
假設 Module 裡有:
Get-CPUHealth
然後:
Invoke-Command -ComputerName SERVER01
-ScriptBlock {
Get-CPUHealth
}
Remote SERVER01 不會因為:
管理機有 Import SysAdminToolkit
就自動認識:
Get-CPUHealth
所以今天範例的:
Get-ServerHealth
把真正需要 Remote 執行的 Query 放在:
-ScriptBlock {
Get-CimInstance ...
}
裡面。
也就是:
Local Module
↓
Get-ServerHealth
↓
Invoke-Command
↓
Remote ScriptBlock
↓
Remote Server 自己執行 Query
不要假設 Module Function 會自動穿越 WinRM。
現在 Import 這個 Module
目前只有:
C:\Automation\Modules
└── SysAdminToolkit
└── SysAdminToolkit.psm1
可以直接:
Import-Module `
"C:\Automation\Modules\SysAdminToolkit\SysAdminToolkit.psm1"
然後:
Get-Command `
-Module SysAdminToolkit
可能看到:
CommandType Name
Function Get-HealthEventType
Function Get-ServerHealth
Function Write-Log
現在就可以:
Get-ServerHealth `
-ComputerName SERVER01
但這樣還少了一個 Manifest
目前只是:
Script Module
接著建立:
SysAdminToolkit.psd1
也就是:
Module Manifest
使用 New-ModuleManifest
例如:
$ManifestPath =
"C:\Automation\Modules\SysAdminToolkit\SysAdminToolkit.psd1"
然後:
New-ModuleManifest -Path $ManifestPath
-RootModule "SysAdminToolkit.psm1" -ModuleVersion "1.0.0"
-Author "IT Operations" -CompanyName "Internal"
-Description "PowerShell toolkit for Windows Server and Active Directory operations." -PowerShellVersion "5.1"
-FunctionsToExport @(
"Write-Log"
"Get-HealthEventType"
"Get-ServerHealth"
)
PowerShell 會產生:
SysAdminToolkit.psd1
Module Manifest 裡有什麼?
大概會看到:
@{
RootModule =
'SysAdminToolkit.psm1'
ModuleVersion =
'1.0.0'
GUID =
'...'
Author =
'IT Operations'
CompanyName =
'Internal'
Description =
'PowerShell toolkit for Windows Server and Active Directory operations.'
PowerShellVersion =
'5.1'
FunctionsToExport = @(
'Write-Log'
'Get-HealthEventType'
'Get-ServerHealth'
)
}
這些資訊可以讓使用者知道:
這個 Module 是什麼?
Version?
適用哪個 PowerShell?
提供哪些 Function?
為什麼 Module 要有 Version?
假設今天:
SysAdminToolkit 1.0.0
下個月新增:
Get-DiskHealth
Get-ServiceHealth
可以變:
1.1.0
如果做了不相容的大修改:
2.0.0
未來某天排查問題就可以知道:
SERVER01 Automation
使用 Module 1.0.0
SERVER02 Automation
使用 Module 1.2.0
這比:
我也不知道哪份 ps1 比較新
好很多。
驗證 Manifest
建立完不要直接假設沒問題。
可以:
Test-ModuleManifest `
-Path $ManifestPath
正常會回傳 Module 資訊。
如果:
RootModule 不存在
Manifest 語法錯誤
版本設定不正確
這時候就會比較容易發現。
改成從 psd1 Import
現在可以:
Import-Module "C:\Automation\Modules\SysAdminToolkit\SysAdminToolkit.psd1"
-Force
這時候 PowerShell 會透過 Manifest 知道:
RootModule 是誰
Version 是多少
Export 哪些 Function
-Force 什麼時候用?
開發 Module 時:
Import-Module SysAdminToolkit -Force
很好用。
因為:
修改 psm1
↓
重新 Import
可以載入新的版本。
不然目前 PowerShell Session 可能還在使用舊的 Module。
正式 Script 一般不需要每次都:
-Force
Export-ModuleMember
目前 Module 裡的 Function 可能預設會被 Export。
但我比較喜歡明確控制。
在:
SysAdminToolkit.psm1
最後加入:
Export-ModuleMember -Function
Write-Log,
Get-HealthEventType,
Get-ServerHealth
意思就是:
Module 外面只公開這三個 Function。
這是一個很重要的概念:
Public Interface
Module 裡面可能有:
10 個 Helper Function
但使用者真正需要看到的只有:
3 個
那就只 Export 3 個。
Public 跟 Private
當 Module 開始變大,我不建議:
SysAdminToolkit.psm1
最後變成:
3000 行
否則只是:
把 3000 行 .ps1 換成 3000 行 .psm1。
問題沒有真的解決。
比較好的結構是:
SysAdminToolkit/
│
├── SysAdminToolkit.psd1
├── SysAdminToolkit.psm1
│
├── Public/
│ ├── Write-Log.ps1
│ ├── Get-ServerHealth.ps1
│ └── Get-HealthEventType.ps1
│
└── Private/
├── Get-OverallStatus.ps1
└── Test-ValidHealthStatus.ps1
Public 是什麼?
就是使用 Module 的人可以直接呼叫:
Get-ServerHealth
例如:
Public/
├── Get-ServerHealth.ps1
├── Write-Log.ps1
└── Get-HealthEventType.ps1
Private 是什麼?
例如:
Get-OverallStatus
只是:
Get-ServerHealth
內部需要使用。
我不希望外部 Script 直接:
Get-OverallStatus
所以放:
Private/
它屬於:
Module Internal Helper。
psm1 負責載入 Function
可以把:
SysAdminToolkit.psm1
改成:
$PublicPath =
Join-Path $PSScriptRoot
"Public"
$PrivatePath =
Join-Path $PSScriptRoot
"Private"
Get-ChildItem -Path $PrivatePath
-Filter "*.ps1" `
-ErrorAction SilentlyContinue |
ForEach-Object {
. $_.FullName
}
$PublicFunctions =
Get-ChildItem -Path $PublicPath
-Filter "*.ps1" `
-ErrorAction Stop
foreach ($File in $PublicFunctions) {
. $File.FullName
}
Export-ModuleMember -Function
$PublicFunctions.BaseName
這裡出現一個新的技巧:
. $File.FullName
這叫:
Dot Sourcing
Dot Sourcing
一般:
.\Get-ServerHealth.ps1
代表:
執行這個 Script。
但:
. .\Get-ServerHealth.ps1
前面多一個:
.
代表:
把這個 Script 裡定義的內容載入目前 Scope。
因此 Module 可以:
讀 Public/*.ps1
↓
Dot Source
↓
Functions 載入 Module Scope
現在每個 Function 可以獨立一個檔案
例如:
Public/
└── Write-Log.ps1
內容就只有:
function Write-Log {
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[string]
$Path,
[Parameter(Mandatory)]
[string]
$Message,
[ValidateSet(
"INFO",
"WARNING",
"ERROR"
)]
[string]
$Level = "INFO"
)
$Time =
Get-Date `
-Format "yyyy-MM-dd HH:mm:ss"
Add-Content `
-Path $Path `
-Value "$Time [$Level] $Message" `
-Encoding UTF8
}
如果今天只修改:
Write-Log
就只改:
Write-Log.ps1
不用在 20 個 Script 找 Function。
把以前的 Functions 慢慢搬進來
最終可能:
SysAdminToolkit/
│
├── SysAdminToolkit.psd1
├── SysAdminToolkit.psm1
│
├── Public/
│ ├── Write-Log.ps1
│ ├── Get-CPUHealth.ps1
│ ├── Get-MemoryHealth.ps1
│ ├── Get-DiskHealth.ps1
│ ├── Get-ServiceHealth.ps1
│ ├── Get-ServerHealth.ps1
│ ├── Get-HealthEventType.ps1
│ └── Save-HealthState.ps1
│
└── Private/
├── Get-OverallStatus.ps1
└── Test-HealthStatus.ps1
這就開始真的像一個:
SysAdmin Toolkit。
Main Script 會變得非常乾淨
以前:
ServerHealthCheck.ps1
500 行
現在 Main 可以:
Import-Module `
"$PSScriptRoot\Modules\SysAdminToolkit\SysAdminToolkit.psd1"
$Servers =
Import-Csv `
"$PSScriptRoot\Config\Servers.csv"
$Results = foreach (
$Server in $Servers
) {
Get-ServerHealth `
-ComputerName $Server.ComputerName `
-CPUWarning 80 `
-CPUCritical 90 `
-MemoryWarning 80 `
-MemoryCritical 90
}
$Results |
Export-Csv "$PSScriptRoot\Reports\ServerHealth.csv"
-NoTypeInformation `
-Encoding UTF8
這支 Script 現在只描述:
我要做什麼。
而不是塞滿:
每一個 Function 是怎麼實作。
這是很重要的抽象化
以前:
Main Script
│
├── CPU 計算
├── Memory 計算
├── WinRM
├── Log
├── State
├── HTML
└── Notification
現在:
Main Script
│
├── Get-ServerHealth
├── Write-Log
├── Get-HealthEventType
└── Save-HealthState
也就是:
Main Script 開始描述 Workflow。
細節交給 Module。
Module 可以放進 PSModulePath
目前每次:
Import-Module `
"C:\Automation\Modules\SysAdminToolkit\SysAdminToolkit.psd1"
路徑還是寫很長。
PowerShell 本身有:
$env:PSModulePath
查看:
$env:PSModulePath `
-split ";"
可能看到:
C:\Users\User\Documents\WindowsPowerShell\Modules
C:\Program Files\WindowsPowerShell\Modules
C:\Windows\System32\WindowsPowerShell\v1.0\Modules
PowerShell 會從這些地方找 Module。
Windows PowerShell 5.1 的 User Module Path
常見:
C:\Users<User>\Documents\WindowsPowerShell\Modules
如果放:
...\Modules
└── SysAdminToolkit
├── SysAdminToolkit.psd1
└── SysAdminToolkit.psm1
就可以:
Import-Module SysAdminToolkit
不需要寫完整路徑。
不要直接硬背 Module Path
不同 PowerShell 版本與環境可能有差異。
比較好的方式是:
$env:PSModulePath -split ";"
直接看:
目前這台機器 PowerShell 到底去哪裡找 Module。
找 Module
例如:
Get-Module -ListAvailable
SysAdminToolkit
可能:
ModuleType Version Name
Script 1.0.0 SysAdminToolkit
表示:
PowerShell 找得到它。
Import
Import-Module `
SysAdminToolkit
查看目前載入:
Get-Module `
SysAdminToolkit
看有哪些 Commands
Get-Command `
-Module SysAdminToolkit
例如:
Get-HealthEventType
Get-ServerHealth
Write-Log
這就是使用者真正看到的 Module Interface。
Remove Module
開發時可以:
Remove-Module `
SysAdminToolkit
再:
Import-Module `
SysAdminToolkit
或者:
Import-Module SysAdminToolkit
-Force
方便測試修改。
Function 最好加 Help
既然開始做 Module,就可以讓:
Get-Help Get-ServerHealth
真的有內容。
例如:
function Get-ServerHealth {
<#
.SYNOPSIS
Checks basic Windows Server health.
.DESCRIPTION
Uses PowerShell Remoting to collect
CPU, memory and uptime information
from a remote Windows Server.
.PARAMETER ComputerName
Target Windows Server name.
.PARAMETER CPUWarning
CPU warning threshold.
.PARAMETER CPUCritical
CPU critical threshold.
.EXAMPLE
Get-ServerHealth -ComputerName SERVER01
.EXAMPLE
Get-ServerHealth -ComputerName SERVER01
-CPUWarning 75 `
-CPUCritical 90
#>
[CmdletBinding()]
param (
...
)
...
}
這叫:
Comment-Based Help
然後:
Get-Help Get-ServerHealth
-Full
或者:
Get-Help Get-ServerHealth
-Examples
現在自己的 Function 開始真的很像原生 Cmdlet。
Naming 也開始變重要
Module 裡不要:
ServerCheck
DoCheck
CheckCPU
Log
RunThing
PowerShell 比較建議:
Verb-Noun
所以:
Get-ServerHealth
Write-Log
Save-HealthState
如果不知道有哪些標準 Verb:
Get-Verb
例如:
Get
Set
New
Remove
Test
Write
Save
Import
Export
Module 越大,命名規則越重要。
不要所有 Function 都 Export
假設有:
function ConvertTo-InternalHealthCode {
...
}
只是:
Get-ServerHealth
內部使用。
就不要 Export。
使用者只需要看到:
Get-ServerHealth
而不是看到:
30 個內部 Helper
這跟寫 API 很像:
Public
→ 別人應該使用的介面
Private
→ 內部實作
Module Version 更新
假設:
1.0.0
加入:
Save-HealthState
可能更新:
1.1.0
修小 Bug:
1.1.1
大幅修改 Function 行為:
2.0.0
可以用:
Major.Minor.Patch
的概念。
目前不用做得像正式開源專案那麼複雜。
但至少:
不要 Module 永遠叫 Final-New-v2-ReallyFinal。
Automation Script 也可以檢查 Module Version
例如:
$Module =
Get-Module `
SysAdminToolkit
Write-Log -Path $LogFile
-Message "SysAdminToolkit version: $($Module.Version)"
Log:
2026-10-02 06:00:00
[INFO]
SysAdminToolkit version: 1.0.0
之後 Troubleshooting 非常有價值。
Task Scheduler 執行時也要注意 Module
你手動:
Import-Module SysAdminToolkit
成功。
Task Scheduler:
Module not found
還是可能發生。
因為 Day 20 講過:
Execution Context 不同。
假設 Module 安裝在:
C:\Users\Ted\Documents\WindowsPowerShell\Modules
但 Task Scheduler 使用:
CONTOSO\svc-automation
那 Service Account 不一定看到 Ted 的個人 Module Path。
Automation 共用 Module 適合放哪裡?
如果是該 Server 上多個帳號都需要使用,
可以依公司的權限與部署方式考慮放到:
C:\Program Files\WindowsPowerShell\Modules
之下。
例如:
C:\Program Files\WindowsPowerShell\Modules
└── SysAdminToolkit
但要注意:
誰可以修改 Module?
因為如果 Task Scheduler 用高權限帳號執行 Module,而任何一般使用者都可以修改:
SysAdminToolkit.psm1
那會形成嚴重的安全問題。
所以 Module Folder 本身也應該有適當:
NTFS Permission
Automation Code 本身也是需要保護的資產
以前我們可能只關心:
Server Password
AD Permission
但現在 Script 越來越重要。
假設:
svc-automation
可以:
Query AD
Remote Server
產生 Report
而 Module:
SysAdminToolkit.psm1
卻所有人都可以修改。
那別人只要改:
Get-ServerHealth
就有可能讓高權限 Scheduled Task 執行他的內容。
所以:
Automation Code Permission 本身也是 Security Control。
Module 不要存 Secret
Module 裡同樣不要:
$Password =
"Password123!"
也不要:
$ApiToken =
"abc..."
Module 應該放:
Logic
Function
Validation
而不是:
Secret
Credential
Secrets 應由適當的 Credential / Secret Management 機制處理。
建立一個真正的 Toolkit 專案
到 Day 24,我會把整個專案整理成:
SysAdmin-Automation/
│
├── Modules/
│ │
│ └── SysAdminToolkit/
│ │
│ ├── SysAdminToolkit.psd1
│ ├── SysAdminToolkit.psm1
│ │
│ ├── Public/
│ │ ├── Write-Log.ps1
│ │ ├── Get-ServerHealth.ps1
│ │ ├── Get-HealthEventType.ps1
│ │ └── Save-HealthState.ps1
│ │
│ └── Private/
│ ├── Get-OverallStatus.ps1
│ └── Test-HealthStatus.ps1
│
├── Config/
│ ├── Servers.csv
│ └── NotificationConfig.csv
│
├── Input/
│ ├── NewUsers.csv
│ └── Offboarding.csv
│
├── Scripts/
│ ├── Collect-ServerHealth.ps1
│ ├── Collect-ADAudit.ps1
│ ├── New-DailyHtmlReport.ps1
│ ├── Send-HealthAlert.ps1
│ ├── Preview-Onboarding.ps1
│ └── Preview-Offboarding.ps1
│
├── Reports/
│
├── Logs/
│
└── State/
這時候已經開始有一個真正專案的樣子。
Scripts 跟 Modules 的責任開始清楚
Modules
負責:
How
例如:
CPU 怎麼查?
State 怎麼比較?
Log 怎麼寫?
Scripts
負責:
What / Workflow
例如:
先巡檢 Server。
再產 HTML。
有問題就通知。
Config
負責:
Environment Differences
例如:
Server 清單
Threshold
Group 名稱
Email Recipient
State
負責:
Previous Execution Memory
Reports
負責:
Output
Logs
負責:
Execution History
這就是前 23 天慢慢累積起來的架構。
今天做一支簡化 Main Script
現在真正的 Daily Script 可以變成:
$ModulePath =
Join-Path $PSScriptRoot
"..\Modules\SysAdminToolkit\SysAdminToolkit.psd1"
Import-Module $ModulePath
-ErrorAction Stop
$LogFile =
Join-Path $PSScriptRoot
"..\Logs\DailyCheck.log"
Write-Log -Path $LogFile
-Message "Daily infrastructure check started."
$ConfigFile =
Join-Path $PSScriptRoot
"..\Config\Servers.csv"
$Servers =
Import-Csv `
$ConfigFile
$Results =
foreach ($Server in $Servers) {
Write-Log `
-Path $LogFile `
-Message "Checking $($Server.ComputerName)"
Get-ServerHealth `
-ComputerName $Server.ComputerName `
-CPUWarning ([int]$Server.CPUWarning) `
-CPUCritical ([int]$Server.CPUCritical) `
-MemoryWarning ([int]$Server.MemoryWarning) `
-MemoryCritical ([int]$Server.MemoryCritical)
}
$ReportFile =
Join-Path $PSScriptRoot
"..\Reports\ServerHealth.csv"
$Results |
Export-Csv -Path $ReportFile
-NoTypeInformation `
-Encoding UTF8
Write-Log -Path $LogFile
-Message "Daily infrastructure check completed."
你會發現:
Main Script 變得非常容易閱讀。
以前看 Script
可能:
第 1~100 行
Function
第 101~300 行
Function
第 301~450 行
Function
第 451 行
Main 開始
現在:
Import Module
↓
Import Config
↓
foreach Server
↓
Get-ServerHealth
↓
Export Report
第一次打開 Script 的工程師也比較容易知道:
這支程式到底要幹嘛。
這其實跟軟體開發概念很像
一開始:
全部寫在一起
快速。
後來功能增加:
Function
再增加:
不同 Script
再增加:
Shared Module
這就是程式慢慢從:
Script
走向:
Maintainable Tooling
的過程。
所以系統工程師學 PowerShell,不只是學:
Get-Service
更重要的是開始理解:
Reuse
Separation of Concerns
Configuration
State
Interface
Version
Maintainability
這些概念。
Day 24 小結
今天沒有新增新的:
CPU Check
AD Query
Event Log
而是處理一個隨著 Automation 越做越大,一定會遇到的問題:
Code 要怎麼維護?
我們把前 23 天零散的:
Write-Log
Get-ServerHealth
Get-HealthEventType
Save-HealthState
開始整理進:
SysAdminToolkit
並認識:
.psm1
→ Script Module
.psd1
→ Module Manifest
最後可以:
Import-Module SysAdminToolkit
使用:
Get-ServerHealth
而不是每一支 Script 都重新 Copy Function。
今天也開始建立:
Public
Private
概念:
Public
→ 使用者應該呼叫的 Function
Private
→ Module 內部實作
再搭配:
Export-ModuleMember
控制 Module 對外提供什麼。
另外我們開始加入:
[CmdletBinding()]
以及:
Comment-Based Help
讓自己的 Function 更接近真正 PowerShell Cmdlet。
最終目前架構已經變成:
Config
│
▼
PowerShell Scripts
│
▼
SysAdminToolkit
PowerShell Module
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Windows Server AD State / Log
│ │ │
└─────────────┼─────────────┘
▼
Report
│
▼
Notification
也就是我們已經從 Day 1:
Get-Service
一路走到現在:
Reusable
Versioned
Modular
SysAdmin Automation Toolkit
這是從:
「我會寫 PowerShell Script」
走向:
「我開始建立自己的維運工具」
很重要的一步。
Day 25 預告
Day 25|PowerShell 測試與防呆:怎麼知道修改 Module 後沒有把原本功能弄壞?
現在我們遇到下一個問題。
假設:
SysAdminToolkit 1.0.0
目前正常。
今天我修改:
Get-HealthEventType
想增加:
Unknown
Recovery
結果不小心讓:
Warning → Critical
原本應該:
Escalated
卻變成:
Changed
如果我們只能:
修改
↓
丟到 Production
↓
隔天才知道壞掉
那 Module 化還是不夠。
所以 Day 25 我們會開始碰:
PowerShell Testing
包括最基本的:
Function Input
↓
Expected Result
↓
Actual Result
↓
Pass / Fail
例如:
Previous = Healthy
Current = Critical
Expected = NewAlert
以及:
Previous = Critical
Current = Healthy
Expected = Recovery
並開始介紹 Pester 的思考方式,把:
「我覺得這個 Function 應該沒問題」
慢慢變成:
「我有測試可以證明主要邏輯仍然符合預期。」
Day 25 會是我們從 Automation Tooling 再往 可測試、可維護的工程化工具 前進的一步。