PowerShell 技能连载 - 基础设施测试

适用于 PowerShell 7.0 及以上版本,需要 Pester v5+ 模块

随着基础设施即代码(IaC)的广泛采用,越来越多的团队使用 PowerShell DSC、Ansible、Terraform 等工具来定义和管理服务器配置。然而,写好了配置代码并不等于部署一定正确——就像应用代码需要单元测试一样,基础设施代码同样需要一套系统化的测试策略来保证部署结果与预期一致。

基础设施测试与传统软件测试有所不同:它验证的不是函数的输入输出,而是操作系统服务是否运行、端口是否监听、文件权限是否合规、注册表键值是否正确。这类测试通常在部署完成后执行,作为 CI/CD 流水线的最后防线,一旦发现异常就阻断发布或触发回滚。

Pester 是 PowerShell 生态中最强大的测试框架,其描述式语法(Describe / Context / It)天然适合编写基础设施验证用例。本文将从 Pester 基础设施测试框架的搭建、合规性测试套件的编写、到部署验证管道的集成,完整展示如何用 PowerShell 构建一套可靠的基础设施测试体系。

Pester 基础设施测试框架

第一个场景是搭建基础设施测试的基本框架。我们用 Pester 编写针对服务器配置的测试用例,验证关键资源(服务、端口、文件、注册表)是否存在且处于期望状态。测试用例可以参数化,方便在不同环境(dev / staging / prod)中复用。

下面的脚本定义了一套基础设施测试,通过外部 JSON 配置文件描述期望状态,Pester 负责逐项断言:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
function Invoke-InfrastructureTest {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ExpectedStatePath,

[ValidateSet('Minimal', 'Standard', 'Comprehensive')]
[string]$TestDepth = 'Standard',

[string]$OutputPath = 'TestResults.xml'
)

$stateConfig = Get-Content -Path $ExpectedStatePath -Raw | ConvertFrom-Json

$pesterConfig = New-PesterConfiguration
$pesterConfig.Run.PassThru = $true
$pesterConfig.Output.Verbosity = 'Detailed'

if ($OutputPath) {
$pesterConfig.TestResult.Enabled = $true
$pesterConfig.TestResult.OutputPath = $OutputPath
$pesterConfig.TestResult.OutputFormat = 'JUnitXml'
}

$container = New-PesterContainer -ScriptBlock {
param($Config, $Depth)

Describe "基础设施状态验证 - $($Config.environmentName)" {
BeforeAll {
$testStartTime = Get-Date
Write-Host " 测试开始时间: $testStartTime" -ForegroundColor DarkGray
}

# 验证 Windows 服务状态
Context "Windows 服务检查" {
foreach ($svc in $Config.services) {
It "服务 '$($svc.name)' 应处于 $($svc.expectedStatus) 状态" {
$service = Get-Service -Name $svc.name -ErrorAction SilentlyContinue
$service | Should -Not -BeNullOrEmpty -Because "服务 '$($svc.name)' 必须存在"
$service.Status.ToString() | Should -Be $svc.expectedStatus
}
}
}

# 验证网络端口监听
Context "网络端口检查" {
foreach ($port in $Config.ports) {
It "端口 $($port.number)/$($port.protocol) 应处于监听状态" {
$protocol = if ($port.protocol -eq 'UDP') { 'Udp' } else { 'Tcp' }
$listener = Get-NetTCPConnection -LocalPort $port.number `
-ErrorAction SilentlyContinue |
Where-Object { $_.State -eq 'Listen' }

$listener | Should -Not -BeNullOrEmpty `
-Because "端口 $($port.number) 必须有进程监听"
}
}
}

# 验证文件和目录
Context "文件系统检查" {
foreach ($file in $Config.files) {
if ($file.type -eq 'Directory') {
It "目录 '$($file.path)' 应该存在" {
Test-Path -Path $file.path -PathType Container |
Should -BeTrue -Because "目录 '$($file.path)' 必须存在"
}
}
else {
It "文件 '$($file.path)' 应该存在且内容包含 '$($file.expectedContent)'" {
Test-Path -Path $file.path -PathType Leaf |
Should -BeTrue -Because "文件 '$($file.path)' 必须存在"

if ($file.expectedContent) {
$content = Get-Content -Path $file.path -Raw
$content | Should -Match $file.expectedContent
}
}
}
}
}

# 深度测试:注册表和权限(仅 Standard 和 Comprehensive 级别)
if ($Depth -in @('Standard', 'Comprehensive')) {
Context "注册表键值检查" {
foreach ($reg in $Config.registryKeys) {
It "注册表 '$($reg.path)\$($reg.name)' 应为 '$($reg.expectedValue)'" {
$actual = Get-ItemProperty -Path $reg.path -Name $reg.name `
-ErrorAction SilentlyContinue
$actual.$($reg.name) | Should -Be $reg.expectedValue
}
}
}
}

AfterAll {
$duration = (Get-Date) - $testStartTime
Write-Host " 测试耗时: $($duration.ToString('mm\:ss\.fff'))" -ForegroundColor DarkGray
}
}
} -Data @{ Config = $stateConfig; Depth = $TestDepth }

$pesterConfig.Run.Container = $container
$result = Invoke-Pester -Configuration $pesterConfig

return $result
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
PS> Invoke-InfrastructureTest -ExpectedStatePath .\expected-state.json -TestDepth Standard

Starting discovery in 1 files.
Discovery finished in 0.23s.
[+] D:\infra-tests\expected-state.json 1.82s (7.3ms|1.81s)
[+] 基础设施状态验证 - production
[+] Windows 服务检查
[+] 服务 'W3SVC' 应处于 Running 状态
[+] 服务 'MSSQLSERVER' 应处于 Running 状态
[+] 网络端口检查
[+] 端口 443/TCP 应处于监听状态
[+] 端口 1433/TCP 应处于监听状态
[+] 文件系统检查
[+] 文件 'D:\app\appsettings.json' 应该存在且内容包含 'Production'
[+] 注册表键值检查
[+] 注册表 'HKLM:\SOFTWARE\MyApp\Server' 应为 '10.0.1.50'
Tests passed: 6, Failed: 0, Skipped: 0, NotRun: 0
TestResults.xml written to TestResults.xml

合规性测试套件

第二个场景是构建一套完整的合规性测试套件,用于安全基线检查和 CIS Benchmark 验证。与基础的状态检查不同,合规性测试更关注安全策略的落地情况,如密码策略、审计日志、防火墙规则、用户权限等,并支持环境间差异检测。

下面的脚本定义了一套可扩展的合规性测试框架,支持按分类执行,输出详细的合规报告:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
function Invoke-ComplianceTestSuite {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$BaselinePath,

[string[]]$Categories = @('AccountPolicy', 'AuditPolicy', 'Firewall', 'UserRights'),

[string]$EnvironmentName = 'default',

[string]$ReportPath = 'ComplianceReport.json'
)

$baseline = Get-Content -Path $BaselinePath -Raw | ConvertFrom-Json
$complianceResults = [System.Collections.Generic.List[PSObject]]::new()
$totalPassed = 0
$totalFailed = 0

foreach ($category in $Categories) {
Write-Host "`n--- 检查分类: $category ---" -ForegroundColor Cyan

$checks = $baseline.$category
if (-not $checks) {
Write-Host " 跳过(基线中无 '$category' 配置)" -ForegroundColor DarkGray
continue
}

foreach ($check in $checks) {
$testResult = $null
$actualValue = $null

try {
switch ($category) {
'AccountPolicy' {
$netAccounts = net accounts |
Out-String | ForEach-Object {
if ($_ -match "$($check.key)\s*:\s*(.+)") { $Matches[1].Trim() }
}
$actualValue = $netAccounts
$testResult = ($actualValue -eq $check.expectedValue)
}

'AuditPolicy' {
$auditLine = auditpol /get /subcategory:"$($check.subcategory)" |
Select-String -Pattern $check.subcategory
if ($auditLine -match 'Success') {
$actualValue = 'Enabled'
}
else {
$actualValue = 'Disabled'
}
$testResult = ($actualValue -eq $check.expectedValue)
}

'Firewall' {
$rule = Get-NetFirewallRule -DisplayName $check.ruleName `
-ErrorAction SilentlyContinue
if ($check.property -eq 'Enabled') {
$actualValue = if ($rule.Enabled -eq $true) { 'True' } else { 'False' }
}
elseif ($check.property -eq 'Direction') {
$actualValue = $rule.Direction.ToString()
}
$testResult = ($actualValue -eq $check.expectedValue)
}

'UserRights' {
$tempFile = [System.IO.Path]::GetTempFileName()
secedit /export /cfg $tempFile /quiet | Out-Null
$secContent = Get-Content -Path $tempFile -Raw
Remove-Item -Path $tempFile -Force

if ($secContent -match "$($check.privilege)\s*=\s*(.+)") {
$actualValue = $Matches[1].Trim()
}
$testResult = ($actualValue -eq $check.expectedValue)
}
}
}
catch {
$actualValue = "Error: $($_.Exception.Message)"
$testResult = $false
}

$result = [PSCustomObject]@{
Category = $category
CheckId = $check.id
Description = $check.description
Expected = $check.expectedValue
Actual = $actualValue
Passed = [bool]$testResult
Severity = $check.severity
Environment = $EnvironmentName
Timestamp = Get-Date -Format 'o'
}

$complianceResults.Add($result)

if ($testResult) {
$totalPassed++
Write-Host " [通过] [$($check.id)] $($check.description)" -ForegroundColor Green
}
else {
$totalFailed++
$color = if ($check.severity -eq 'Critical') { 'Red' } else { 'Yellow' }
Write-Host " [失败] [$($check.id)] $($check.description)" -ForegroundColor $color
Write-Host " 期望: $($check.expectedValue) 实际: $actualValue" -ForegroundColor DarkGray
}
}
}

# 输出摘要
$total = $totalPassed + $totalFailed
$passRate = if ($total -gt 0) { [math]::Round(($totalPassed / $total) * 100, 1) } else { 0 }

Write-Host "`n========== 合规性摘要 ==========" -ForegroundColor Cyan
Write-Host " 环境: $EnvironmentName"
Write-Host " 总检查项: $total"
Write-Host " 通过: $totalPassed 失败: $totalFailed"
Write-Host " 合规率: $passRate%"
Write-Host "================================"

# 写入报告文件
$report = [PSCustomObject]@{
Environment = $EnvironmentName
GeneratedAt = Get-Date -Format 'o'
TotalChecks = $total
Passed = $totalPassed
Failed = $totalFailed
PassRate = $passRate
Results = $complianceResults
}
$report | ConvertTo-Json -Depth 5 | Set-Content -Path $ReportPath
Write-Host "报告已保存至: $ReportPath" -ForegroundColor DarkGray

return $report
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
PS> Invoke-ComplianceTestSuite -BaselinePath .\cis-baseline.json -EnvironmentName prod -ReportPath .\prod-compliance.json

--- 检查分类: AccountPolicy ---
[通过] [ACC-001] 密码最短长度应为 14 个字符
[失败] [ACC-002] 密码最长使用期限应为 90
期望: 90 实际: 180
--- 检查分类: AuditPolicy ---
[通过] [AUD-001] 登录审核应启用
[通过] [AUD-002] 对象访问审核应启用
--- 检查分类: Firewall ---
[通过] [FW-001] 防火墙域配置文件应启用
[失败] [FW-002] RDP 入站规则应禁用
期望: False 实际: True
--- 检查分类: UserRights ---
[通过] [USR-001] 本地管理员组仅包含授权账户

========== 合规性摘要 ==========
环境: prod
总检查项: 7
通过: 5 失败: 2
合规率: 71.4%
================================
报告已保存至: .\prod-compliance.json

部署验证管道

第三个场景是将基础设施测试集成到部署管道中,实现部署后自动运行验证、基于测试结果判断是否回滚、以及生成可读的测试报告。这是将测试从”手动执行”升级为”自动化防线”的关键一步。

下面的脚本封装了完整的部署验证流程,支持自定义通过阈值、回滚触发、以及 HTML 报告生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
function Invoke-DeploymentValidation {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$TestPath,

[double]$PassThreshold = 100.0,

[string]$RollbackScriptPath,

[string]$ReportOutputDir = 'ValidationReports',

[string]$DeploymentId = "deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
)

$null = New-Item -ItemType Directory -Path $ReportOutputDir -Force
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Write-Host "`n========== 部署验证管道 ==========" -ForegroundColor Cyan
Write-Host " 部署ID: $DeploymentId"
Write-Host " 测试路径: $TestPath"
Write-Host " 通过阈值: $PassThreshold%"
Write-Host " 启动时间: $timestamp"
Write-Host "====================================`n"

# 执行 Pester 测试
$pesterConfig = New-PesterConfiguration
$pesterConfig.Run.Path = $TestPath
$pesterConfig.Run.PassThru = $true
$pesterConfig.Output.Verbosity = 'Normal'

$junitPath = Join-Path $ReportOutputDir "$DeploymentId-junit.xml"
$pesterConfig.TestResult.Enabled = $true
$pesterConfig.TestResult.OutputPath = $junitPath
$pesterConfig.TestResult.OutputFormat = 'JUnitXml'

$result = Invoke-Pester -Configuration $pesterConfig

# 计算通过率
$totalTests = $result.TotalCount
$passedTests = $result.PassedCount
$failedTests = $result.FailedCount
$passRate = if ($totalTests -gt 0) {
[math]::Round(($passedTests / $totalTests) * 100, 2)
}
else {
0
}

Write-Host "`n--- 测试结果 ---" -ForegroundColor Cyan
Write-Host " 总计: $totalTests 通过: $passedTests 失败: $failedTests"
Write-Host " 通过率: $passRate% (阈值: $PassThreshold%)"

$passed = $passRate -ge $PassThreshold

# 生成 HTML 报告
$htmlPath = Join-Path $ReportOutputDir "$DeploymentId-report.html"
$failedDetails = $result.Tests |
Where-Object { $_.Result -eq 'Failed' } |
ForEach-Object {
"<li>$($_.Name) <br/><pre>$($_.ErrorRecord)</pre></li>"
}

$statusColor = if ($passed) { '#28a745' } else { '#dc3545' }
$statusText = if ($passed) { 'PASSED' } else { 'FAILED' }

$html = @"
<!DOCTYPE html>
<html>
<head><title>部署验证报告 - $DeploymentId</title></head>
<body style='font-family:Segoe UI,sans-serif;margin:40px'>
<h1 style='color:$statusColor'>部署验证: $statusText</h1>
<p>部署ID: $DeploymentId | 时间: $timestamp</p>
<p>总计: $totalTests | 通过: $passedTests | 失败: $failedTests | 通过率: $passRate%</p>
<h3>失败用例:</h3>
<ul>$($failedDetails -join "`n")</ul>
</body></html>
"@
$html | Set-Content -Path $htmlPath -Encoding UTF8
Write-Host " HTML 报告: $htmlPath" -ForegroundColor DarkGray

# 回滚判断
if (-not $passed) {
Write-Host "`n[失败] 通过率 $passRate% 低于阈值 $PassThreshold%" -ForegroundColor Red

if ($RollbackScriptPath -and (Test-Path $RollbackScriptPath)) {
Write-Host "[回滚] 正在执行回滚脚本: $RollbackScriptPath" -ForegroundColor Yellow
try {
& $RollbackScriptPath
Write-Host "[回滚] 回滚完成。" -ForegroundColor Yellow
}
catch {
Write-Host "[回滚失败] $($_.Exception.Message)" -ForegroundColor Red
}
}
else {
Write-Host "[警告] 未指定回滚脚本,请手动处理。" -ForegroundColor Yellow
}
}
else {
Write-Host "`n[通过] 部署验证通过,所有测试均在阈值范围内。" -ForegroundColor Green
}

return [PSCustomObject]@{
DeploymentId = $DeploymentId
Passed = $passed
TotalTests = $totalTests
PassedTests = $passedTests
FailedTests = $failedTests
PassRate = $passRate
HtmlReportPath = $htmlPath
JUnitReportPath = $junitPath
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
PS> Invoke-DeploymentValidation -TestPath .\infra-tests\ -PassThreshold 90 -RollbackScriptPath .\rollback.ps1 -DeploymentId deploy-20260414

========== 部署验证管道 ==========
部署ID: deploy-20260414
测试路径: D:\infra-tests\
通过阈值: 90%
启动时间: 2026-04-14 11:00:00
====================================

Discovery finished in 0.45s.
[+] D:\infra-tests\service.tests.ps1 2.31s (12 tests passed)
[+] D:\infra-tests\firewall.tests.ps1 1.08s (8 tests passed)
[-] D:\infra-tests\registry.tests.ps1 0.92s (19 passed, 1 failed)

--- 测试结果 ---
总计: 40 通过: 39 失败: 1
通过率: 97.5% (阈值: 90%)
HTML 报告: ValidationReports\deploy-20260414-report.html

[通过] 部署验证通过,所有测试均在阈值范围内。

DeploymentId : deploy-20260414
Passed : True
TotalTests : 40
PassedTests : 39
FailedTests : 1
PassRate : 97.5
HtmlReportPath : ValidationReports\deploy-20260414-report.html
JUnitReportPath : ValidationReports\deploy-20260414-junit.xml

注意事项

  1. Pester 版本兼容性:本文使用 Pester v5 的 New-PesterConfigurationNew-PesterContainer API,与 v3/v4 语法不兼容。可通过 Get-Module Pester -ListAvailable 检查已安装版本,如需升级使用 Install-Module Pester -Force -SkipPublisherCheck

  2. 测试用例参数化:基础设施测试最大的挑战是环境差异。建议将所有环境特定的值(服务器名、端口、路径)抽取到外部 JSON 配置文件中,测试脚本本身只包含断言逻辑,这样同一套测试可以在不同环境中复用。

  3. 合规性基线维护:CIS Benchmark 和安全基线会随版本更新,基线 JSON 文件应纳入 Git 版本控制。每次基线更新后,先用 -ReportOnly 模式在非生产环境试运行,确认新规则的误报率可接受后再应用到生产环境。

  4. 部署验证的幂等性:验证管道可能因网络抖动等原因产生偶发失败。建议对关键测试加入重试逻辑(Set-ItResult -Pending 后重试一次),同时在设置 PassThreshold 时留出合理余量(如 95% 而非 100%),避免单次偶发失败阻断整个发布。

  5. 回滚脚本的可靠性:回滚脚本本身也需要测试和验证。建议将回滚脚本纳入版本控制,并在 staging 环境中定期演练。回滚操作应有详细的日志记录,包括回滚前后的状态快照,便于事后审计。

  6. 测试报告的持久化:部署验证生成的 JUnit XML 和 HTML 报告应上传到 CI/CD 平台的制品库(如 Azure Pipelines 的 Publish Test Results 任务),保留至少 90 天。长期积累的测试数据可以帮助识别反复失败的测试用例,从而优化测试套件的稳定性。

PowerShell 技能连载 - Bicep 基础设施即代码

适用于 PowerShell 7.0 及以上版本

在现代云原生开发中,基础设施即代码(Infrastructure as Code,IaC)已经成为团队协作和持续交付的基石。传统 ARM Template 虽然功能强大,但 JSON 语法的冗长和嵌套层级让维护成本居高不下。微软推出的 Bicep 语言正是为了解决这个痛点而诞生的——它是一种透明抽象(transparent abstraction),编译后生成标准 ARM Template,同时提供了更简洁的声明式语法、类型安全和模块化支持。

Bicep 的核心优势在于:语法简洁(去掉了 JSON 的大量样板代码)、编译期类型检查(在部署前就能发现错误)、模块化设计(支持将复杂部署拆分为可复用的模块),以及与 Azure 生态的深度集成。配合 PowerShell 的 Az 模块,我们可以构建从开发到生产的完整部署流水线,实现环境一致性管理和自动化运维。

本文将从 Bicep 模板编写、PowerShell 部署脚本以及模块化与重用三个层面,介绍如何通过 PowerShell + Bicep 构建 Azure 基础设施的自动化部署方案。

Bicep 模板编写

Bicep 模板以 .bicep 为扩展名,采用声明式语法定义 Azure 资源。以下是一个完整的 Bicep 模板,包含参数定义、变量计算、资源声明和输出值。

1
2
3
4
5
# 安装 Bicep CLI(如果尚未安装)
az bicep install

# 检查 Bicep 版本
az bicep version

下面是 Bicep 模板文件 main.bicep 的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
@description('资源部署的目标区域')
param location string = resourceGroup().location

@description('环境名称,用于资源命名前缀')
@allowed([
'dev'
'staging'
'prod'
])
param environment string = 'dev'

@description('应用名称,用于资源命名')
param appName string

@description('SKU 定价层')
@allowed([
'F1'
'B1'
'S1'
'P1v3'
])
param skuName string = 'B1'

// 变量:统一命名前缀
var namingPrefix = '${appName}-${environment}'
var tags = {
Environment: environment
Application: appName
ManagedBy: 'bicep'
CreatedDate: utcNow('yyyy-MM-dd')
}

// 应用服务计划
resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
name: '${namingPrefix}-asp'
location: location
sku: {
name: skuName
tier: (skuName startsWith('F') ? 'Free' : (skuName startsWith('B') ? 'Basic' : 'Standard'))
capacity: 1
}
properties: {
reserved: false
}
tags: tags
}

// Web 应用
resource webApp 'Microsoft.Web/sites@2023-01-01' = {
name: '${namingPrefix}-web'
location: location
properties: {
serverFarmId: appServicePlan.id
httpsOnly: true
siteConfig: {
linuxFxVersion: 'DOTNETCORE|8.0'
minTlsVersion: '1.2'
ftpsState: 'Disabled'
http20Enabled: true
}
}
tags: tags
}

// Application Insights
resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
name: '${namingPrefix}-ai'
location: location
kind: 'web'
properties: {
Application_Type: 'web'
SamplingPercentage: 100
DisableIpMasking: false
}
tags: tags
}

// 输出部署结果
output webAppName string = webApp.name
output webAppUrl string = 'https://${webApp.defaultHostName}'
output appInsightsKey string = appInsights.properties.InstrumentationKey
output resourceGroupName string = resourceGroup().name

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PS> az bicep version
Bicep CLI version 0.32.4 (SHA: abc123def)

PS> az bicep build --file main.bicep
# 编译成功,生成 main.json(ARM Template)

PS> Test-AzResourceGroupDeployment `
-ResourceGroupName "myapp-dev-rg" `
-TemplateFile ./main.bicep `
-TemplateParameterObject @{
appName = "myapp"
environment = "dev"
}

# 输出验证结果(无错误)

PowerShell 部署脚本

有了 Bicep 模板后,我们通过 PowerShell 构建自动化部署脚本,支持多环境参数化配置和部署状态跟踪。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
<#
.SYNOPSIS
使用 Bicep 模板部署 Azure 基础设施

.DESCRIPTION
支持多环境部署(dev/staging/prod),自动读取参数文件,
支持差异预览(WhatIf)和实际部署两种模式。
#>

[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidateSet('dev', 'staging', 'prod')]
[string]$Environment,

[Parameter(Mandatory)]
[string]$AppName,

[string]$ResourceGroupName,

[string]$Location = 'eastasia',

[string]$BicepTemplatePath = './main.bicep',

[string]$ParameterFilePath = "./parameters/${Environment}.bicepparam"
)

# 导入 Az 模块
$RequiredModules = @('Az.Resources', 'Az.Accounts')
foreach ($Module in $RequiredModules) {
if (-not (Get-Module -ListAvailable -Name $Module)) {
Write-Host "安装模块: $Module" -ForegroundColor Yellow
Install-Module -Name $Module -Force -Scope CurrentUser
}
Import-Module $Module -ErrorAction Stop
}

# 确保已登录 Azure
$Context = Get-AzContext
if (-not $Context) {
Write-Host '请先登录 Azure...' -ForegroundColor Cyan
Connect-AzAccount
$Context = Get-AzContext
}

Write-Host "当前订阅: $($Context.Subscription.Name)" -ForegroundColor Green

# 构建资源组名称
if (-not $ResourceGroupName) {
$ResourceGroupName = "${AppName}-${Environment}-rg"
}

# 确保资源组存在
$Rg = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue
if (-not $Rg) {
if ($PSCmdlet.ShouldProcess($ResourceGroupName, '创建资源组')) {
$Rg = New-AzResourceGroup -Name $ResourceGroupName -Location $Location
Write-Host "已创建资源组: $ResourceGroupName" -ForegroundColor Green
}
}

# 构建部署参数
$DeployParams = @{
ResourceGroupName = $ResourceGroupName
TemplateFile = $BicepTemplatePath
Verbose = $true
}

# 优先使用参数文件,其次使用命令行参数
if (Test-Path $ParameterFilePath) {
$DeployParams['TemplateParameterFile'] = $ParameterFilePath
Write-Host "使用参数文件: $ParameterFilePath" -ForegroundColor Cyan
} else {
$DeployParams['TemplateParameterObject'] = @{
appName = $AppName
environment = $Environment
location = $Location
}
Write-Host '使用命令行参数' -ForegroundColor Cyan
}

# WhatIf 模式:预览变更
if ($PSCmdlet.ShouldProcess('Azure 资源', '差异预览')) {
Write-Host "`n========== 差异预览 ==========" -ForegroundColor Yellow
$WhatIfResult = Get-AzResourceGroupDeploymentWhatIfResult @DeployParams
$WhatIfResult.Changes | ForEach-Object {
Write-Host " [$($_.ChangeType)] $($_.ResourceId)" -ForegroundColor White
}
}

# 实际部署
if ($PSCmdlet.ShouldProcess('Azure 资源', '执行部署')) {
$DeploymentName = "${AppName}-${Environment}-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
$DeployParams['Name'] = $DeploymentName

Write-Host "`n========== 开始部署 ==========" -ForegroundColor Yellow
$Result = New-AzResourceGroupDeployment @DeployParams

# 输出部署结果
Write-Host "`n========== 部署结果 ==========" -ForegroundColor Green
Write-Host " 部署名称: $($Result.DeploymentName)"
Write-Host " 状态: $($Result.ProvisioningState)"

if ($Result.Outputs) {
Write-Host "`n 输出变量:" -ForegroundColor Cyan
foreach ($Key in $Result.Outputs.Keys) {
Write-Host " $Key = $($Result.Outputs[$Key].Value)"
}
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
PS> .\Deploy-Infrastructure.ps1 -Environment dev -AppName myapp -WhatIf

当前订阅: Visual Studio Enterprise
使用命令行参数

========== 差异预览 ==========
[Create] /subscriptions/.../resourceGroups/myapp-dev-rg/providers/Microsoft.Web/serverfarms/myapp-dev-asp
[Create] /subscriptions/.../resourceGroups/myapp-dev-rg/providers/Microsoft.Web/sites/myapp-dev-web
[Create] /subscriptions/.../resourceGroups/myapp-dev-rg/providers/Microsoft.Insights/components/myapp-dev-ai

PS> .\Deploy-Infrastructure.ps1 -Environment dev -AppName myapp

当前订阅: Visual Studio Enterprise
使用命令行参数

========== 开始部署 ==========

========== 部署结果 ==========
部署名称: myapp-dev-20260320-080000
状态: Succeeded

输出变量:
webAppName = myapp-dev-web
webAppUrl = https://myapp-dev-web.azurewebsites.net
appInsightsKey = 12345678-abcd-efgh-ijkl-1234567890ab
resourceGroupName = myapp-dev-rg

模块化与重用

当基础设施规模增长后,将 Bicep 模板拆分为模块是提高可维护性的关键。以下展示如何创建 Bicep 模块、构建共享模板库,并与 CI/CD 流水线集成。

首先是共享模块 modules/storage.bicep

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# modules/storage.bicep - 存储账户共享模块

@description('存储账户位置')
param location string

@description('存储账户名称(全局唯一)')
param storageAccountName string

@description('存储账户 SKU')
param skuName string = 'Standard_LRS'

@description('资源标签')
param tags object

resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: storageAccountName
location: location
sku: {
name: skuName
}
kind: 'StorageV2'
properties: {
accessTier: 'Hot'
minimumTlsVersion: 'TLS1_2'
allowBlobPublicAccess: false
supportsHttpsTrafficOnly: true
networkAcls: {
defaultAction: 'Deny'
}
}
tags: tags
}

// 启用 Blob 软删除
resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = {
name: '${storageAccount.name}/default'
properties: {
deleteRetentionPolicy: {
enabled: true
days: 30
}
containerDeleteRetentionPolicy: {
enabled: true
days: 30
}
}
}

output storageAccountId string = storageAccount.id
output primaryEndpoint string = storageAccount.properties.primaryEndpoints.blob

然后在主模板中引用模块,并构建共享模板仓库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<#
.SYNOPSIS
管理共享 Bicep 模块仓库
#>

param(
[ValidateSet('publish', 'list', 'restore')]
[string]$Action = 'restore',

[string]$RegistryName = 'myacr.azurecr.io',
[string]$ModulePath = 'bicep/modules'
)

# 模块清单:定义所有需要发布到 ACR 的模块
$ModuleManifest = @{
'storage' = @{
Source = './modules/storage.bicep'
Tag = 'v1.2.0'
}
'keyvault' = @{
Source = './modules/keyvault.bicep'
Tag = 'v1.0.0'
}
'appservice' = @{
Source = './modules/appservice.bicep'
Tag = 'v2.1.0'
}
'sql' = @{
Source = './modules/sql.bicep'
Tag = 'v1.3.0'
}
}

switch ($Action) {
'publish' {
Write-Host '发布模块到 Azure Container Registry...' -ForegroundColor Cyan
foreach ($ModuleName in $ModuleManifest.Keys) {
$Module = $ModuleManifest[$ModuleName]
$Target = "br:${RegistryName}/${ModulePath}/${ModuleName}:${$Module.Tag}"
Write-Host " 发布: ${ModuleName}@$($Module.Tag)" -ForegroundColor White

az bicep publish `
--file $Module.Source `
--target $Target

if ($LASTEXITCODE -eq 0) {
Write-Host " 成功" -ForegroundColor Green
} else {
Write-Host " 失败" -ForegroundColor Red
}
}
}

'restore' {
Write-Host '从 ACR 还原模块依赖...' -ForegroundColor Cyan
az bicep restore --file ./main.bicep --force
Write-Host '还原完成' -ForegroundColor Green
}

'list' {
Write-Host '已注册的 Bicep 模块:' -ForegroundColor Cyan
foreach ($ModuleName in $ModuleManifest.Keys | Sort-Object) {
$Module = $ModuleManifest[$ModuleName]
Write-Host " ${ModuleName}:$($Module.Tag) -> $($Module.Source)"
}
}
}

在主模板中使用远程模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# main.bicep - 引用 ACR 中的共享模块

param location string = resourceGroup().location
param environment string
param appName string

var namingPrefix = '${appName}-${environment}'
var tags = {
Environment: environment
ManagedBy: 'bicep'
}

// 引用 ACR 中的存储模块
module storage 'br:myacr.azurecr.io/bicep/modules/storage:v1.2.0' = {
name: '${namingPrefix}-storage-deploy'
params: {
location: location
storageAccountName: '${namingPrefix}st01'
skuName: (environment == 'prod' ? 'Standard_ZRS' : 'Standard_LRS')
tags: tags
}
}

output storageEndpoint string = storage.outputs.primaryEndpoint

CI/CD 集成脚本(GitHub Actions 本地验证):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<#
.SYNOPSIS
CI/CD 流水线中的 Bicep 验证与部署步骤
#>

param(
[string]$BicepPath = './main.bicep',
[string]$Environment = 'dev'
)

$ErrorActionPreference = 'Stop'

# 步骤 1: 语法验证
Write-Host '== 步骤 1: Bicep 语法验证 ==' -ForegroundColor Yellow
$LintResult = az bicep lint --file $BicepPath 2>&1
if ($LintResult -match 'Error') {
Write-Host "语法检查失败:`n$LintResult" -ForegroundColor Red
exit 1
}
Write-Host '语法检查通过' -ForegroundColor Green

# 步骤 2: 编译为 ARM Template
Write-Host "`n== 步骤 2: 编译 Bicep 模板 ==" -ForegroundColor Yellow
az bicep build --file $BicepPath --outfile ./artifacts/main.json
Write-Host '编译成功' -ForegroundColor Green

# 步骤 3: 安全扫描(检查硬编码密钥等)
Write-Host "`n== 步骤 3: 模板安全扫描 ==" -ForegroundColor Yellow
$ArmContent = Get-Content ./artifacts/main.json -Raw
$SensitivePatterns = @(
@{ Name = '密码明文'; Pattern = '"password"\s*:\s*"[^{}"]+"' }
@{ Name = '密钥明文'; Pattern = '"secretKey"\s*:\s*"[^{}"]+"' }
@{ Name = '连接字符串'; Pattern = '"connectionString"\s*:\s*"[^{}"]+"' }
)
$SecurityPassed = $true
foreach ($Pattern in $SensitivePatterns) {
if ($ArmContent -match $Pattern.Pattern) {
Write-Host " 警告: 检测到 $($Pattern.Name)" -ForegroundColor Red
$SecurityPassed = $false
}
}
if ($SecurityPassed) {
Write-Host '安全扫描通过' -ForegroundColor Green
} else {
Write-Host '安全扫描未通过,请修复后重试' -ForegroundColor Red
exit 1
}

# 步骤 4: WhatIf 预览
Write-Host "`n== 步骤 4: 部署差异预览 ==" -ForegroundColor Yellow
$WhatIf = Get-AzResourceGroupDeploymentWhatIfResult `
-ResourceGroupName "myapp-${Environment}-rg" `
-TemplateFile $BicepPath `
-TemplateParameterFile "./parameters/${Environment}.bicepparam"

$CreateCount = ($WhatIf.Changes | Where-Object { $_.ChangeType -eq 'Create' }).Count
$ModifyCount = ($WhatIf.Changes | Where-Object { $_.ChangeType -eq 'Modify' }).Count
$DeleteCount = ($WhatIf.Changes | Where-Object { $_.ChangeType -eq 'Delete' }).Count

Write-Host " 新增: $CreateCount | 修改: $ModifyCount | 删除: $DeleteCount" -ForegroundColor White
Write-Host "`n验证流程全部通过,可以执行部署" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
PS> .\Manage-BicepModules.ps1 -Action list

已注册的 Bicep 模块:
appservice:v2.1.0 -> ./modules/appservice.bicep
keyvault:v1.0.0 -> ./modules/keyvault.bicep
sql:v1.3.0 -> ./modules/sql.bicep
storage:v1.2.0 -> ./modules/storage.bicep

PS> .\CiCd-Validate.ps1 -BicepPath ./main.bicep -Environment staging

== 步骤 1: Bicep 语法验证 ==
语法检查通过

== 步骤 2: 编译 Bicep 模板 ==
编译成功

== 步骤 3: 模板安全扫描 ==
安全扫描通过

== 步骤 4: 部署差异预览 ==
新增: 3 | 修改: 1 | 删除: 0

验证流程全部通过,可以执行部署

注意事项

  1. Bicep CLI 安装方式:Bicep CLI 可通过 Azure CLI(az bicep install)或独立 MSI 安装。推荐使用 Azure CLI 方式管理,避免版本不一致的问题。确保 Bicep 版本与目标 Azure API 版本兼容。

  2. 参数文件优先级:使用 .bicepparam 文件替代 .json 参数文件,前者支持 Bicep 语法并可直接引用变量和函数,比纯 JSON 参数文件更灵活。命令行传入的参数会覆盖参数文件中的同名参数。

  3. 模块版本管理:将 Bicep 模块发布到 Azure Container Registry(ACR)后,主模板通过 br: 前缀引用。建议使用语义化版本号(SemVer),生产环境应锁定具体版本号而非使用 latest 标签。

  4. 敏感信息处理:绝不要在 Bicep 模板中硬编码密码、密钥等敏感信息。应使用 secureStringsecureObject 类型参数,配合 Azure Key Vault 引用(referenceKeyId)在部署时动态获取。

  5. WhatIf 的局限性Get-AzResourceGroupDeploymentWhatIfResult 的差异预览是一种预测,某些资源类型的变更可能无法准确检测(如应用设置、连接字符串等嵌套属性的变更)。在关键环境部署前,建议先在非生产环境进行完整测试。

  6. 订阅级与租户级部署New-AzResourceGroupDeployment 用于资源组级部署,New-AzDeployment 用于订阅级部署(如创建资源组、策略分配),New-AzTenantDeployment 用于租户级部署。选择正确的部署范围可以避免权限错误。

PowerShell 技能连载 - DSC v3 配置即代码

适用于 PowerShell 7.0 及以上版本

在基础设施即代码(IaC)的实践中,配置管理一直是最核心也最容易出问题的环节。传统的 DSC v2 依赖本地配置管理器(LCM)和 MOF 文档,虽然功能强大但架构笨重,调试困难,且难以与现代 GitOps 流水线无缝对接。微软推出的 DSC v3 彻底重新设计了架构,将配置引擎与资源提供者解耦,采用 JSON 作为配置文档格式,并原生支持跨平台运行。

DSC v3 的设计哲学是”配置即代码”(Configuration as Code)。配置文档以 JSON 格式存储,天然适合纳入 Git 版本控制;资源提供者可以基于任何语言开发(PowerShell、Python、Go 均可),通过标准化的 JSON Schema 接口与 DSC 引擎通信。这种松耦合架构让 DSC v3 能够轻松融入 CI/CD 流水线,与 Azure Machine Configuration、Ansible、Terraform 等工具协同工作。

本文将从实战角度出发,演示如何用 DSC v3 编写 JSON 配置文档、开发自定义 PowerShell 资源,以及构建配置漂移检测机制,帮助你建立可靠的配置即代码工作流。

DSC v3 配置文档编写

DSC v3 使用 JSON 格式的配置文档来声明系统的期望状态。配置文档包含资源实例的列表,每个实例通过 type 指定资源类型,通过 properties 定义期望的配置值。下面的代码展示如何编写一份完整的 DSC v3 配置文档,并利用资源发现功能验证配置的合法性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# 定义 DSC v3 配置文档(JSON 格式)
$configDocument = @{
'$schema' = 'https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json'
contentVersion = '1.0.0'
resources = @(
@{
type = 'Microsoft.Windows/Registry'
name = 'EnableLongPaths'
properties = @{
keyPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem'
valueName = 'LongPathsEnabled'
valueData = @{
dword = 1
}
ensure = 'Present'
}
}
@{
type = 'Microsoft.Windows/Feature'
name = 'InstallSSH'
properties = @{
name = 'OpenSSH.Server'
ensure = 'Present'
includeAllSubFeature = $false
}
}
@{
type = 'Microsoft/Process'
name = 'EnsureSSHService'
properties = @{
path = '/usr/sbin/sshd'
running = $true
}
}
)
}

# 将配置保存为 JSON 文件
$configPath = Join-Path $env:TEMP 'dsc-v3-server-config.json'
$configDocument | ConvertTo-Json -Depth 10 | Set-Content -Path $configPath -Encoding UTF8

Write-Host "配置文档已保存至: $configPath"

# 列出当前可用的 DSC 资源类型
Write-Host "`n--- 已注册的 DSC 资源 ---"
dsc resource list 2>$null | ForEach-Object {
$r = $_ | ConvertFrom-Json
Write-Host (" {0,-40} {1}" -f $r.type, $r.version)
}

# 验证配置文档的合法性
Write-Host "`n--- 验证配置文档 ---"
$validation = dsc config validate -p $configPath 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "配置文档验证通过" -ForegroundColor Green
$validation | ConvertFrom-Json | ForEach-Object {
Write-Host (" 资源: {0} -> 状态: {1}" -f $_.name, '有效')
}
} else {
Write-Host "配置文档验证失败:" -ForegroundColor Red
Write-Host $validation
}

# 导出配置文档的完整 JSON Schema(用于 IDE 智能提示)
Write-Host "`n--- 配置文档概览 ---"
$schemaInfo = @{
配置路径 = $configPath
资源数量 = $configDocument.resources.Count
资源清单 = $configDocument.resources | ForEach-Object { "$($_.type)[$($_.name)]" }
版本 = $configDocument.contentVersion
}
$schemaInfo | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
配置文档已保存至: /tmp/dsc-v3-server-config.json

--- 已注册的 DSC 资源 ---
Microsoft.Windows/Registry 0.1.0
Microsoft.Windows/Feature 0.2.0
Microsoft/Process 0.1.0
Microsoft/OSInfo 0.1.0

--- 验证配置文档 ---
配置文档验证通过
资源: EnableLongPaths -> 状态: 有效
资源: InstallSSH -> 状态: 有效
资源: EnsureSSHService -> 状态: 有效

--- 配置文档概览 ---

配置路径 资源数量 资源清单 版本
-------- -------- -------- ----
/tmp/dsc-v3-server-config.json 3 {Microsoft.Windows/Registry[EnableLongPaths]...} 1.0.0

自定义 DSC 资源开发

DSC v3 的资源提供者采用适配器模式,每个资源需要实现 getsettestdelete 四个操作。对于 PowerShell 用户来说,最自然的方式是使用 Class-based 资源。下面的代码演示如何创建一个管理本地用户配置文件的自定义 DSC 资源,包括资源定义、导出和注册。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# 定义自定义 DSC 资源的清单文件(manifest)
$resourceManifest = @{
'$schema' = 'https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/resource/manifest.json'
type = 'Contoso.UserProfile'
version = '1.0.0'
description = '管理用户环境配置文件'
get = @{
executable = 'pwsh'
args = @(
'-NoLogo', '-NonInteractive', '-NoProfile', '-Command'
"Import-Module 'Contoso.DscResources'; Get-TargetResource"
)
}
set = @{
executable = 'pwsh'
args = @(
'-NoLogo', '-NonInteractive', '-NoProfile', '-Command'
"Import-Module 'Contoso.DscResources'; Set-TargetResource"
)
preTest = $true
}
test = @{
executable = 'pwsh'
args = @(
'-NoLogo', '-NonInteractive', '-NoProfile', '-Command'
"Import-Module 'Contoso.DscResources'; Test-TargetResource"
)
}
schema = @{
embedded = @{
type = 'object'
properties = @{
UserName = @{ type = 'string'; description = '用户名' }
HomeDirectory = @{ type = 'string'; description = '主目录路径' }
Shell = @{ type = 'string'; description = '默认 Shell' }
Ensure = @{
type = 'string'
enum = @('Present', 'Absent')
description = '确保状态'
}
}
required = @('UserName')
}
}
}

# 保存资源清单
$manifestPath = Join-Path $env:TEMP 'Contoso.UserProfile.dsc.resource.json'
$resourceManifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath -Encoding UTF8

# 编写 PowerShell 脚本资源实现
$scriptResource = @'
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$UserName,

[string]$HomeDirectory,
[string]$Shell = '/bin/zsh',
[ValidateSet('Present', 'Absent')]
[string]$Ensure = 'Present'
)

begin {
function Write-DscOutput {
param([hashtable]$Data)
$Data | ConvertTo-Json -Depth 5 -Compress
}
}

process {
# 获取当前状态的实际值
$actualUser = Get-LocalUser -Name $UserName -ErrorAction SilentlyContinue
$actualHome = $actualUser ? (Get-ADUserResultantHomePath $UserName) : $null

# 构建输出对象(符合 DSC v3 JSON Schema)
$result = @{
actualState = @{
UserName = $UserName
HomeDirectory = if ($actualHome) { $actualHome } else { '未设置' }
Shell = $Shell
Ensure = if ($actualUser) { 'Present' } else { 'Absent' }
InDesiredState = ($null -ne $actualUser -and $Ensure -eq 'Present')
}
}

Write-DscOutput -Data $result
}
'@

$resourceScript = Join-Path $env:TEMP 'Contoso.DscResources.psm1'
$scriptResource | Set-Content -Path $resourceScript -Encoding UTF8

# 注册自定义资源到 DSC v3
Write-Host "自定义资源清单已保存至: $manifestPath"
Write-Host "资源实现脚本已保存至: $resourceScript"

# 在配置文档中引用自定义资源
$customConfig = @{
'$schema' = 'https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json'
contentVersion = '1.0.0'
resources = @(
@{
type = 'Contoso.UserProfile'
name = 'DevOperator'
properties = @{
UserName = 'devops'
HomeDirectory = '/home/devops'
Shell = '/bin/zsh'
Ensure = 'Present'
}
}
)
}

$customConfigPath = Join-Path $env:TEMP 'dsc-v3-custom-config.json'
$customConfig | ConvertTo-Json -Depth 10 | Set-Content -Path $customConfigPath -Encoding UTF8

Write-Host "`n自定义资源配置文档已保存至: $customConfigPath"
Write-Host "资源类型: Contoso.UserProfile v1.0.0"
Write-Host "包含属性: UserName, HomeDirectory, Shell, Ensure"

执行结果示例:

1
2
3
4
5
6
自定义资源清单已保存至: /tmp/Contoso.UserProfile.dsc.resource.json
资源实现脚本已保存至: /tmp/Contoso.DscResources.psm1

自定义资源配置文档已保存至: /tmp/dsc-v3-custom-config.json
资源类型: Contoso.UserProfile v1.0.0
包含属性: UserName, HomeDirectory, Shell, Ensure

配置测试与偏差修正

DSC v3 的核心工作模式是 Get-Test-Set 循环。get 操作获取当前实际状态,test 操作比较实际状态与期望状态,set 操作将系统收敛到期望状态。下面的代码演示如何构建一个完整的配置漂移检测和修正流程,并生成可读的漂移报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# 定义漂移检测与修正函数
function Invoke-DscDriftDetection {
param(
[Parameter(Mandatory)]
[string]$ConfigPath,

[switch]$AutoRemediate,

[switch]$DetailedReport
)

$report = [System.Collections.Generic.List[PSObject]]::new()
$timestamp = Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'

# 步骤 1: 获取当前状态(dsc config get)
Write-Host "[$timestamp] 正在获取当前配置状态..." -ForegroundColor Cyan
$getResult = dsc config get -p $ConfigPath 2>&1
$currentState = $getResult | ConvertFrom-Json

# 步骤 2: 测试配置漂移(dsc config test)
Write-Host "[$timestamp] 正在检测配置漂移..." -ForegroundColor Cyan
$testResult = dsc config test -p $ConfigPath 2>&1
$testState = $testResult | ConvertFrom-Json

$driftCount = 0
$inSpecCount = 0

foreach ($resource in $testState.results) {
$entry = [ordered]@{
Timestamp = $timestamp
ResourceType = $resource.type
ResourceName = $resource.name
InDesiredState = $resource.inDesiredState
DriftDetails = $null
}

if (-not $resource.inDesiredState) {
$driftCount++
# 提取漂移的具体属性差异
$diffs = @()
if ($resource.differences) {
foreach ($diff in $diff) {
$diffs += "{0}: 期望='{1}', 实际='{2}'" -f @(
$diff.property
$diff.expected
$diff.actual
)
}
} else {
$diffs = @('状态不匹配')
}
$entry.DriftDetails = $diffs -join '; '
Write-Host (" [漂移] {0}[{1}]: {2}" -f @(
$resource.type, $resource.name, ($diffs -join ', ')
)) -ForegroundColor Yellow
} else {
$inSpecCount++
if ($DetailedReport) {
Write-Host (" [合规] {0}[{1}]" -f @(
$resource.type, $resource.name
)) -ForegroundColor Green
}
}

$report.Add([PSCustomObject]$entry)
}

# 步骤 3: 输出汇总报告
Write-Host "`n========== 配置漂移报告 ==========" -ForegroundColor White
Write-Host ("检测时间: {0}" -f $timestamp)
Write-Host ("配置文件: {0}" -f $ConfigPath)
Write-Host ("资源总数: {0}" -f ($driftCount + $inSpecCount))
Write-Host ("合规数量: {0}" -f $inSpecCount) -ForegroundColor Green
Write-Host ("漂移数量: {0}" -f $driftCount) -ForegroundColor $(if ($driftCount -gt 0) { 'Red' } else { 'Green' })

# 步骤 4: 自动修正(如果启用)
if ($driftCount -gt 0 -and $AutoRemediate) {
Write-Host "`n[$(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK')] 正在执行自动修正..." -ForegroundColor Cyan
$setResult = dsc config set -p $ConfigPath 2>&1
$setState = $setResult | ConvertFrom-Json

$remediated = 0
foreach ($resource in $setState.results) {
if ($resource.rebootRequired) {
Write-Host (" [注意] {0}[{1}] 需要重启" -f @(
$resource.type, $resource.name
)) -ForegroundColor Magenta
}
$remediated++
}

Write-Host ("修正完成: {0} 个资源已收敛" -f $remediated) -ForegroundColor Green
} elseif ($driftCount -gt 0) {
Write-Host "`n提示: 使用 -AutoRemediate 参数可自动修正漂移" -ForegroundColor DarkGray
}

Write-Host "=================================="

# 返回结构化报告
return $report
}

# 执行漂移检测(仅检测,不修正)
$serverConfig = Join-Path $env:TEMP 'dsc-v3-server-config.json'
$driftReport = Invoke-DscDriftDetection -ConfigPath $serverConfig -DetailedReport

# 将漂移报告导出为 JSON(供 CI/CD 流水线消费)
$reportPath = Join-Path $env:TEMP "drift-report-$(Get-Date -Format 'yyyyMMdd-HHmmss').json"
$driftReport | ConvertTo-Json -Depth 5 | Set-Content -Path $reportPath -Encoding UTF8
Write-Host "`n漂移报告已导出至: $reportPath"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[2026-01-06T10:30:15+08:00] 正在获取当前配置状态...
[2026-01-06T10:30:17+08:00] 正在检测配置漂移...
[漂移] Microsoft.Windows/Registry[EnableLongPaths]: 状态不匹配
[合规] Microsoft.Windows/Feature[InstallSSH]
[合规] Microsoft/Process[EnsureSSHService]

========== 配置漂移报告 ==========
检测时间: 2026-01-06T10:30:17+08:00
配置文件: /tmp/dsc-v3-server-config.json
资源总数: 3
合规数量: 2
漂移数量: 1
提示: 使用 -AutoRemediate 参数可自动修正漂移
==================================

漂移报告已导出至: /tmp/drift-report-20260106-103017.json

注意事项

  1. DSC v3 需要独立安装:DSC v3 是独立的原生可执行文件(dsc),不随 PowerShell 自带。你需要从 PowerShell DSC 的 GitHub Releases 页面单独下载安装,或通过 winget install Microsoft.DSC 获取。

  2. JSON Schema 验证很重要:编写配置文档时务必引用官方 JSON Schema。大多数现代编辑器(VS Code、JetBrains)能根据 $schema 字段提供智能提示和实时校验,大幅减少语法错误。在应用到生产环境前,始终先运行 dsc config validate

  3. 自定义资源的幂等性:自定义资源必须确保 set 操作的幂等性——即多次执行结果一致。在 test 操作中精确比较期望状态与实际状态,避免产生不必要的修正操作。对于复杂属性,建议逐字段对比而非整体序列化比较。

  4. 配置漂移报告纳入 CI/CD:将漂移检测集成到 CI/CD 流水线中,每次配置变更都自动触发漂移检测。-AutoRemediate 参数在生产环境使用时应格外谨慎,建议先以只读模式运行检测,人工确认漂移报告后再执行修正。

  5. 与 Azure Machine Configuration 配合:DSC v3 是 Azure Machine Configuration( formerly Azure Policy Guest Configuration)的原生引擎。如果你的环境在 Azure 中,可以通过 Azure Policy 将 DSC v3 配置分配给虚拟机,实现大规模的配置合规性审计和自动修正。

  6. 版本管理配置文档:配置文档应纳入 Git 版本控制,通过 Pull Request 审核配置变更。建议在仓库中设置 dsc config validate 作为 pre-commit hook,确保每次提交的配置文档都是合法的。同时保留历史配置版本,便于回滚和变更追溯。

PowerShell 技能连载 - 配置即代码

适用于 PowerShell 7.0 及以上版本

在 DevOps 和基础设施自动化的浪潮中,”配置即代码”(Configuration as Code,CaC)已经成为一种核心实践。传统的服务器配置往往依赖运维人员手动登录、逐台修改,这种方式不仅效率低下,而且容易出现人为错误,更无法保证环境之间的一致性。当服务器规模从几台增长到几十台、上百台时,手动配置的方式就完全不可行了。

配置即代码的核心理念是将系统的期望状态用声明式的代码描述出来,然后通过工具自动将系统收敛到这个状态。PowerShell 作为 Windows 生态的首选自动化工具,天然具备实现配置即代码的能力。从 PowerShell Desired State Configuration(DSC)到自定义的配置管理框架,我们可以灵活选择适合团队规模的方案。

本文将演示如何用 PowerShell 构建一套轻量级的配置即代码框架,包括定义配置文件、编写配置测试、实现幂等的配置应用,以及生成配置漂移报告。

定义 JSON 配置清单

首先,我们需要一种结构化的方式来描述系统的期望状态。JSON 是一种通用且易于阅读的格式,非常适合充当配置清单的角色。下面的代码定义了一个配置清单结构,并创建一份示例配置文件,涵盖 Windows 功能、注册表项、文件路径和服务状态等常见配置项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# 定义配置清单的目录和文件路径
$configRoot = "$env:USERPROFILE\Documents\ConfigAsCode"
$manifestPath = Join-Path $configRoot "server-manifest.json"

# 确保配置目录存在
if (-not (Test-Path $configRoot)) {
New-Item -Path $configRoot -ItemType Directory -Force | Out-Null
Write-Host "已创建配置目录:$configRoot" -ForegroundColor Green
}

# 定义期望状态的配置清单
$manifest = [PSCustomObject]@{
Metadata = [PSCustomObject]@{
Name = "WebServer-Production"
Version = "1.2.0"
Author = "DevOps Team"
LastUpdated = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ")
Description = "生产环境 Web 服务器标准配置"
}
WindowsFeatures = @(
[PSCustomObject]@{ Name = "Web-Server"; Ensure = "Present" }
[PSCustomObject]@{ Name = "Web-Mgmt-Tools"; Ensure = "Present" }
[PSCustomObject]@{ Name = "Telnet-Client"; Ensure = "Absent" }
)
RegistrySettings = @(
[PSCustomObject]@{
Key = "HKLM:\SOFTWARE\MyApp"
Name = "LogLevel"
Value = 2
Type = "DWord"
}
[PSCustomObject]@{
Key = "HKLM:\SOFTWARE\MyApp"
Name = "MaxConnections"
Value = 100
Type = "DWord"
}
)
Files = @(
[PSCustomObject]@{
Path = "C:\MyApp\config\appsettings.json"
Content = '{"Logging":{"Level":"Information"}}'
Ensure = "Present"
}
)
Services = @(
[PSCustomObject]@{ Name = "W3SVC"; State = "Running"; StartType = "Automatic" }
[PSCustomObject]@{ Name = "Spooler"; State = "Stopped"; StartType = "Disabled" }
)
}

# 将配置清单写入 JSON 文件
$manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath -Encoding UTF8
Write-Host "配置清单已保存至:$manifestPath" -ForegroundColor Cyan

# 验证文件内容
$config = Get-Content -Path $manifestPath -Raw | ConvertFrom-Json
Write-Host "`n配置名称:$($config.Metadata.Name)"
Write-Host "配置版本:$($config.Metadata.Version)"
Write-Host "Windows 功能项:$($config.WindowsFeatures.Count) 个"
Write-Host "注册表设置:$($config.RegistrySettings.Count) 项"
Write-Host "文件配置:$($config.Files.Count) 个"
Write-Host "服务配置:$($config.Services.Count) 个"
1
2
3
4
5
6
7
8
9
已创建配置目录:C:\Users\admin\Documents\ConfigAsCode
配置清单已保存至:C:\Users\admin\Documents\ConfigAsCode\server-manifest.json

配置名称:WebServer-Production
配置版本:1.2.0
Windows 功能项:3 个
注册表设置:2 项
文件配置:1 个
服务配置:2 个

编写配置测试与漂移检测

配置即代码的核心价值在于”可验证”。我们需要一套测试机制,定期对比系统的实际状态与期望状态,发现配置漂移(Configuration Drift)。下面的代码实现了一个漂移检测函数,它会逐项检查配置清单中的每一类资源,输出合规状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# 配置漂移检测函数
function Test-ConfigurationDrift {
param(
[Parameter(Mandatory)]
[string]$ManifestPath,

[string[]]$Categories = @("All")
)

$manifest = Get-Content -Path $ManifestPath -Raw | ConvertFrom-Json
$drifts = [System.Collections.Generic.List[PSCustomObject]]::new()

Write-Host "`n========== 配置漂移检测 ==========" -ForegroundColor Cyan
Write-Host "目标配置:$($manifest.Metadata.Name) v$($manifest.Metadata.Version)"
Write-Host "检测时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "==================================`n"

# 检查注册表设置
if ($Categories -contains "All" -or $Categories -contains "Registry") {
Write-Host "--- 注册表设置 ---" -ForegroundColor Yellow
foreach ($reg in $manifest.RegistrySettings) {
$actualValue = $null
$compliant = $false

if (Test-Path $reg.Key) {
$item = Get-ItemProperty -Path $reg.Key -Name $reg.Name -ErrorAction SilentlyContinue
if ($null -ne $item) {
$actualValue = $item.($reg.Name)
$compliant = ($actualValue -eq $reg.Value)
}
}

$drifts.Add([PSCustomObject]@{
Category = "Registry"
Resource = "$($reg.Key)\$($reg.Name)"
Expected = $reg.Value
Actual = $actualValue
Compliant = $compliant
})

$status = if ($compliant) { "[OK]" } else { "[DRIFT]" }
$color = if ($compliant) { "Green" } else { "Red" }
Write-Host " $status $($reg.Name):期望=$($reg.Value),实际=$actualValue" -ForegroundColor $color
}
}

# 检查文件配置
if ($Categories -contains "All" -or $Categories -contains "Files") {
Write-Host "`n--- 文件配置 ---" -ForegroundColor Yellow
foreach ($file in $manifest.Files) {
$exists = Test-Path $file.Path
$compliant = $false
$actualContent = $null

if ($exists -and $file.Ensure -eq "Present") {
$actualContent = Get-Content -Path $file.Path -Raw
$compliant = ($actualContent.Trim() -eq $file.Content.Trim())
}
elseif (-not $exists -and $file.Ensure -eq "Absent") {
$compliant = $true
}

$drifts.Add([PSCustomObject]@{
Category = "Files"
Resource = $file.Path
Expected = $file.Ensure
Actual = if ($exists) { "Present" } else { "Absent" }
Compliant = $compliant
})

$status = if ($compliant) { "[OK]" } else { "[DRIFT]" }
$color = if ($compliant) { "Green" } else { "Red" }
Write-Host " $status $($file.Path):$($file.Ensure)" -ForegroundColor $color
}
}

# 检查服务状态
if ($Categories -contains "All" -or $Categories -contains "Services") {
Write-Host "`n--- 服务配置 ---" -ForegroundColor Yellow
foreach ($svc in $manifest.Services) {
$service = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue
$compliant = $false

if ($null -ne $service) {
$stateOk = ($service.Status.ToString() -eq $svc.State)
$startTypeOk = ($service.StartType.ToString() -eq $svc.StartType)
$compliant = ($stateOk -and $startTypeOk)
}

$drifts.Add([PSCustomObject]@{
Category = "Services"
Resource = $svc.Name
Expected = "$($svc.State) / $($svc.StartType)"
Actual = if ($service) { "$($service.Status) / $($service.StartType)" } else { "NotFound" }
Compliant = $compliant
})

$status = if ($compliant) { "[OK]" } else { "[DRIFT]" }
$color = if ($compliant) { "Green" } else { "Red" }
Write-Host " $status $($svc.Name):期望=$($svc.State)/$($svc.StartType)" -ForegroundColor $color
}
}

# 汇总报告
$total = $drifts.Count
$compliantCount = ($drifts | Where-Object { $_.Compliant }).Count
$driftCount = $total - $compliantCount
$complianceRate = if ($total -gt 0) { [math]::Round($compliantCount / $total * 100, 1) } else { 0 }

Write-Host "`n========== 漂移汇总 ==========" -ForegroundColor Cyan
Write-Host " 总检测项:$total"
Write-Host " 合规项: $compliantCount" -ForegroundColor Green
Write-Host " 漂移项: $driftCount" -ForegroundColor Red
Write-Host " 合规率: $complianceRate%"
Write-Host "================================`n"

return $drifts
}

# 执行漂移检测
$driftResults = Test-ConfigurationDrift -ManifestPath $manifestPath
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
========== 配置漂移检测 ==========
目标配置:WebServer-Production v1.2.0
检测时间:2025-09-30 14:30:00
==================================

--- 注册表设置 ---
[DRIFT] LogLevel:期望=2,实际=
[DRIFT] MaxConnections:期望=100,实际=

--- 文件配置 ---
[DRIFT] C:\MyApp\config\appsettings.json:Present

--- 服务配置 ---
[OK] W3SVC:期望=Running/Automatic
[DRIFT] Spooler:期望=Stopped/Disabled

========== 漂移汇总 ==========
总检测项:5
合规项: 1
漂移项: 4
合规率: 20.0%
================================

实现幂等的配置应用

检测到漂移后,下一步是自动修复。配置应用函数必须是幂等的(Idempotent),即多次执行的结果与一次执行相同。下面的代码会根据漂移检测结果,逐项将系统收敛到期望状态,并记录每一步的操作日志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# 幂等配置应用函数
function Invoke-ConfigurationApply {
param(
[Parameter(Mandatory)]
[string]$ManifestPath,

[switch]$WhatIf
)

$manifest = Get-Content -Path $ManifestPath -Raw | ConvertFrom-Json
$logEntries = [System.Collections.Generic.List[PSCustomObject]]::new()

Write-Host "`n========== 配置应用 ==========" -ForegroundColor Cyan
Write-Host "目标配置:$($manifest.Metadata.Name) v$($manifest.Metadata.Version)"
if ($WhatIf) {
Write-Host "模式:WhatIf(仅预览,不执行变更)" -ForegroundColor Yellow
}
Write-Host "开始时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "==============================`n"

# 应用注册表设置
foreach ($reg in $manifest.RegistrySettings) {
$action = "Set"
if (-not (Test-Path $reg.Key)) {
if (-not $WhatIf) {
New-Item -Path $reg.Key -Force | Out-Null
}
$action = "Create+Set"
}

if (-not $WhatIf) {
Set-ItemProperty -Path $reg.Key -Name $reg.Name -Value $reg.Value -Type $reg.Type -Force
}

$logEntries.Add([PSCustomObject]@{
Time = Get-Date -Format "HH:mm:ss"
Action = $action
Target = "$($reg.Key)\$($reg.Name)"
Value = $reg.Value
Status = "Applied"
})

Write-Host " [$action] $($reg.Key)\$($reg.Name) = $($reg.Value)" -ForegroundColor Green
}

# 应用文件配置
foreach ($file in $manifest.Files) {
$parentDir = Split-Path $file.Path -Parent

if ($file.Ensure -eq "Present") {
if (-not (Test-Path $parentDir)) {
if (-not $WhatIf) {
New-Item -Path $parentDir -ItemType Directory -Force | Out-Null
}
}

if (-not $WhatIf) {
Set-Content -Path $file.Path -Value $file.Content -NoNewline -Force
}

$logEntries.Add([PSCustomObject]@{
Time = Get-Date -Format "HH:mm:ss"
Action = "Write"
Target = $file.Path
Value = "(content)"
Status = "Applied"
})

Write-Host " [Write] $($file.Path)" -ForegroundColor Green
}
elseif ($file.Ensure -eq "Absent" -and (Test-Path $file.Path)) {
if (-not $WhatIf) {
Remove-Item -Path $file.Path -Force
}

$logEntries.Add([PSCustomObject]@{
Time = Get-Date -Format "HH:mm:ss"
Action = "Remove"
Target = $file.Path
Value = ""
Status = "Applied"
})

Write-Host " [Remove] $($file.Path)" -ForegroundColor Green
}
}

# 应用服务配置
foreach ($svc in $manifest.Services) {
$service = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue

if ($null -ne $service) {
# 设置启动类型
if ($service.StartType.ToString() -ne $svc.StartType) {
if (-not $WhatIf) {
Set-Service -Name $svc.Name -StartupType $svc.StartType
}
Write-Host " [SetStartup] $($svc.Name) -> $($svc.StartType)" -ForegroundColor Green
}

# 设置服务状态
if ($service.Status.ToString() -ne $svc.State) {
if (-not $WhatIf) {
if ($svc.State -eq "Running") {
Start-Service -Name $svc.Name
}
elseif ($svc.State -eq "Stopped") {
Stop-Service -Name $svc.Name -Force
}
}
Write-Host " [SetState] $($svc.Name) -> $($svc.State)" -ForegroundColor Green
}

$logEntries.Add([PSCustomObject]@{
Time = Get-Date -Format "HH:mm:ss"
Action = "Configure"
Target = $svc.Name
Value = "$($svc.State) / $($svc.StartType)"
Status = "Applied"
})
}
}

Write-Host "`n==============================" -ForegroundColor Cyan
Write-Host "变更总数:$($logEntries.Count)"
Write-Host "完成时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "==============================`n"

return $logEntries
}

# 先用 WhatIf 预览变更
Write-Host ">>> 预览模式 <<<" -ForegroundColor Magenta
$previewLog = Invoke-ConfigurationApply -ManifestPath $manifestPath -WhatIf

# 确认后执行实际变更
Write-Host "`n>>> 执行模式 <<<" -ForegroundColor Magenta
$applyLog = Invoke-ConfigurationApply -ManifestPath $manifestPath

# 再次检测漂移,验证修复效果
Write-Host "`n>>> 修复验证 <<<" -ForegroundColor Magenta
$verifyResults = Test-ConfigurationDrift -ManifestPath $manifestPath
$verifyCompliant = ($verifyResults | Where-Object { $_.Compliant }).Count
Write-Host "修复后合规项:$verifyCompliant / $($verifyResults.Count)" -ForegroundColor Green
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
>>> 预览模式 <<<

========== 配置应用 ==========
目标配置:WebServer-Production v1.2.0
模式:WhatIf(仅预览,不执行变更)
开始时间:2025-09-30 14:35:00
==============================

[Create+Set] HKLM:\SOFTWARE\MyApp\LogLevel = 2
[Create+Set] HKLM:\SOFTWARE\MyApp\MaxConnections = 100
[Write] C:\MyApp\config\appsettings.json
[Configure] W3SVC
[Configure] Spooler

==============================
变更总数:5
完成时间:2025-09-30 14:35:00
==============================

>>> 执行模式 <<<

========== 配置应用 ==========
目标配置:WebServer-Production v1.2.0
开始时间:2025-09-30 14:35:12
==============================

[Create+Set] HKLM:\SOFTWARE\MyApp\LogLevel = 2
[Create+Set] HKLM:\SOFTWARE\MyApp\MaxConnections = 100
[Write] C:\MyApp\config\appsettings.json
[SetStartup] Spooler -> Disabled
[SetState] Spooler -> Stopped

==============================
变更总数:5
完成时间:2025-09-30 14:35:14
==============================

>>> 修复验证 <<<

========== 配置漂移检测 ==========
目标配置:WebServer-Production v1.2.0
检测时间:2025-09-30 14:35:15
==================================

--- 注册表设置 ---
[OK] LogLevel:期望=2,实际=2
[OK] MaxConnections:期望=100,实际=100

--- 文件配置 ---
[OK] C:\MyApp\config\appsettings.json:Present

--- 服务配置 ---
[OK] W3SVC:期望=Running/Automatic
[OK] Spooler:期望=Stopped/Disabled

========== 漂移汇总 ==========
总检测项:5
合规项: 5
漂移项: 0
合规率: 100.0%
================================

修复后合规项:5 / 5

生成配置变更历史报告

配置管理的最后一环是审计。我们需要记录每次配置变更的详情,包括变更时间、操作者、变更内容和结果,以便在出现问题时快速回溯。下面的代码实现了一个简单的变更历史追踪机制,将每次应用配置的日志追加到 CSV 文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# 配置变更历史追踪
function Write-ConfigurationAuditLog {
param(
[Parameter(Mandatory)]
[PSCustomObject[]]$LogEntries,

[string]$AuditLogPath = "$configRoot\audit-log.csv"
)

# 为每条日志添加审计字段
$auditRecords = foreach ($entry in $LogEntries) {
[PSCustomObject]@{
Timestamp = "$(Get-Date -Format 'yyyy-MM-ddTHH:mm:ss')"
Operator = $env:USERNAME
Machine = $env:COMPUTERNAME
Action = $entry.Action
Target = $entry.Target
Value = $entry.Value
Status = $entry.Status
ConfigRef = "WebServer-Production v1.2.0"
}
}

# 追加到 CSV 审计日志(如文件不存在则创建表头)
$auditRecords | Export-Csv -Path $AuditLogPath -NoTypeInformation -Append -Force -Encoding UTF8
Write-Host "审计日志已追加至:$AuditLogPath" -ForegroundColor Green

# 显示最近的审计记录
Write-Host "`n最近 5 条审计记录:" -ForegroundColor Cyan
$recentLogs = Import-Csv -Path $AuditLogPath -Encoding UTF8 |
Select-Object -Last 5

$recentLogs | Format-Table Timestamp, Operator, Action, Target, Status -AutoSize
}

# 计算合规趋势(适用于定期巡检场景)
function Get-ComplianceTrend {
param(
[string]$TrendLogPath = "$configRoot\compliance-trend.csv"
)

# 记录本次合规率
$drifts = Test-ConfigurationDrift -ManifestPath $manifestPath
$total = $drifts.Count
$compliant = ($drifts | Where-Object { $_.Compliant }).Count
$rate = if ($total -gt 0) { [math]::Round($compliant / $total * 100, 1) } else { 100 }

$trendRecord = [PSCustomObject]@{
Date = Get-Date -Format "yyyy-MM-dd"
Time = Get-Date -Format "HH:mm:ss"
TotalItems = $total
Compliant = $compliant
Drifted = $total - $compliant
RatePercent = $rate
}

$trendRecord | Export-Csv -Path $TrendLogPath -NoTypeInformation -Append -Force -Encoding UTF8

Write-Host "`n合规趋势已记录:" -ForegroundColor Cyan
Write-Host " 日期:$($trendRecord.Date)"
Write-Host " 合规率:$($trendRecord.RatePercent)%"
Write-Host " 漂移项:$($trendRecord.Drifted)"

# 显示近 7 次巡检趋势
if (Test-Path $TrendLogPath) {
$history = Import-Csv -Path $TrendLogPath -Encoding UTF8 |
Select-Object -Last 7

Write-Host "`n近 7 次巡检合规率趋势:" -ForegroundColor Yellow
foreach ($record in $history) {
$bar = "=" * ([math]::Floor([int]$record.RatePercent / 5))
Write-Host " $($record.Date) [$($record.RatePercent)%] $bar"
}
}
}

# 写入审计日志
Write-ConfigurationAuditLog -LogEntries $applyLog

# 记录合规趋势
Get-ComplianceTrend
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
审计日志已追加至:C:\Users\admin\Documents\ConfigAsCode\audit-log.csv

最近 5 条审计记录:
Timestamp Operator Action Target Status
--------- -------- ------ ------ ------
2025-09-30T14:35:12 admin Create+Set HKLM:\SOFTWARE\MyApp\LogLevel Applied
2025-09-30T14:35:12 admin Create+Set HKLM:\SOFTWARE\MyApp\MaxConnections Applied
2025-09-30T14:35:13 admin Write C:\MyApp\config\appsettings.json Applied
2025-09-30T14:35:13 admin Configure W3SVC Applied
2025-09-30T14:35:14 admin Configure Spooler Applied

========== 配置漂移检测 ==========
目标配置:WebServer-Production v1.2.0
检测时间:2025-09-30 14:36:00
==================================

--- 注册表设置 ---
[OK] LogLevel:期望=2,实际=2
[OK] MaxConnections:期望=100,实际=100

--- 文件配置 ---
[OK] C:\MyApp\config\appsettings.json:Present

--- 服务配置 ---
[OK] W3SVC:期望=Running/Automatic
[OK] Spooler:期望=Stopped/Disabled

========== 漂移汇总 ==========
总检测项:5
合规项: 5
漂移项: 0
合规率: 100.0%
================================

合规趋势已记录:
日期:2025-09-30
合规率:100.0%
漂移项:0

近 7 次巡检合规率趋势:
2025-09-24 [60.0%] ============
2025-09-25 [80.0%] ================
2025-09-26 [80.0%] ================
2025-09-27 [40.0%] ========
2025-09-28 [60.0%] ============
2025-09-29 [80.0%] ================
2025-09-30 [100.0%] ====================

注意事项

  1. 幂等性是底线:配置应用函数必须保证多次执行结果一致,避免重复创建、重复写入等副作用,每次操作前先检查当前状态
  2. JSON Schema 验证:在生产环境中,应在加载配置清单前用 JSON Schema 验证其结构完整性,防止因配置文件格式错误导致不可预期的变更
  3. WhatIf 先行:所有配置变更操作都应先以 -WhatIf 模式预览,确认变更范围后再执行,结合 CI/CD 流水线可实现审批门控
  4. 变更回滚机制:每次应用配置前应备份当前状态,或维护一个”上一个已知良好配置”,出现问题时能快速回退
  5. 敏感信息保护:配置清单中可能包含密码、API 密钥等敏感数据,应使用 Azure Key Vault、Windows Credential Manager 或 PowerShell SecretManagement 模块管理,不要明文存储在 JSON 中
  6. 跨平台兼容性:本文示例以 Windows 注册表和服务为主,如果需要管理 Linux 节点,可将配置目标替换为文件权限、systemd 服务和包管理器,核心框架逻辑不变