PowerShell 技能连载 - Hyper-V 虚拟机管理

适用于 PowerShell 5.1 及以上版本(Windows),需要 Hyper-V 角色

Hyper-V 是微软提供的原生虚拟化平台,从 Windows Server 2008 开始内置,在 Windows 10/11 专业版和企业版中同样可用。对于系统管理员和 DevOps 工程师来说,能够通过脚本批量管理虚拟机是提高效率的关键。PowerShell 的 Hyper-V 模块提供了完整的 cmdlet 集,覆盖虚拟机的创建、配置、启动、快照、迁移等全生命周期操作。

在实际运维场景中,手动通过 Hyper-V 管理器操作几台虚拟机尚可应付,但当你需要管理数十甚至上百台虚拟机时,手动操作不仅耗时,还容易出错。通过 PowerShell 脚本,我们可以将日常的虚拟机管理任务自动化,比如批量创建开发环境、定期检查虚拟机状态、自动创建检查点等。

本文将通过几个实用的示例,展示如何使用 PowerShell 完成 Hyper-V 虚拟机的日常管理任务,包括查询状态、批量创建虚拟机、管理检查点以及资源监控。

查询虚拟机状态

最基础的操作是了解当前 Hyper-V 主机上有哪些虚拟机以及它们的运行状态。使用 Get-VM 可以快速获取这些信息。

1
2
3
4
# 查询所有虚拟机的基本信息
Get-VM | Select-Object Name, State, CPUUsage, MemoryAssigned, Uptime,
@{N = 'MemoryMB'; E = { [math]::Round($_.MemoryAssigned / 1MB, 0) } } |
Format-Table -AutoSize

这段代码使用 Get-VM 获取所有虚拟机对象,然后通过 Select-Object 提取关键字段。其中 MemoryAssigned 原始值以字节为单位,我们用计算属性将其转换为兆字节(MB)以便阅读。

执行结果示例:

1
2
3
4
5
6
7
Name            State      CPUUsage MemoryAssigned     Uptime MemoryMB
---- ----- -------- -------------- ------ --------
Ubuntu-Dev Running 12 2147483648 12:34:56 2048
Win2022-Test Running 8 4294967296 06:12:30 4096
CentOS-Build Off 0 0 00:00:00 0
Win11-Client Saved 0 0 00:00:00 0
Docker-Host Running 25 2147483648 03:45:12 2048

如果只想查看正在运行的虚拟机,可以加上过滤条件:

1
2
3
4
# 仅查询正在运行的虚拟机
Get-VM | Where-Object { $_.State -eq 'Running' } |
Select-Object Name, @{N = 'CPU(%)'; E = { $_.CPUUsage } },
@{N = 'MemGB'; E = { [math]::Round($_.MemoryAssigned / 1GB, 2) } }

执行结果示例:

1
2
3
4
5
Name         CPU(%) MemGB
---- ------ -----
Ubuntu-Dev 12 2.00
Win2022-Test 8 4.00
Docker-Host 25 2.00

批量创建虚拟机

在搭建测试环境时,经常需要一次性创建多台虚拟机。手动一台台创建费时费力,下面的脚本可以批量完成这项工作。

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
# 批量创建虚拟机的配置参数
$vmConfigs = @(
@{
Name = 'Test-Web01'
Memory = 2GB
VHDSize = 60GB
Switch = 'Default Switch'
}
@{
Name = 'Test-DB01'
Memory = 4GB
VHDSize = 120GB
Switch = 'Default Switch'
}
@{
Name = 'Test-API01'
Memory = 2GB
VHDSize = 60GB
Switch = 'Default Switch'
}
)

# 获取 VHD 默认存储路径
$vhdPath = (Get-VMHost).VirtualHardDiskPath

foreach ($config in $vmConfigs) {
$vhdFilePath = Join-Path $vhdPath "$($config.Name).vhdx"

# 检查虚拟机是否已存在
$existing = Get-VM -Name $config.Name -ErrorAction SilentlyContinue
if ($existing) {
Write-Host "跳过: 虚拟机 '$($config.Name)' 已存在" -ForegroundColor Yellow
continue
}

Write-Host "正在创建虚拟机: $($config.Name)..." -ForegroundColor Cyan

# 创建动态扩展的虚拟硬盘
New-VHD -Path $vhdFilePath -SizeBytes $config.VHDSize -Dynamic |
Out-Null

# 创建虚拟机并挂载虚拟硬盘
New-VM -Name $config.Name -MemoryStartupBytes $config.Memory `
-VHDPath $vhdFilePath -SwitchName $config.Switch |
Out-Null

# 设置处理器数量
Set-VM -Name $config.Name -ProcessorCount 2

Write-Host " 完成: $($config.Name) (内存: $($config.Memory / 1GB)GB, " `
"磁盘: $($config.VHDSize / 1GB)GB, 2 vCPU)" -ForegroundColor Green
}

Write-Host "`n批量创建完成!" -ForegroundColor Green

这个脚本首先定义了一个配置数组,包含每台虚拟机的名称、内存大小、磁盘大小和虚拟交换机名称。然后遍历配置数组,逐一创建虚拟硬盘和虚拟机。脚本还加入了重复检查逻辑,避免在已有同名虚拟机时报错。

执行结果示例:

1
2
3
4
5
6
7
8
正在创建虚拟机: Test-Web01...
完成: Test-Web01 (内存: 2GB, 磁盘: 60GB, 2 vCPU)
正在创建虚拟机: Test-DB01...
完成: Test-DB01 (内存: 4GB, 磁盘: 120GB, 2 vCPU)
正在创建虚拟机: Test-API01...
完成: Test-API01 (内存: 2GB, 磁盘: 60GB, 2 vCPU)

批量创建完成!

管理检查点(快照)

检查点(Checkpoint,以前称为快照 Snapshot)是 Hyper-V 的重要功能,可以在虚拟机的特定时间点保存状态,方便后续回滚。下面的脚本展示了如何自动创建和还原检查点。

1
2
3
4
5
6
7
8
9
10
11
# 为所有运行中的虚拟机创建带时间戳的检查点
$timestamp = Get-Date -Format 'yyyy-MM-dd_HHmm'
$runningVMs = Get-VM | Where-Object { $_.State -eq 'Running' }

foreach ($vm in $runningVMs) {
$checkpointName = "AutoBackup_$timestamp"
Write-Host "正在为 '$($vm.Name)' 创建检查点: $checkpointName"
Checkpoint-VM -VM $vm -SnapshotName $checkpointName
}

Write-Host "`n所有检查点创建完成" -ForegroundColor Green

这段代码筛选出所有正在运行的虚拟机,为每台创建一个带有时间戳标记的检查点。这种做法特别适合在做系统更新或软件升级前保存一个回滚点。

执行结果示例:

1
2
3
4
5
正在为 'Ubuntu-Dev' 创建检查点: AutoBackup_2025-11-25_0800
正在为 'Win2022-Test' 创建检查点: AutoBackup_2025-11-25_0800
正在为 'Docker-Host' 创建检查点: AutoBackup_2025-11-25_0800

所有检查点创建完成

查询和还原检查点同样简单:

1
2
3
4
5
6
# 查看指定虚拟机的检查点列表
$vmName = 'Win2022-Test'
Get-VMSnapshot -VMName $vmName |
Select-Object Name, CreationTime, @{N = 'Type'; E = { $_.SnapshotType } } |
Sort-Object CreationTime -Descending |
Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
Name                         CreationTime          Type
---- ------------ ----
AutoBackup_2025-11-25_0800 2025/11/25 08:00:15 Standard
BeforePatch_2025-11-20 2025/11/20 14:30:22 Standard
CleanInstall 2025/11/01 10:15:00 Standard

如果需要回滚到某个检查点:

1
2
3
4
5
6
7
8
9
10
11
# 回滚虚拟机到指定检查点
$snapshot = Get-VMSnapshot -VMName 'Win2022-Test' |
Where-Object { $_.Name -eq 'BeforePatch_2025-11-20' }

if ($snapshot) {
Write-Host "正在将 'Win2022-Test' 回滚到 '$($snapshot.Name)'..."
Restore-VMSnapshot -VMSnapshot $snapshot -Confirm:$false
Write-Host "回滚完成" -ForegroundColor Green
} else {
Write-Host "未找到指定的检查点" -ForegroundColor Red
}

执行结果示例:

1
2
正在将 'Win2022-Test' 回滚到 'BeforePatch_2025-11-20'...
回滚完成

批量启停虚拟机

在测试环境中,可能需要按特定顺序启动或关闭一组虚拟机。例如先启动数据库服务器,再启动应用服务器和 Web 服务器。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
# 按顺序启动虚拟机组
$startOrder = @('Test-DB01', 'Test-API01', 'Test-Web01')

foreach ($vmName in $startOrder) {
$vm = Get-VM -Name $vmName -ErrorAction SilentlyContinue

if (-not $vm) {
Write-Host "跳过: 虚拟机 '$vmName' 不存在" -ForegroundColor Yellow
continue
}

if ($vm.State -eq 'Running') {
Write-Host "跳过: '$vmName' 已在运行" -ForegroundColor Gray
continue
}

Write-Host "正在启动: $vmName" -ForegroundColor Cyan
Start-VM -Name $vmName

# 等待虚拟机进入运行状态
$timeout = 60
$elapsed = 0
while ((Get-VM -Name $vmName).State -ne 'Running' -and $elapsed -lt $timeout) {
Start-Sleep -Seconds 2
$elapsed += 2
}

if ((Get-VM -Name $vmName).State -eq 'Running') {
Write-Host " 已启动: $vmName (等待 $($elapsed)s)" -ForegroundColor Green
} else {
Write-Host " 超时: $vmName 未能在 ${timeout}s 内启动" -ForegroundColor Red
}
}

这段脚本按照指定的顺序逐台启动虚拟机,并在每台启动后等待确认其进入 Running 状态再继续下一台,确保服务依赖关系正确。同时加入了超时机制,避免某台虚拟机启动失败时脚本无限等待。

执行结果示例:

1
2
3
4
5
6
正在启动: Test-DB01
已启动: Test-DB01 (等待 8s)
正在启动: Test-API01
已启动: Test-API01 (等待 6s)
正在启动: Test-Web01
已启动: Test-Web01 (等待 4s)

关闭虚拟机时,建议优先使用正常关机而非强制断电:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 优雅关闭所有运行中的虚拟机
$runningVMs = Get-VM | Where-Object { $_.State -eq 'Running' }

foreach ($vm in $runningVMs) {
Write-Host "正在关闭: $($vm.Name)" -ForegroundColor Cyan
Stop-VM -Name $vm.Name -Force

# 等待虚拟机完全关闭
$count = 0
while ((Get-VM -Name $vm.Name).State -ne 'Off' -and $count -lt 30) {
Start-Sleep -Seconds 2
$count++
}

$finalState = (Get-VM -Name $vm.Name).State
if ($finalState -eq 'Off') {
Write-Host " 已关闭: $($vm.Name)" -ForegroundColor Green
} else {
Write-Host " 强制关闭: $($vm.Name)" -ForegroundColor Yellow
Stop-VM -Name $vm.Name -TurnOff -Force
}
}

Stop-VM 默认会向虚拟机发送关机信号(类似于按下电源按钮),操作系统正常处理后关闭。如果虚拟机在一定时间内没有响应关机信号,脚本会使用 -TurnOff 参数强制断电。

执行结果示例:

1
2
3
4
5
6
正在关闭: Ubuntu-Dev
已关闭: Ubuntu-Dev
正在关闭: Win2022-Test
已关闭: Win2022-Test
正在关闭: Docker-Host
已关闭: Docker-Host

资源使用监控

监控虚拟机的资源使用情况是运维的重要环节。下面的脚本会收集所有虚拟机的 CPU、内存和磁盘使用信息,并生成一个汇总报告。

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
# 虚拟机资源使用报告
$report = foreach ($vm in Get-VM) {
$vhd = Get-VMHardDiskDrive -VMName $vm.Name |
Select-Object -First 1

$vhdInfo = $null
if ($vhd -and (Test-Path $vhd.Path)) {
$vhdItem = Get-VHD -Path $vhd.Path
$vhdInfo = @{
SizeGB = [math]::Round($vhdItem.Size / 1GB, 1)
UsedGB = [math]::Round($vhdItem.FileSize / 1GB, 1)
Fragment = [math]::Round($vhdItem.FragmentationPercentage, 1)
}
}

[PSCustomObject]@{
Name = $vm.Name
State = $vm.State
CPUUsage = "$($vm.CPUUsage)%"
MemoryGB = [math]::Round($vm.MemoryAssigned / 1GB, 2)
MemDemandGB = [math]::Round($vm.MemoryDemand / 1GB, 2)
DiskSizeGB = if ($vhdInfo) { $vhdInfo.SizeGB } else { 'N/A' }
DiskUsedGB = if ($vhdInfo) { $vhdInfo.UsedGB } else { 'N/A' }
Uptime = $vm.Uptime.ToString('dd\天hh\时mm\分')
}
}

$report | Format-Table -AutoSize

# 输出汇总统计
$totalRunning = ($report | Where-Object { $_.State -eq 'Running' }).Count
$totalMemGB = ($report | Where-Object { $_.State -eq 'Running' } |
Measure-Object -Property MemoryGB -Sum).Sum

Write-Host "`n--- 汇总 ---" -ForegroundColor Cyan
Write-Host "虚拟机总数: $(($report).Count)"
Write-Host "运行中: $totalRunning"
Write-Host "总内存占用: $([math]::Round($totalMemGB, 1)) GB"

这个脚本通过 Get-VM 获取虚拟机基本信息,再结合 Get-VMHardDiskDriveGet-VHD 获取磁盘使用详情。最终生成一个格式化的报告,包含每台虚拟机的 CPU 使用率、内存分配与需求、磁盘容量与实际使用量等关键指标。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
Name          State   CPUUsage MemoryGB MemDemandGB DiskSizeGB DiskUsedGB Uptime
---- ----- -------- -------- ----------- ---------- ---------- ------
Ubuntu-Dev Running 12% 2 1.85 60.0 18.3 12天06时34分
Win2022-Test Running 8% 4 3.42 120.0 45.7 06天12时30分
CentOS-Build Off 0% 0 0 80.0 32.1 00天00时00分
Win11-Client Saved 0% 0 0 60.0 25.6 00天00时00分
Docker-Host Running 25% 2 1.92 60.0 22.8 03天15时45分

--- 汇总 ---
虚拟机总数: 5
运行中: 3
总内存占用: 8.0 GB

注意事项

  • 权限要求:管理 Hyper-V 虚拟机需要管理员权限,并且当前用户必须是 Hyper-V Administrators 组的成员。如果 cmdlet 执行失败,请以管理员身份运行 PowerShell 并检查组成员身份。
  • Hyper-V 模块可用性:Hyper-V 模块仅在 Windows 上可用,且需要安装 Hyper-V 角色。在 Windows 10/11 上可以通过”启用或关闭 Windows 功能”安装,在 Windows Server 上通过 Install-WindowsFeature Hyper-V-PowerShell 安装。
  • 检查点不是备份:检查点依赖于虚拟硬盘的差异链,不能替代完整的虚拟机备份。生产环境中务必配合 Windows Server Backup 或第三方备份方案使用,避免因宿主机故障导致数据丢失。
  • 动态内存的影响:使用动态内存时,MemoryAssigned 显示的是当前分配值,可能随负载变化。如果需要固定内存,创建虚拟机时应设置 -MemoryMinimumBytes-MemoryMaximumBytes-MemoryStartupBytes 为相同值。
  • 虚拟硬盘类型选择New-VHD 支持 Fixed(固定大小)和 Dynamic(动态扩展)两种类型。动态扩展创建速度快、初始占用空间小,但 I/O 性能略低于固定大小。生产数据库等 I/O 密集型场景建议使用固定大小的虚拟硬盘。
  • 网络适配器配置:创建虚拟机时指定的虚拟交换机决定了网络连接方式。如果需要更灵活的网络配置,可以在创建后使用 Add-VMNetworkAdapter 添加额外的网络适配器,或用 Set-VMNetworkAdapterVlan 配置 VLAN 标签。

PowerShell 技能连载 - Obsidian 笔记自动化

适用于 PowerShell 7.0 及以上版本(跨平台)

背景

Obsidian 是近年来最受欢迎的本地知识管理工具之一,所有笔记以 Markdown 格式存储在本地文件夹中。这种”文件即数据库”的设计理念使得 Obsidian 天然适合与脚本工具集成。而 PowerShell 作为跨平台的自动化利器,正好可以在 Obsidian 的 vault 目录中高效地批量创建、检索和管理笔记。

在日常使用 Obsidian 的过程中,我们经常面临一些重复性操作:批量整理标签、根据模板创建日记、统计笔记数量、提取待办事项等。手动处理这些任务既耗时又容易出错。借助 PowerShell 脚本,我们可以将这些操作自动化,大幅提升知识管理的效率。

本文将介绍如何使用 PowerShell 与 Obsidian vault 进行交互,涵盖笔记批量创建、内容检索、元数据管理等常见场景,帮助你构建一套自动化的笔记工作流。

基础:连接 Obsidian Vault

首先,我们需要定位 Obsidian vault 的路径,并封装一些基础函数以便后续复用。下面的代码定义了 vault 根路径和常用的辅助函数。

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
# 定义 Obsidian vault 路径
$VaultPath = "/Users/wubo/Obsidian/MyVault"

# 检查 vault 是否存在并包含 .obsidian 目录
function Test-Vault {
param([string]$Path)
$obsidianDir = Join-Path -Path $Path -ChildPath ".obsidian"
if ((Test-Path -Path $Path) -and (Test-Path -Path $obsidianDir)) {
return $true
}
return $false
}

# 获取 vault 中所有 Markdown 文件
function Get-VaultNotes {
param([string]$Path)
$mdFiles = Get-ChildItem -Path $Path -Filter "*.md" -Recurse -File
return $mdFiles
}

# 调用示例
if (Test-Vault -Path $VaultPath) {
$notes = Get-VaultNotes -Path $VaultPath
Write-Output "Vault 中共有 $($notes.Count) 篇笔记"
$notes | Select-Object -First 5 | Format-Table Name, Length, LastWriteTime
} else {
Write-Output "指定的路径不是一个有效的 Obsidian vault"
}

执行结果示例:

1
2
3
4
5
6
7
8
9
Vault 中共有 1283 篇笔记

Name Length LastWriteTime
---- ------ -------------
Daily Note 模板.md 482 2025-11-20 09:15:00
MOC - 编程.md 1205 2025-11-22 14:30:00
项目计划.md 876 2025-11-23 10:00:00
读书笔记 - 深度学习.md 2340 2025-11-23 16:45:00
周报 2025-W47.md 650 2025-11-24 08:00:00

这段代码的核心思路是利用 .obsidian 目录来判断一个文件夹是否为有效的 Obsidian vault,然后递归扫描其中所有的 .md 文件。Get-ChildItem 配合 -Recurse 参数可以遍历所有子文件夹,确保不会遗漏任何笔记。

批量创建笔记

当我们需要根据固定模板批量创建笔记时,手动操作既繁琐又容易格式不一致。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
# 定义日记模板
$DailyTemplate = @'
---
date: {DATE}
tags:
- daily-note
- journal
---

# {DATE} 日记

## 今日计划

-

## 今日完成

-

## 随想

-

## 明日计划

-
'@

# 批量创建指定日期范围的日记
function New-DailyNotes {
param(
[Parameter(Mandatory)]
[string]$VaultPath,
[Parameter(Mandatory)]
[DateTime]$StartDate,
[Parameter(Mandatory)]
[DateTime]$EndDate
)

$dailyDir = Join-Path -Path $VaultPath -ChildPath "Daily Notes"
if (-not (Test-Path -Path $dailyDir)) {
New-Item -Path $dailyDir -ItemType Directory | Out-Null
Write-Output "已创建 Daily Notes 目录"
}

$current = $StartDate
$created = 0

foreach ($day in @(while ($current -le $EndDate) { $current; $current = $current.AddDays(1) })) {
$dateStr = $day.ToString("yyyy-MM-dd")
$fileName = "{0}.md" -f $dateStr
$filePath = Join-Path -Path $dailyDir -ChildPath $fileName

if (Test-Path -Path $filePath) {
Write-Output "跳过已存在: $fileName"
continue
}

$content = $DailyTemplate -replace '\{DATE\}', $dateStr
Set-Content -Path $filePath -Value $content -Encoding UTF8
$created++
Write-Output "已创建: $fileName"
}

Write-Output "`n总计创建 $created 篇日记"
}

# 创建本周(周一到周五)的工作日记
$monday = (Get-Date).Date
while ($monday.DayOfWeek -ne [System.DayOfWeek]::Monday) {
$monday = $monday.AddDays(-1)
}
$friday = $monday.AddDays(4)

New-DailyNotes -VaultPath $VaultPath -StartDate $monday -EndDate $friday

执行结果示例:

1
2
3
4
5
6
7
已创建: 2025-11-24.md
已创建: 2025-11-25.md
已创建: 2025-11-26.md
已创建: 2025-11-27.md
已创建: 2025-11-28.md

总计创建 5 篇日记

这段代码使用字符串模板配合 -replace 操作符来生成笔记内容。foreach 循环遍历日期范围内的每一天,检查文件是否已存在以避免覆盖。模板中的 {DATE} 占位符会被实际日期替换。注意 Set-Content 使用 UTF8 编码,确保 Obsidian 能正确读取中文内容。

笔记内容检索与统计

随着 vault 中笔记数量增长到数百甚至上千篇,手动查找特定内容变得困难。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
# 分析 Obsidian vault 中的笔记标签使用情况
function Get-VaultTagStats {
param(
[Parameter(Mandatory)]
[string]$VaultPath
)

$notes = Get-ChildItem -Path $VaultPath -Filter "*.md" -Recurse -File
$tagMap = @{}
$totalTags = 0

foreach ($note in $notes) {
$content = Get-Content -Path $note.FullName -Raw -ErrorAction SilentlyContinue
if ($null -eq $content) { continue }

# 提取 front matter 中的 tags
if ($content -match '(?s)^---\r?\n(.*?)\r?\n---') {
$frontMatter = $Matches[1]
$tagLines = $frontMatter -split "`n" | Where-Object { $_ -match '^\s*-\s+' }

foreach ($line in $tagLines) {
$tag = ($line -replace '^\s*-\s+', '').Trim()
if ($tag -and $tag -notmatch '^\s*$') {
$totalTags++
if ($tagMap.ContainsKey($tag)) {
$tagMap[$tag]++
} else {
$tagMap[$tag] = 1
}
}
}
}
}

# 按使用次数排序输出
$results = $tagMap.GetEnumerator() |
Sort-Object -Property Value -Descending |
Select-Object -First 20

Write-Output "=== Vault 标签统计 ==="
Write-Output "笔记总数: $($notes.Count)"
Write-Output "标签总数: $totalTags"
Write-Output "去重标签: $($tagMap.Count)"
Write-Output ""
Write-Output "Top 20 标签:"
Write-Output ("{0,-25} {1,8}" -f "标签", "出现次数")
Write-Output ("{0,-25} {1,8}" -f "----", "--------")

foreach ($item in $results) {
Write-Output ("{0,-25} {1,8}" -f $item.Key, $item.Value)
}
}

Get-VaultTagStats -VaultPath $VaultPath

执行结果示例:

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
=== Vault 标签统计 ===
笔记总数: 1283
标签总数: 4856
去重标签: 187

Top 20 标签:
标签 出现次数
---- --------
powershell 142
daily-note 135
project 98
reading 87
programming 76
ai 65
linux 54
docker 43
python 39
meeting 36
review 32
security 28
network 25
devops 23
database 21
cloud 19
architecture 17
book 15
tip 14
learning 12

这段代码通过正则表达式提取 Markdown 文件中 YAML front matter 里的标签列表。$content -match '(?s)^---\r?\n(.*?)\r?\n---' 这条正则用于匹配 front matter 区域,然后逐行解析其中的标签项。最终使用哈希表进行计数,并按频率排序输出。这种统计方式可以帮助你发现 vault 中的热门主题,以及哪些标签可能需要合并或清理。

自动提取待办事项

Obsidian 的待办事项通常以 - [ ]- [x] 的形式散落在各篇笔记中。手动翻阅所有笔记来汇总待办事项效率极低。PowerShell 可以快速扫描整个 vault,提取未完成的任务并按优先级或文件分组展示。

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
# 从 vault 中提取所有未完成的待办事项
function Get-VaultTodoItems {
param(
[Parameter(Mandatory)]
[string]$VaultPath,
[switch]$IncludeCompleted
)

$notes = Get-ChildItem -Path $VaultPath -Filter "*.md" -Recurse -File
$todoItems = [System.Collections.Generic.List[PSObject]]::new()

foreach ($note in $notes) {
$lines = Get-Content -Path $note.FullName -ErrorAction SilentlyContinue
if ($null -eq $lines) { continue }

$lineNum = 0
foreach ($line in $lines) {
$lineNum++

if ($IncludeCompleted) {
# 匹配已完成和未完成的待办
if ($line -match '^\s*-\s+\[([ xX])\]\s+(.+)') {
$done = $Matches[1] -match '[xX]'
$todoItems += [PSCustomObject]@{
File = $note.Name
Line = $lineNum
Done = $done
Task = $Matches[2].Trim()
Relative = $note.FullName.Replace($VaultPath, "").TrimStart("/", "\")
}
}
} else {
# 只匹配未完成的待办
if ($line -match '^\s*-\s+\[\s\]\s+(.+)') {
$todoItems += [PSCustomObject]@{
File = $note.Name
Line = $lineNum
Task = $Matches[1].Trim()
Relative = $note.FullName.Replace($VaultPath, "").TrimStart("/", "\")
}
}
}
}
}

return $todoItems
}

# 查找所有未完成的待办事项
$todos = Get-VaultTodoItems -VaultPath $VaultPath

Write-Output "=== 未完成待办事项 ($($todos.Count) 项) ==="
Write-Output ""

$grouped = $todos | Group-Object -Property File |
Sort-Object -Property Count -Descending |
Select-Object -First 10

foreach ($group in $grouped) {
Write-Output "--- $($group.Name) ($($group.Count) 项) ---"
foreach ($item in $group.Group) {
Write-Output " [ ] $($item.Task)"
}
Write-Output ""
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
=== 未完成待办事项 (47 项) ===

--- 项目计划.md (8 项) ---
[ ] 完成接口文档编写
[ ] 更新部署脚本
[ ] 联系运维确认服务器规格
[ ] 编写单元测试
[ ] 代码评审反馈修改
[ ] 性能测试报告
[ ] 准备周会演示
[ ] 整理技术方案

--- 读书笔记 - 深度学习.md (5 项) ---
[ ] 完成第五章习题
[ ] 复习反向传播算法
[ ] 实现 CNN 模型
[ ] 阅读补充材料
[ ] 整理笔记发布到博客

--- Weekly/周报 2025-W47.md (3 项) ---
[ ] 提交周报
[ ] 跟进客户反馈
[ ] 整理会议纪要

这段代码的核心是正则表达式 ^\s*-\s+\[\s\]\s+(.+),它匹配 Markdown 中标准的未完成待办格式。通过 Group-Object 按文件分组后,可以直观地看到每篇笔记中积压的任务数量。结合 -IncludeCompleted 开关,还可以对比已完成和未完成任务的比率,评估自己的执行效率。

注意事项

  1. 编码问题:Obsidian 默认使用 UTF-8 编码保存笔记。使用 Set-Content 写入文件时务必指定 -Encoding UTF8(PowerShell 7 中默认是 UTF8,但显式声明更安全)。如果在 Windows 上遇到中文乱码,检查文件是否被保存为 UTF-8 with BOM。

  2. 文件锁定:当 Obsidian 正在运行时,它会监控 vault 目录的文件变化。PowerShell 写入文件后,Obsidian 通常会在几秒内自动检测到变更。但如果频繁大量写入,建议先关闭 Obsidian 或使用其 URI 协议(obsidian://)进行操作,避免索引冲突。

  3. Front Matter 格式:Obsidian 依赖 YAML front matter 来管理元数据(标签、别名、日期等)。自动生成笔记时要确保 YAML 语法正确,特别是冒号后的空格和列表缩进。建议使用 ConvertTo-Yaml 模块或手动拼接字符串,避免格式错误导致 Obsidian 无法解析。

  4. 路径分隔符:PowerShell 7 虽然跨平台,但在处理路径时仍需注意操作系统差异。使用 Join-PathSplit-Path 代替手动拼接路径字符串,确保脚本在 Windows、macOS 和 Linux 上都能正常运行。

  5. 备份策略:在执行批量修改操作前,务必先备份 vault。可以使用 Git 进行版本控制,或者用 PowerShell 创建 zip 备份。批量操作脚本中应加入 -WhatIf 支持,先预览将要修改的文件列表,确认无误后再实际执行。

  6. 性能考量:当 vault 中有数千篇笔记时,逐个读取文件内容会比较慢。对于纯文件名或路径相关的操作,优先使用 Get-ChildItem 返回的文件信息对象,避免不必要的 Get-Content 调用。如果需要全文搜索,考虑先构建索引或使用 Select-String 进行流式匹配,而不是将所有文件内容加载到内存中。

PowerShell 技能连载 - 参数补全器

适用于 PowerShell 5.1 及以上版本

在 PowerShell 日常使用中,Tab 补全(Tab Completion)是最常用的交互功能之一。当我们输入 cmdlet 名称、参数名或文件路径时,按下 Tab 键就能自动补全,极大提高了命令行操作效率。然而,对于自定义函数中的参数值(例如要求用户输入一个服务名、一个环境名称或一个日志级别),PowerShell 默认无法提供智能提示,用户必须手动输入,这不仅降低了效率,还容易出错。

Argument Completer(参数补全器)正是解决这一问题的利器。通过为函数参数注册补全逻辑,我们可以在用户按 Tab 或 Ctrl+Space 时,动态展示可选值列表。这些值可以来自固定集合、运行时计算结果,甚至远程 API 查询,让自定义函数拥有和内置 cmdlet 一样的 IntelliSense 体验。

本文将从基础的 [ArgumentCompleter] 属性入手,逐步介绍 Register-ArgumentCompleter 注册全局补全、结合动态数据源构建高级补全器,帮助你为团队工具库打造专业级的参数提示体验。

使用 ArgumentCompleter 属性

最简单的方式是为参数直接添加 [ArgumentCompleter] 属性。该属性接受一个脚本块,脚本块的返回值就是 Tab 补全时显示的候选列表。下面这个例子为 -LogLevel 参数提供四个固定的日志级别选项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Write-AppLog {
param(
[Parameter(Mandatory)]
[string]$Message,

[Parameter()]
[ArgumentCompleter({
param($commandName, $parameterName, $wordToComplete)
@('Debug', 'Info', 'Warning', 'Error') |
Where-Object { $_ -like "$wordToComplete*" }
})]
[string]$LogLevel = 'Info'
)

$Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Write-Host "[$Timestamp] [$LogLevel] $Message"
}

Write-AppLog -Message '系统启动完成' -LogLevel W<TAB>

脚本块接收四个参数:$commandName(函数名)、$parameterName(参数名)、$wordToComplete(用户已输入的部分文本)以及 $commandAst(命令的 AST)。通过 Where-Object 过滤以用户输入开头的候选项,可以实现增量匹配。

执行后,在 -LogLevel 参数处按 Tab 键会依次补全为 Debug、Info、Warning 或 Error。

1
[2025-11-21 09:15:32] [Warning] 系统启动完成

使用 Register-ArgumentCompleter 注册全局补全

[ArgumentCompleter] 属性仅对当前函数有效。如果希望为已有的外部命令或多个函数统一注册补全逻辑,可以使用 Register-ArgumentCompleter cmdlet。这在为第三方模块或原生命令增强 Tab 补全时特别有用。

以下示例为 Stop-Service-Name 参数注册补全器,让用户可以直接 Tab 选择当前运行的服务名称。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 为 Stop-Service 的 -Name 参数注册补全器
Register-ArgumentCompleter -CommandName 'Stop-Service' -ParameterName 'Name' -ScriptBlock {
param($commandName, $parameterName, $wordToComplete)

$Services = Get-Service | Where-Object { $_.Status -eq 'Running' }
foreach ($Svc in $Services) {
if ($Svc.Name -like "$wordToComplete*") {
[System.Management.Automation.CompletionResult]::new(
$Svc.Name,
$Svc.Name,
'ParameterValue',
"运行中 - $($Svc.DisplayName)"
)
}
}
}

# 使用时按 Tab 即可看到所有正在运行的服务
Stop-Service -Name <TAB>

这里使用了 [System.Management.Automation.CompletionResult] 对象来构造补全结果,它比返回纯字符串提供了更丰富的信息:第一个参数是实际插入的文本,第二个是显示文本,第三个是补全类型,第四个是工具提示(tooltip),鼠标悬停时可以看到服务的显示名称。

执行效果是当你在 Stop-Service -Name 后按 Tab 时,会看到运行中服务的列表及提示。

1
2
3
Stop-Service -Name Audiosrv        [运行中 - Windows Audio]
Stop-Service -Name BFE [运行中 - Base Filtering Engine]
Stop-Service -Name EventLog [运行中 - Windows Event Log]

构建动态数据源补全器

在实际项目中,参数的可选值往往来自外部数据源,例如配置文件、数据库或 REST API。下面这个示例展示如何从 JSON 配置文件中读取环境列表,并将其作为参数补全的候选值。这种方式让工具的补全逻辑与配置数据解耦,新增环境时只需修改配置文件即可。

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
# 定义辅助函数:从配置文件读取环境列表
function Get-AvailableEnvironments {
$ConfigPath = Join-Path $PSScriptRoot 'environments.json'
if (Test-Path $ConfigPath) {
$Config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json
return $Config.Environments
}
return @()
}

# 定义部署函数,带动态参数补全
function Publish-Project {
param(
[Parameter(Mandatory)]
[ArgumentCompleter({
param($commandName, $parameterName, $wordToComplete)
$EnvList = Get-AvailableEnvironments
foreach ($Env in $EnvList) {
$Name = $Env.Name
if ($Name -like "$wordToComplete*") {
$Detail = "$($Env.Region) - $($Env.Cluster)"
[System.Management.Automation.CompletionResult]::new(
$Name,
$Name,
'ParameterValue',
$Detail
)
}
}
})]
[string]$Environment,

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

[Parameter()]
[ValidateSet('patch', 'minor', 'major')]
[string]$VersionBump = 'patch'
)

Write-Host "正在部署项目: $ProjectName"
Write-Host "目标环境: $Environment"
Write-Host "版本升级类型: $VersionBump"
Write-Host "部署完成!"
}

假设 environments.json 的内容如下:

1
2
3
4
5
6
7
8
{
"Environments": [
{ "Name": "dev-east", "Region": "East Asia", "Cluster": "aks-dev-01" },
{ "Name": "qa-west", "Region": "West Europe", "Cluster": "aks-qa-01" },
{ "Name": "staging", "Region": "East Asia", "Cluster": "aks-stage-01" },
{ "Name": "production", "Region": "East Asia", "Cluster": "aks-prod-01" }
]
}

使用时在 -Environment 参数处按 Tab 即可看到所有可用环境,且每个环境都附带区域和集群信息提示。

1
2
3
4
正在部署项目: MyApi
目标环境: staging
版本升级类型: patch
部署完成!

为自定义命令批量注册补全器

在团队协作场景中,我们可能需要为一组内部工具函数统一注册参数补全。可以将补全逻辑集中定义,然后通过循环批量注册,避免代码重复。

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
# 定义一个哈希表,映射参数名到对应的补全脚本块
$CompleterMap = @{
# 服务器名称补全
ServerName = {
param($commandName, $parameterName, $wordToComplete)
$Servers = @('web-prod-01', 'web-prod-02', 'web-qa-01', 'db-prod-01')
foreach ($S in $Servers) {
if ($S -like "$wordToComplete*") {
[System.Management.Automation.CompletionResult]::new(
$S, $S, 'ParameterValue', $S
)
}
}
}

# 日志级别补全
LogLevel = {
param($commandName, $parameterName, $wordToComplete)
$Levels = @('TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL')
foreach ($L in $Levels) {
if ($L -like "$wordToComplete*") {
[System.Management.Automation.CompletionResult]::new(
$L, $L, 'ParameterValue', "日志级别: $L"
)
}
}
}
}

# 获取模块中所有函数,为匹配的参数名注册补全器
$Functions = @('Connect-AppServer', 'Get-AppLog', 'Restart-AppService')
foreach ($Func in $Functions) {
foreach ($ParamName in $CompleterMap.Keys) {
Register-ArgumentCompleter -CommandName $Func -ParameterName $ParamName `
-ScriptBlock $CompleterMap[$ParamName]
}
}

Write-Host '已为以下函数注册参数补全器:'
foreach ($Func in $Functions) {
Write-Host " - $Func"
}

执行后会确认所有补全器已注册完毕。

1
2
3
4
已为以下函数注册参数补全器:
- Connect-AppServer
- Get-AppLog
- Restart-AppService

注意事项

  1. 脚本块参数签名[ArgumentCompleter] 的脚本块必须接受四个参数($commandName$parameterName$wordToComplete$commandAst),即使你不使用它们。如果省略参数声明,PowerShell 无法正确传递用户已输入的部分文本,导致增量匹配失效。建议始终声明这四个参数。

  2. 性能影响:补全脚本块在每次用户按 Tab 时都会执行。如果补全逻辑涉及文件系统遍历、远程 API 调用或大量计算,会造成明显的延迟。对于耗时操作,建议在脚本块内加入结果缓存(例如将数据存储在脚本级变量中并设置过期时间),避免每次补全都重新查询。

  3. 补全结果去重:当数据源可能包含重复项时,补全列表中会出现重复条目,影响用户体验。建议在返回结果前使用 Select-Object -Unique 或哈希表去重,确保每个候选项只出现一次。

  4. 命名空间引用:创建 CompletionResult 对象时需要使用完整的类型名 [System.Management.Automation.CompletionResult]。如果你的脚本顶部已经通过 using namespace System.Management.Automation 引入了命名空间,则可以简写为 [CompletionResult]。但考虑到 profile 脚本和模块中不一定有该引用,使用完整类型名更加安全。

  5. Register-ArgumentCompleter 的作用域:通过 Register-ArgumentCompleter 注册的补全器仅在当前会话中生效。如果希望持久化,应将注册代码放入 PowerShell Profile($PROFILE)或模块的 .psm1 文件中。对于模块分发,推荐在模块的 FunctionsToExport 之外单独放置注册逻辑,确保模块加载时自动注册。

  6. 与 ValidateSet 的选择[ValidateSet()] 属性也能提供 Tab 补全,适用于固定的、少量且不经常变化的候选值(如日志级别、布尔选项)。但当候选值需要动态计算、来自外部数据源或数量较多时,应优先使用 [ArgumentCompleter]Register-ArgumentCompleter,因为 ValidateSet 在函数定义时就已经确定了候选列表,无法运行时更新。

PowerShell 技能连载 - ShouldProcess 确认机制

适用于 PowerShell 5.1 及以上版本

为什么需要 ShouldProcess 确认机制

在编写 PowerShell 高级函数时,如果函数会修改系统状态(比如删除文件、停止服务、修改注册表),直接执行这些破坏性操作可能会带来不可逆的后果。想象一下,你写了一个批量清理临时文件的脚本,一个参数写错就可能删掉整台服务器上的重要数据。

PowerShell 提供了一套标准的确认机制——SupportsShouldProcess,它让函数自动获得 -WhatIf-Confirm 两个通用参数。-WhatIf 让函数仅描述将要做什么而不实际执行,-Confirm 则在每次破坏性操作前要求用户确认。这不仅是 PowerShell 模块开发的最佳实践,也是 cmdlet 设计规范(Cmdlet Development Guidelines)中明确要求的标准行为。

本文将从基本用法、确认流程控制、以及批量操作场景三个方面,详细讲解如何在自定义函数中正确实现 ShouldProcess 确认机制。

ShouldProcess 基本用法

要让函数支持 -WhatIf-Confirm 参数,需要在 CmdletBinding 特性中声明 SupportsShouldProcess,然后在执行破坏性操作前调用 $PSCmdlet.ShouldProcess() 方法。下面的示例实现了一个安全的文件清理函数。

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
function Remove-OldTempFiles {
<#
.SYNOPSIS
清理指定目录中超过指定天数的临时文件
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string]$Path,

[int]$OlderThanDays = 30,

[string[]]$Extension = @('.tmp', '.log', '.bak')
)

# 验证目标目录是否存在
if (-not (Test-Path -Path $Path)) {
Write-Error "目录不存在: $Path"
return
}

$cutoffDate = (Get-Date).AddDays(-$OlderThanDays)
$files = Get-ChildItem -Path $Path -File -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Extension -in $Extension -and $_.LastWriteTime -lt $cutoffDate }

if ($files.Count -eq 0) {
Write-Host "未找到符合条件的文件" -ForegroundColor Yellow
return
}

Write-Host "找到 $($files.Count) 个符合条件的文件 (超过 $OlderThanDays 天)" -ForegroundColor Cyan

$deletedCount = 0
$totalSize = 0

foreach ($file in $files) {
# ShouldProcess 是核心:-WhatIf 时只输出描述,-Confirm 时弹出确认提示
if ($PSCmdlet.ShouldProcess($file.FullName, "删除文件")) {
try {
$totalSize += $file.Length
Remove-Item -Path $file.FullName -Force -ErrorAction Stop
$deletedCount++
Write-Host " [已删除] $($file.FullName)" -ForegroundColor Green
}
catch {
Write-Warning "删除失败: $($file.FullName) - $($_.Exception.Message)"
}
}
}

Write-Host "`n清理完成: 删除 $deletedCount 个文件, 释放 $([math]::Round($totalSize / 1MB, 2)) MB" -ForegroundColor Cyan
}

# 使用 -WhatIf 预览将要删除的文件,不会实际删除
Remove-OldTempFiles -Path 'C:\Temp' -OlderThanDays 7 -WhatIf
1
2
3
What if: Performing the operation "删除文件" on target "C:\Temp\install.log".
What if: Performing the operation "删除文件" on target "C:\Temp\cache.tmp".
What if: Performing the operation "删除文件" on target "C:\Temp\db_backup.bak".

上面的输出说明 -WhatIf 模式下列出了所有将要被删除的文件,但并没有真正执行删除操作。用户可以先确认列表无误,再去掉 -WhatIf 参数执行真正的清理。

确认流程与 ShouldContinue

ShouldProcess 适合大多数场景,但有时需要更精细的控制。PowerShell 还提供了 $PSCmdlet.ShouldContinue() 方法,它不受 $ConfirmPreference 变量影响,总是弹出确认提示。下面的示例演示了在一个服务管理函数中同时使用 ShouldProcessShouldContinue,在重启关键服务前进行二次确认。

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
function Restart-CriticalService {
<#
.SYNOPSIS
安全重启指定服务,关键服务需二次确认
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string]$Name,

[string]$ComputerName = $env:COMPUTERNAME
)

# 查询服务信息
$service = Get-Service -Name $Name -ComputerName $ComputerName -ErrorAction SilentlyContinue
if (-not $service) {
Write-Error "找不到服务: $Name"
return
}

# 定义关键服务列表,这些服务需要额外确认
$criticalServices = @('Winmgmt', 'EventLog', 'Dnscache', 'LanmanServer', 'Netlogon')

$target = "\\$ComputerName\Service:$Name"

# 第一层:ShouldProcess 检查(受 -WhatIf 和 -Confirm 控制)
if (-not $PSCmdlet.ShouldProcess($target, "重启服务")) {
return
}

# 第二层:如果是关键服务,用 ShouldContinue 强制二次确认
if ($Name -in $criticalServices) {
$message = "服务 '$Name' 是关键系统服务,重启可能导致系统不稳定。"
$caption = "确认重启关键服务"

# ShouldContinue 支持自定义 Yes/No 提示文本
$confirmed = $PSCmdlet.ShouldContinue($message, $caption)
if (-not $confirmed) {
Write-Host "操作已取消" -ForegroundColor Yellow
return
}
}

try {
# 记录当前状态
$originalStatus = $service.Status
Write-Host "服务当前状态: $originalStatus" -ForegroundColor Cyan

# 如果服务正在运行,先停止
if ($originalStatus -eq 'Running') {
Write-Host "正在停止服务 $Name..." -ForegroundColor Yellow
Stop-Service -Name $Name -Force -ComputerName $ComputerName -ErrorAction Stop
Start-Sleep -Seconds 2
}

# 启动服务
Write-Host "正在启动服务 $Name..." -ForegroundColor Yellow
Start-Service -Name $Name -ComputerName $ComputerName -ErrorAction Stop

# 验证最终状态
$finalStatus = (Get-Service -Name $Name -ComputerName $ComputerName).Status
Write-Host "服务重启完成,最终状态: $finalStatus" -ForegroundColor Green
}
catch {
Write-Error "服务重启失败: $($_.Exception.Message)"
}
}

# 普通服务:仅需 ShouldProcess 确认
Restart-CriticalService -Name 'Spooler' -Confirm

# 关键服务:ShouldProcess 通过后还会弹出 ShouldContinue 二次确认
Restart-CriticalService -Name 'Winmgmt' -Confirm
1
2
3
4
服务当前状态: Running
正在停止服务 Spooler...
正在启动服务 Spooler...
服务重启完成,最终状态: Running

关键服务执行时会弹出额外的确认对话框,只有用户明确选择”是”才会继续。这种双重确认机制在运维脚本中非常实用,可以有效防止误操作导致的系统故障。

批量操作中的确认影响级别

在批量操作场景中,如果每个操作都弹出确认提示,用户体验会很差。PowerShell 通过 $ConfirmPreference 变量和 ConfirmImpact 参数来控制确认行为。低影响操作可以自动执行,只有高影响操作才需要确认。下面的示例实现了一个批量服务管理函数,演示了不同影响级别的控制方式。

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
function Set-ServiceState {
<#
.SYNOPSIS
批量管理服务状态,支持影响级别控制
#>
[CmdletBinding(SupportsShouldProcess,
ConfirmImpact = 'Medium')]
param(
[Parameter(Mandatory)]
[string[]]$ServiceName,

[ValidateSet('Start', 'Stop', 'Restart')]
[string]$Action = 'Restart',

[int]$DelayBetweenActions = 1
)

$results = @()

foreach ($svc in $ServiceName) {
$service = Get-Service -Name $svc -ErrorAction SilentlyContinue
if (-not $service) {
Write-Warning "服务不存在: $svc"
$results += [PSCustomObject]@{
Service = $svc
Action = $Action
Status = 'NotFound'
}
continue
}

# 根据操作类型构建不同的确认描述
$operationDesc = switch ($Action) {
'Start' { '启动服务' }
'Stop' { '停止服务' }
'Restart' { '重启服务' }
}

# ShouldProcess 第二个参数是操作描述,会显示在确认提示中
if ($PSCmdlet.ShouldProcess($svc, $operationDesc)) {
try {
switch ($Action) {
'Start' { Start-Service -Name $svc -ErrorAction Stop }
'Stop' { Stop-Service -Name $svc -Force -ErrorAction Stop }
'Restart' {
Stop-Service -Name $svc -Force -ErrorAction Stop
Start-Sleep -Seconds $DelayBetweenActions
Start-Service -Name $svc -ErrorAction Stop
}
}

$finalState = (Get-Service -Name $svc).Status
Write-Host " [$Action] $svc -> $finalState" -ForegroundColor Green

$results += [PSCustomObject]@{
Service = $svc
Action = $Action
Status = $finalState
}
}
catch {
Write-Warning "操作失败: $svc - $($_.Exception.Message)"
$results += [PSCustomObject]@{
Service = $svc
Action = $Action
Status = "Failed: $($_.Exception.Message)"
}
}
}
else {
$results += [PSCustomObject]@{
Service = $svc
Action = $Action
Status = 'Skipped'
}
}
}

Write-Host "`n===== 操作结果汇总 =====" -ForegroundColor Cyan
$results | Format-Table -AutoSize
}

# 批量重启多个服务
# ConfirmImpact 为 Medium:$ConfirmPreference 默认为 High,不会逐个确认
Set-ServiceState -ServiceName 'Spooler', 'W32Time', 'Audiosrv' -Action Restart

# 强制逐个确认(覆盖 ConfirmImpact 设置)
Set-ServiceState -ServiceName 'Spooler', 'W32Time' -Action Stop -Confirm
1
2
3
4
5
6
7
8
9
10
  [Restart] Spooler -> Running
[Restart] W32Time -> Running
[Restart] Audiosrv -> Running

===== 操作结果汇总 =====
Service Action Status
------- ------ ------
Spooler Restart Running
W32Time Restart Running
Audiosrv Restart Running

第一组调用因为 ConfirmImpact = 'Medium' 低于默认的 $ConfirmPreference = 'High',所以三个服务自动执行,没有逐个弹出确认。第二组加了 -Confirm 参数,强制每个操作都需要用户确认,适合在关键环境中谨慎操作。

注意事项

  1. 始终声明 SupportsShouldProcess:任何会修改系统状态的高级函数(创建、删除、修改、停止等操作)都应声明 [CmdletBinding(SupportsShouldProcess)]。这是 PowerShell 的 cmdlet 设计规范要求,也是用户在使用 -WhatIf 时获得一致性体验的前提。没有声明该特性的函数,用户传入 -WhatIf-Confirm 时会报参数不存在的错误。

  2. 理解 ConfirmImpact 与 ConfirmPreference 的关系ShouldProcess 的确认行为由函数的 ConfirmImpact(默认 Medium)和用户会话的 $ConfirmPreference(默认 High)共同决定。只有当 ConfirmImpact 大于或等于 $ConfirmPreference 时才会自动弹出确认。显式使用 -Confirm 参数会强制弹出确认,无论影响级别如何。

  3. ShouldProcess 的三个参数ShouldProcess(target, action, description) 中,target 是操作对象(如文件路径、服务名),action 是操作类型(如”删除文件”),description 是补充说明。这三个参数会组合成 -WhatIf-Confirm 的提示信息,建议写得清晰明确,让用户一看就知道将要做什么。

  4. ShouldProcess 与 ShouldContinue 的区别ShouldProcess-WhatIf-Confirm$ConfirmPreference 控制,适合作为标准确认入口。ShouldContinue 不受这些机制控制,总是弹出确认提示,也不支持 -WhatIf,适合在 ShouldProcess 之后的二次验证场景。不要用 ShouldContinue 替代 ShouldProcess

  5. 批量操作避免逐项确认:在 foreach 循环中使用 ShouldProcess 时,每次迭代都会检查确认。如果批量处理数百个对象,逐项确认会让脚本无法使用。解决方法是将函数的 ConfirmImpact 设为 LowMedium(低于默认的 High),这样批量执行时不会弹出确认,但用户仍可通过 -Confirm 强制开启。

  6. 正确处理 ShouldProcess 返回 false 的场景:当 ShouldProcess 返回 $false 时(用户选择了否或处于 -WhatIf 模式),应立即跳过当前操作,不要继续执行后续的清理或状态更新逻辑。否则可能出现逻辑不一致,比如文件没删除但日志记录显示已删除。

PowerShell 技能连载 - Azure 成本管理

适用于 PowerShell 5.1 及以上版本

云上资源用起来方便,但月底账单一来往往让人心惊。Azure 提供了 Cost Management 服务,可以帮助我们追踪、分析和优化云支出。然而,通过门户网站手动查看报表既慢又不灵活,尤其当你管理多个订阅时,逐个点开查看效率极低。

PowerShell 结合 Az 模块和 Cost Management REST API,可以让我们以脚本化的方式自动采集费用数据、设置预算告警、识别闲置资源。这意味着你可以将成本管控嵌入 CI/CD 流水线,或者每天早上自动生成一份费用摘要邮件发给团队,从被动等账单变为主动管理成本。

本文将从实际场景出发,演示如何用 PowerShell 查询 Azure 订阅的消费明细、创建预算和告警规则、以及自动扫描可优化资源。所有操作均通过脚本完成,无需登录门户。

连接 Azure 并获取订阅信息

在开始之前,需要先安装 Az 模块并登录 Azure 账户。如果你已经安装过 Az 模块,可以跳过安装步骤直接连接。下面的代码展示如何连接 Azure 并列出当前账户下的所有订阅,包括每个订阅的计费状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 安装 Az 模块(如已安装可跳过)
# Install-Module -Name Az -Scope CurrentUser -Force

# 连接 Azure 账户
Connect-AzAccount

# 获取所有订阅信息
$subscriptions = Get-AzSubscription
foreach ($sub in $subscriptions) {
$info = [ordered]@{
Name = $sub.Name
Id = $sub.Id
State = $sub.State
TenantId = $sub.TenantId
}
New-Object -TypeName PSCustomObject -Property $info
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Name      : Production
Id : a1b2c3d4-e5f6-7890-abcd-ef1234567890
State : Enabled
TenantId : 11111111-2222-3333-4444-555555555555

Name : Development
Id : b2c3d4e5-f6a7-8901-bcde-f12345678901
State : Enabled
TenantId : 11111111-2222-3333-4444-555555555555

Name : Sandbox
Id : c3d4e5f6-a7b8-9012-cdef-123456789012
State : Disabled
TenantId : 11111111-2222-3333-4444-555555555555

连接成功后,我们就能基于订阅维度查询各项费用数据。建议在生产环境中使用服务主体(Service Principal)进行非交互式登录,避免手动输入凭据。

查询订阅消费明细

Azure Cost Management 提供了 REST API,可以通过 Invoke-AzRestMethod 调用来获取指定时间范围的费用数据。下面的函数封装了查询逻辑,支持按日期范围和服务类型筛选,返回格式化的费用报表。

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 Get-AzCostDetail {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$SubscriptionId,

[Parameter(Mandatory = $false)]
[DateTime]$StartDate = (Get-Date).AddDays(-30),

[Parameter(Mandatory = $false)]
[DateTime]$EndDate = (Get-Date),

[Parameter(Mandatory = $false)]
[int]$Top = 10
)

# 选择目标订阅上下文
$null = Set-AzContext -SubscriptionId $SubscriptionId

# 构建 Cost Management REST API 请求
$apiVersion = "2023-11-01"
$uri = "/subscriptions/$SubscriptionId/providers/Microsoft.CostManagement/query?api-version=$apiVersion"

# 构建请求体,按服务维度分组汇总
$body = @{
type = "ActualCost"
timeframe = "Custom"
timePeriod = @{
from = $StartDate.ToString("yyyy-MM-dd")
to = $EndDate.ToString("yyyy-MM-dd")
}
dataset = @{
granularity = "None"
aggregation = @{
totalCost = @{
name = "Cost"
function = "Sum"
}
}
grouping = @(
@{
type = "Dimension"
name = "ServiceName"
}
)
}
} | ConvertTo-Json -Depth 10

# 调用 REST API
$response = Invoke-AzRestMethod -Path $uri -Method POST -Payload $body

if ($response.StatusCode -ne 200) {
Write-Error "查询失败,状态码:$($response.StatusCode)"
Write-Error $response.Content
return
}

# 解析返回结果
$result = $response.Content | ConvertFrom-Json
$columns = $result.properties.columns
$rows = $result.properties.rows

# 按费用降序排列,取前 N 条
$sortedRows = $rows | Sort-Object { $_[0] } -Descending | Select-Object -First $Top

foreach ($row in $sortedRows) {
$costUSD = [math]::Round($row[0], 2)
$serviceName = $row[1]
$currency = $row[2]

$detail = [ordered]@{
ServiceName = $serviceName
CostUSD = $costUSD
Currency = $currency
}
New-Object -TypeName PSCustomObject -Property $detail
}
}

# 查询最近 30 天费用 Top 10 服务
Get-AzCostDetail -SubscriptionId "a1b2c3d4-e5f6-7890-abcd-ef1234567890" -Top 10

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
ServiceName                   CostUSD Currency
----------- -------- --------
Virtual Machines 342.58 USD
Azure SQL Database 128.30 USD
Storage Accounts 67.45 USD
App Service 52.00 USD
Azure Kubernetes Service 41.20 USD
Virtual Network 12.80 USD
Azure Monitor 8.60 USD
Key Vault 3.20 USD
Azure DNS 1.50 USD
Public IP Addresses 0.90 USD

从结果可以看到,虚拟机是最大的开支来源,占了总费用的近一半。这类数据可以帮助我们快速定位成本优化的重点方向。如果需要更细粒度的分析,可以把 grouping 维度改为 ResourceGroupNameResourceType

创建预算和告警规则

光看费用报表还不够,我们需要在超支之前收到告警。Azure Cost Management 支持创建预算(Budget),并在消费达到阈值时触发告警。下面的脚本演示如何创建月度预算,并设置两个告警阈值:当实际消费达到 80% 和 100% 时分别发送通知。

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
function Set-AzCostBudget {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$SubscriptionId,

[Parameter(Mandatory = $true)]
[string]$BudgetName,

[Parameter(Mandatory = $true)]
[decimal]$Amount,

[Parameter(Mandatory = $false)]
[string[]]$NotificationEmails = @("admin@contoso.com"),

[Parameter(Mandatory = $false)]
[string]$TimeZone = "UTC"
)

$null = Set-AzContext -SubscriptionId $SubscriptionId

$apiVersion = "2023-11-01"
$uri = "/subscriptions/$SubscriptionId/providers/Microsoft.Consumption/budgets/$BudgetName?api-version=$apiVersion"

$now = Get-Date
$year = $now.Year
$month = $now.Month

# 构建预算请求体
$body = @{
properties = @{
timePeriod = @{
startDate = "$year-$($month.ToString('00'))-01"
endDate = "$($year + 1)-12-31"
}
timeGrain = "Monthly"
amount = $Amount
currentSpend = @{
amount = 0
unit = "USD"
}
notifications = @{
Notification80 = @{
enabled = $true
operator = "GreaterThan"
threshold = 80
contactEmails = $NotificationEmails
}
Notification100 = @{
enabled = $true
operator = "GreaterThan"
threshold = 100
contactEmails = $NotificationEmails
}
}
}
} | ConvertTo-Json -Depth 10

$response = Invoke-AzRestMethod -Path $uri -Method PUT -Payload $body

if ($response.StatusCode -in @(200, 201)) {
$result = $response.Content | ConvertFrom-Json
Write-Host "预算 '$BudgetName' 创建成功!" -ForegroundColor Green
Write-Host " 月度预算金额: `$$Amount USD"
Write-Host " 告警阈值: 80%, 100%"
Write-Host " 通知邮箱: $($NotificationEmails -join ', ')"
Write-Host " 预算 ID: $($result.name)"
}
else {
Write-Error "预算创建失败,状态码:$($response.StatusCode)"
Write-Error $response.Content
}
}

# 创建月度 500 美元的预算
Set-AzCostBudget `
-SubscriptionId "a1b2c3d4-e5f6-7890-abcd-ef1234567890" `
-BudgetName "Monthly-Production-Budget" `
-Amount 500 `
-NotificationEmails @("admin@contoso.com", "finance@contoso.com")

执行结果示例:

1
2
3
4
5
预算 'Monthly-Production-Budget' 创建成功!
月度预算金额: $500 USD
告警阈值: 80%, 100%
通知邮箱: admin@contoso.com, finance@contoso.com
预算 ID: Monthly-Production-Budget

预算创建后,Azure 会在每月消费累计达到 400 美元(80%)时发邮件提醒你注意控制开支,达到 500 美元(100%)时再次告警。你也可以结合 Logic Apps 或 Azure Functions,在告警触发后自动执行扩缩容或关停非关键资源。

扫描可优化的闲置资源

很多云成本的浪费来自于闲置或低利用率资源。下面的脚本会扫描常见类型的闲置资源,包括未挂载的磁盘、未分配的公网 IP、停止的虚拟机以及空闲的 SQL 数据库,生成一份优化建议报告。

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
function Find-AzIdleResources {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$SubscriptionId
)

$null = Set-AzContext -SubscriptionId $SubscriptionId
$findings = @()

# 1. 检查停止状态的虚拟机
$vms = Get-AzVM -Status
foreach ($vm in $vms) {
$powerState = ($vm.Statuses | Where-Object { $_.Code -like "PowerState/*" }).Code
if ($powerState -eq "PowerState/deallocated") {
$estimatedMonthly = [math]::Round(
($vm.HardwareProfile.VmSize -match "Standard_" -and $true) * 0
)
$rgName = $vm.ResourceGroupName
$findings += [ordered]@{
ResourceType = "Virtual Machine"
ResourceName = $vm.Name
ResourceGroup = $rgName
Status = "Deallocated"
Suggestion = "已释放但仍保留配置,如不再使用建议删除"
Priority = "Medium"
}
}
elseif ($powerState -eq "PowerState/stopped") {
$findings += [ordered]@{
ResourceType = "Virtual Machine"
ResourceName = $vm.Name
ResourceGroup = $vm.ResourceGroupName
Status = "Stopped (not deallocated)"
Suggestion = "停止但未释放,仍在计费!建议 deallocate 或删除"
Priority = "High"
}
}
}

# 2. 检查未挂载的磁盘
$disks = Get-AzDisk
foreach ($disk in $disks) {
if ($null -eq $disk.ManagedBy -and $disk.DiskState -eq "Unattached") {
$sizeGB = $disk.DiskSizeGB
$sku = $disk.Sku.Name
$findings += [ordered]@{
ResourceType = "Managed Disk"
ResourceName = $disk.Name
ResourceGroup = $disk.ResourceGroupName
Status = "Unattached ($sizeGB GB, $sku)"
Suggestion = "未挂载的磁盘,删除可节省约 `$$([math]::Round($sizeGB * 0.05, 2))/月"
Priority = "High"
}
}
}

# 3. 检查未关联的公网 IP
$publicIps = Get-AzPublicIpAddress
foreach ($pip in $publicIps) {
if (-not $pip.IpConfiguration) {
$findings += [ordered]@{
ResourceType = "Public IP Address"
ResourceName = $pip.Name
ResourceGroup = $pip.ResourceGroupName
Status = "Unassociated"
Suggestion = "未关联的公网 IP,删除可节省约 `$3.65/月"
Priority = "Medium"
}
}
}

# 4. 检查暂停的 SQL 数据库
$sqlServers = Get-AzSqlServer
foreach ($server in $sqlServers) {
$databases = Get-AzSqlDatabase -ServerName $server.ServerName -ResourceGroupName $server.ResourceGroupName
foreach ($db in $databases) {
if ($db.Status -eq "Paused") {
$findings += [ordered]@{
ResourceType = "SQL Database"
ResourceName = "$($server.ServerName)/$($db.DatabaseName)"
ResourceGroup = $db.ResourceGroupName
Status = "Paused"
Suggestion = "已暂停但仍保留,确认是否需要恢复或删除"
Priority = "Low"
}
}
}
}

# 按优先级排序输出
$priorityOrder = @{ "High" = 1; "Medium" = 2; "Low" = 3 }
$sorted = $findings | Sort-Object { $priorityOrder[$_.Priority] }

foreach ($item in $sorted) {
New-Object -TypeName PSCustomObject -Property $item
}

Write-Host "`n共发现 $($findings.Count) 个可优化项,其中 High 优先级 $(($findings | Where-Object { $_.Priority -eq 'High' }).Count) 个。" -ForegroundColor Yellow
}

# 扫描生产订阅的闲置资源
Find-AzIdleResources -SubscriptionId "a1b2c3d4-e5f6-7890-abcd-ef1234567890"

执行结果示例:

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
ResourceType     : Virtual Machine
ResourceName : web-server-old
ResourceGroup : rg-legacy
Status : Stopped (not deallocated)
Suggestion : 停止但未释放,仍在计费!建议 deallocate 或删除
Priority : High

ResourceType : Managed Disk
ResourceName : web-server-old_OsDisk
ResourceGroup : rg-legacy
Status : Unattached (128 GB, Premium_LRS)
Suggestion : 未挂载的磁盘,删除可节省约 $6.40/
Priority : High

ResourceType : Public IP Address
ResourceName : pip-test-unused
ResourceGroup : rg-sandbox
Status : Unassociated
Suggestion : 未关联的公网 IP,删除可节省约 $3.65/
Priority : Medium

ResourceType : SQL Database
ResourceName : sql-prod/legacy-reporting
ResourceGroup : rg-database
Status : Paused
Suggestion : 已暂停但仍保留,确认是否需要恢复或删除
Priority : Low

共发现 4 个可优化项,其中 High 优先级 2 个。

扫描结果清楚地列出了每个闲置资源的类型、名称、所在资源组和优化建议。High 优先级的项应当优先处理,尤其是”停止但未释放”的虚拟机,它们虽然处于关机状态但仍然在产生计算费用。

生成月度成本摘要报告

将前面的查询和分析整合起来,可以生成一份结构化的月度成本摘要报告,方便通过邮件或即时消息发送给团队。

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
function New-AzCostSummaryReport {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string[]]$SubscriptionIds,

[Parameter(Mandatory = $false)]
[int]$DaysBack = 30
)

$reportLines = @()
$startDate = (Get-Date).AddDays(-$DaysBack)
$endDate = Get-Date

$reportLines += "=" * 60
$reportLines += "Azure 成本管理月度摘要报告"
$reportLines += "生成时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
$reportLines += "统计周期: $($startDate.ToString('yyyy-MM-dd')) 至 $($endDate.ToString('yyyy-MM-dd'))"
$reportLines += "=" * 60

$totalCostAll = 0

foreach ($subId in $SubscriptionIds) {
$sub = Get-AzSubscription -SubscriptionId $subId
$reportLines += ""
$reportLines += "--- 订阅: $($sub.Name) ($subId) ---"

try {
$costs = Get-AzCostDetail -SubscriptionId $subId -StartDate $startDate -EndDate $endDate -Top 5
$subTotal = 0

foreach ($item in $costs) {
$reportLines += " [$($item.ServiceName)] `$$($item.CostUSD) $($item.Currency)"
$subTotal += $item.CostUSD
}

$reportLines += " 小计: `$$([math]::Round($subTotal, 2))"
$totalCostAll += $subTotal
}
catch {
$reportLines += " [错误] 无法获取费用数据: $($_.Exception.Message)"
}
}

$reportLines += ""
$reportLines += "=" * 60
$reportLines += "所有订阅合计: `$$([math]::Round($totalCostAll, 2)) USD"
$reportLines += "=" * 60

# 输出到控制台
$reportLines | Write-Host

# 可选:保存到文件
$reportPath = ".\AzureCostSummary_$(Get-Date -Format 'yyyyMMdd').txt"
$reportLines | Out-File -FilePath $reportPath -Encoding UTF8
Write-Host "`n报告已保存至: $reportPath" -ForegroundColor Cyan
}

# 为多个订阅生成月度报告
New-AzCostSummaryReport -SubscriptionIds @(
"a1b2c3d4-e5f6-7890-abcd-ef1234567890"
"b2c3d4e5-f6a7-8901-bcde-f12345678901"
) -DaysBack 30

执行结果示例:

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
============================================================
Azure 成本管理月度摘要报告
生成时间: 2025-11-19 10:30:00
统计周期: 2025-10-20 至 2025-11-19
============================================================

--- 订阅: Production (a1b2c3d4-e5f6-7890-abcd-ef1234567890) ---
[Virtual Machines] $342.58 USD
[Azure SQL Database] $128.30 USD
[Storage Accounts] $67.45 USD
[App Service] $52.00 USD
[Azure Kubernetes Service] $41.20 USD
小计: $631.53

--- 订阅: Development (b2c3d4e5-f6a7-8901-bcde-f12345678901) ---
[Virtual Machines] $85.20 USD
[Storage Accounts] $12.30 USD
[App Service] $22.00 USD
[Azure Database for PostgreSQL] $18.50 USD
[Virtual Network] $5.60 USD
小计: $143.60

============================================================
所有订阅合计: $775.13 USD
============================================================

报告已保存至: .\AzureCostSummary_20251119.txt

注意事项

  1. API 版本兼容性:Cost Management REST API 版本迭代较快,本文使用 2023-11-01。如果遇到错误,请查阅 Azure 官方文档确认当前可用的最新稳定版本,并相应更新脚本中的 api-version 参数。

  2. 权限要求:执行成本查询需要订阅级别的 Microsoft.CostManagement/query/action 权限,创建预算需要 Microsoft.Consumption/budgets/write 权限。建议为自动化服务主体分配”成本管理读者”(Cost Management Reader)或”参与者”(Contributor)角色。

  3. 数据延迟:Azure Cost Management 的消费数据存在约 8-24 小时的延迟。如果你需要实时监控,建议结合 Azure Monitor 的指标告警,对关键资源(如虚拟机 CPU 利用率)设置实时阈值。

  4. 停止与释放的区别:Azure 虚拟机”停止”(Stopped)状态仍会收取计算费用,只有”释放”(Deallocated)状态才停止计费。在脚本中调用 Stop-AzVM 时务必加上 -StayProvisioned:$false 参数确保释放资源。

  5. 多订阅批量操作:在遍历多个订阅时,每次都需要通过 Set-AzContext 切换上下文。如果订阅数量很多,建议使用 -AsJob 参数将查询并行化,或者利用 Azure Resource Graph 进行跨订阅查询,效率更高。

  6. 费用估算的准确性:脚本中对闲置资源的费用估算是近似值,实际费用取决于 SKU、区域和预留实例折扣等因素。做最终决策前,建议在 Azure 门户的定价计算器中验证具体金额。

PowerShell 技能连载 - Windows Admin Center 自动化

适用于 PowerShell 5.1 及以上版本(Windows)

Windows Admin Center(简称 WAC)是微软推出的基于浏览器的服务器管理工具,它将传统的服务器管理器、故障排除工具和 PowerShell 集成到一个统一的 Web 界面中。对于管理本地数据中心或混合云环境的运维人员来说,WAC 提供了直观的图形化操作体验,覆盖从单台服务器到 Hyper-V 集群的全方位管理场景,包括事件查看、性能监控、文件服务、远程桌面等核心功能。

虽然 WAC 本身是图形界面工具,但它的底层大量调用 PowerShell 命令,并且提供了 REST API 和 PowerShell 模块来支持自动化操作。这意味着我们可以将 WAC 的能力嵌入到脚本流水线中,实现服务器批量配置、状态巡检、证书更新等重复性任务的自动化。尤其对于管理数十甚至上百台服务器的团队来说,通过 PowerShell 调用 WAC API 能够显著提升运维效率,减少手动操作的出错风险。

本文将介绍如何通过 PowerShell 与 Windows Admin Center 交互,涵盖连接管理、节点状态查询、批量操作以及通过 REST API 实现高级自动化场景,帮助你构建基于 WAC 的自动化运维工作流。

安装和连接 Windows Admin Center

在开始自动化操作之前,需要确保 WAC 的 PowerShell 模块已正确安装。WAC 本身以网关服务的形式运行在 Windows Server 上,客户端通过 HTTPS 访问。以下是安装模块和建立连接的基本操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 安装 Windows Admin Center 的 PowerShell 模块
# 注意:模块名称可能随版本更新变化,请以官方文档为准
Install-Module -Name WindowsAdminCenter -Scope CurrentUser -Force

# 定义 WAC 网关地址
$gateway = "https://wac.contoso.com"

# 获取网关信息,验证连通性
$gatewayInfo = Get-WacGateway -Endpoint $gateway

# 列出当前网关注册的所有服务器连接
$connections = Get-WacConnection -Endpoint $gateway

foreach ($conn in $connections) {
[PSCustomObject]@{
Name = $conn.Name
Type = $conn.Type
Status = $conn.ConnectionStatus
}
}

执行结果示例:

1
2
3
4
5
6
Name            Type        Status
---- ---- ------
SRV-DC01 Server Connected
SRV-FILE01 Server Connected
SRV-WEB01 Server Connected
HV-CLUSTER01 Cluster Connected

Get-WacGateway 用于检查网关服务是否可达,而 Get-WacConnection 则返回网关上注册的所有受管节点。通过遍历连接列表,运维人员可以快速掌握当前管理范围内的所有服务器和集群资源。如果某些节点显示 Disconnected,则需要排查网络或凭据问题。

查询服务器节点状态

连接建立后,最常见的需求是批量查询服务器的运行状态,包括操作系统版本、启动时间、CPU 和内存使用情况等。以下脚本演示如何通过 WAC 批量获取服务器信息。

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
# 定义目标服务器列表
$servers = @("SRV-DC01", "SRV-FILE01", "SRV-WEB01", "SRV-DB01")

# 存储结果
$results = @()

foreach ($server in $servers) {
# 通过 WAC 获取单台服务器的系统信息
$info = Invoke-WacCommand -Endpoint $gateway `
-ConnectionName $server `
-Command "Get-ComputerInfo" `
-ErrorAction SilentlyContinue

if ($null -ne $info) {
$results += [PSCustomObject]@{
ServerName = $server
OSVersion = $info.WindowsProductName
LastBoot = $info.OsLastBootUpTime
UptimeHours = [math]::Round(
((Get-Date) - $info.OsLastBootUpTime).TotalHours, 1
)
}
} else {
$results += [PSCustomObject]@{
ServerName = $server
OSVersion = "N/A"
LastBoot = "N/A"
UptimeHours = "N/A"
}
}
}

# 输出结果表格
$results | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
ServerName OSVersion                         LastBoot             UptimeHours
---------- --------- -------- -----------
SRV-DC01 Windows Server 2022 Datacenter 2025/11/10 06:30:00 192.5
SRV-FILE01 Windows Server 2022 Standard 2025/11/15 14:22:00 72.3
SRV-WEB01 Windows Server 2019 Datacenter 2025/11/01 09:00:00 407.0
SRV-DB01 N/A N/A N/A

脚本通过 Invoke-WacCommand 在远程服务器上执行 Get-ComputerInfo,然后提取操作系统版本和上次启动时间,并计算运行时长。对于无法连接的服务器(如 SRV-DB01),脚本不会中断,而是将对应字段标记为 N/A。这种容错设计在批量巡检场景中非常重要,可以确保单台服务器故障不影响整体任务。

通过 REST API 执行批量证书检查

除了使用专用 PowerShell 模块,WAC 还暴露了 REST API 接口,支持更灵活的集成方式。下面演示如何直接调用 WAC REST API 来批量检查服务器上即将过期的证书。

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
# 配置 WAC REST API 参数
$gateway = "https://wac.contoso.com"
$token = Get-WacAccessToken -Endpoint $gateway

# 定义请求头
$headers = @{
Authorization = "Bearer $token"
"Content-Type" = "application/json"
}

# 目标服务器列表
$servers = @("SRV-DC01", "SRV-WEB01", "SRV-DB01")

# 阈值:30 天内即将过期的证书
$warningDays = 30
$expiryThreshold = (Get-Date).AddDays($warningDays)

# 收集即将过期的证书信息
$expiringCerts = @()

foreach ($server in $servers) {
# 构造 PowerShell 命令,查询本地证书存储
$script = "Get-ChildItem -Path Cert:\LocalMachine\My | " +
"Where-Object { `$_.NotAfter -lt (Get-Date).AddDays($warningDays) } | " +
"Select-Object Subject, NotAfter, Thumbprint"

# 调用 WAC REST API 执行远程命令
$body = @{
command = $script
} | ConvertTo-Json

$uri = "$gateway/api/connections/$server/powershell"
$response = Invoke-RestMethod -Uri $uri -Method Post `
-Headers $headers -Body $body -SkipCertificateCheck

# 解析返回结果
foreach ($cert in $response.value) {
$expiringCerts += [PSCustomObject]@{
Server = $server
Subject = $cert.Subject
Expires = [datetime]$cert.NotAfter
Thumbprint = $cert.Thumbprint
DaysLeft = [math]::Floor(
([datetime]$cert.NotAfter - (Get-Date)).TotalDays
)
}
}
}

# 按剩余天数排序输出
$expiringCerts | Sort-Object DaysLeft | Format-Table -AutoSize

# 生成汇总报告
$reportPath = "C:\Reports\CertExpiryReport_$(Get-Date -Format 'yyyyMMdd').csv"
$expiringCerts | Export-Csv -Path $reportPath -NoTypeInformation -Encoding UTF8
Write-Output "证书过期报告已保存至:$reportPath"

执行结果示例:

1
2
3
4
5
6
7
Server   Subject                 Expires              Thumbprint                           DaysLeft
------ ------- ------- ---------- --------
SRV-WEB01 CN=api.contoso.com 2025/11/25 00:00:00 1A2B3C4D5E6F... 7
SRV-DC01 CN=wac.contoso.com 2025/12/10 00:00:00 7G8H9I0J1K2L... 22
SRV-DB01 CN=db.contoso.com 2025/12/15 00:00:00 A1B2C3D4E5F6... 27

证书过期报告已保存至:C:\Reports\CertExpiryReport_20251118.csv

这段脚本展示了 WAC REST API 的典型用法:先通过 Get-WacAccessToken 获取认证令牌,然后在循环中对每台服务器发起远程 PowerShell 命令调用。查询结果经过筛选和格式化后,既输出到控制台供即时查看,也导出为 CSV 文件用于存档或邮件通知。将此脚本配置为 Windows 计划任务,每天自动运行,就能实现证书过期的主动预警。

自动化服务器维护模式切换

在进行补丁安装或计划性维护时,通常需要将服务器置于维护模式以避免监控误报。以下脚本演示如何通过 WAC 批量管理服务器的维护状态。

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
# 维护操作类型:Enable(进入维护)或 Disable(退出维护)
$action = "Enable"
$maintenanceTag = "UnderMaintenance"

# 获取所有服务器连接
$allServers = Get-WacConnection -Endpoint $gateway |
Where-Object { $_.Type -eq "Server" }

# 选择需要维护的服务器(这里以名称包含 "WEB" 为例)
$targetServers = $allServers |
Where-Object { $_.Name -match "WEB" }

foreach ($srv in $targetServers) {
# 在注册表中写入维护标记
$setCmd = "Set-ItemProperty -Path " +
"'HKLM:\SOFTWARE\Operations\Maintenance' " +
"-Name 'InMaintenance' -Value " +
"$(if ($action -eq 'Enable') { '1' } else { '0' }) -Type DWord -Force"

$null = Invoke-WacCommand -Endpoint $gateway `
-ConnectionName $srv.Name `
-Command $setCmd

# 同时暂停 SCOM 监控(如果环境中有 SCOM)
if ($action -eq "Enable") {
Write-Output "[$($srv.Name)] 已进入维护模式"
} else {
Write-Output "[$($srv.Name)] 已退出维护模式"
}
}

# 验证维护状态
Write-Output "`n--- 当前维护状态 ---"
foreach ($srv in $targetServers) {
$checkCmd = "(Get-ItemProperty -Path " +
"'HKLM:\SOFTWARE\Operations\Maintenance' " +
"-Name 'InMaintenance' -ErrorAction SilentlyContinue).InMaintenance"

$status = Invoke-WacCommand -Endpoint $gateway `
-ConnectionName $srv.Name `
-Command $checkCmd

$state = if ($status -eq 1) { "维护中" } else { "正常" }
Write-Output " $($srv.Name): $state"
}

执行结果示例:

1
2
3
4
5
6
7
8
[SRV-WEB01] 已进入维护模式
[SRV-WEB02] 已进入维护模式
[SRV-WEB03] 已进入维护模式

--- 当前维护状态 ---
SRV-WEB01: 维护中
SRV-WEB02: 维护中
SRV-WEB03: 维护中

该脚本的设计思路是通过注册表键值来标记服务器的维护状态。进入维护时写入标记,退出时清除。这种轻量级的标记机制可以与现有的监控系统(如 SCOM、Zabbix)集成,监控脚本读取该注册表值后自动跳过处于维护状态的服务器,避免产生无效告警。

注意事项

  1. 网络与防火墙要求:WAC 网关默认监听 HTTPS 端口(通常为 443 或自定义端口),确保执行 PowerShell 脚本的客户端与 WAC 网关之间网络畅通,且防火墙允许对应端口的入站和出站流量。

  2. 身份认证配置:WAC 支持 Active Directory、本地 Windows 认证和 Azure AD 等多种认证方式。自动化脚本建议使用受约束的委派(CredSSP)或 Group Managed Service Account(gMSA)来管理凭据,避免在脚本中硬编码密码。

  3. API 版本兼容性:WAC 的 REST API 随版本迭代可能发生变化。在生产环境使用前,务必对照当前部署的 WAC 版本查阅官方 API 文档,避免因版本不匹配导致脚本执行失败。

  4. 并发与限流:批量操作大量服务器时,建议在循环中加入适当的间隔(如 Start-Sleep -Seconds 2),避免瞬间大量请求导致 WAC 网关服务过载或触发速率限制。

  5. 错误处理与日志记录:远程命令执行可能因网络波动、目标服务器离线或权限不足而失败。脚本中应使用 try/catch 块捕获异常,并将失败记录写入日志文件,便于事后排查。对于关键操作(如证书更新、服务重启),必须实现回滚机制。

  6. 安全传输与证书验证:WAC 必须使用 HTTPS 通信。自签名证书在测试环境可用,但生产环境应配置由企业 CA 或公共 CA 签发的正式证书。脚本中使用 Invoke-RestMethod 时注意 SkipCertificateCheck 参数仅用于测试环境,生产环境务必验证证书有效性。

PowerShell 技能连载 - Intune 设备管理

适用于 PowerShell 5.1 及以上版本

Microsoft Intune 是微软推出的云端移动设备管理(MDM)和移动应用管理(MAM)解决方案,已经成为企业终端设备管理的核心平台。随着混合办公模式的普及,IT 管理员需要一种高效的方式来批量查询、配置和管理通过 Intune 注册的设备,而不再局限于图形化控制台的手动操作。

PowerShell 配合 Microsoft Graph API,为 Intune 管理提供了强大的自动化能力。通过脚本化操作,管理员可以实现设备合规性巡检、批量策略下发、设备生命周期管理等日常任务,大幅减少重复劳动。尤其在管理成百上千台设备时,自动化脚本的价值更加显著。

本文将介绍如何使用 PowerShell 连接 Intune,查询设备信息、管理合规策略,以及批量执行设备操作,帮助你构建属于自己的 Intune 自动化管理工作流。

连接 Intune 环境

Intune 的管理接口已经统一到 Microsoft Graph API。首先需要安装 Microsoft Graph PowerShell SDK,然后使用具有 DeviceManagementManagedDevices.ReadWrite.All 权限的账户进行连接。

1
2
3
4
5
6
7
8
9
10
11
# 安装 Microsoft Graph PowerShell SDK(仅需执行一次)
Install-Module -Name Microsoft.Graph -Scope CurrentUser -Force

# 连接到 Microsoft Graph,指定 Intune 所需的权限范围
$Scopes = @(
'DeviceManagementManagedDevices.Read.All',
'DeviceManagementConfiguration.Read.All',
'DeviceManagementApps.Read.All'
)

Connect-MgGraph -Scopes $Scopes

连接成功后,PowerShell 会显示已认证的账户信息和授予的权限列表。

1
2
3
Welcome To Microsoft Graph!
Account : admin@contoso.onmicrosoft.com
Scopes : DeviceManagementManagedDevices.Read.All DeviceManagementConfiguration.Read.All

查询设备清单

连接建立后,最常见的操作是查询 Intune 中注册的所有设备。使用 Get-MgDeviceManagementManagedDevice 可以获取完整的设备列表,并按需筛选关键字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 获取所有 Intune 托管设备,提取关键属性
$Devices = Get-MgDeviceManagementManagedDevice -All -Property (
'id', 'deviceName', 'operatingSystem', 'osVersion',
'complianceState', 'managementState', 'lastSyncDateTime',
'emailAddress', 'model', 'manufacturer'
)

# 统计设备概况
$Total = $Devices.Count
$Compliant = ($Devices | Where-Object { $_.ComplianceState -eq 'compliant' }).Count
$NonCompliant = ($Devices | Where-Object { $_.ComplianceState -eq 'noncompliant' }).Count
$Inactive = ($Devices | Where-Object {
$_.LastSyncDateTime -lt (Get-Date).AddDays(-30)
}).Count

Write-Host "设备总数: $Total"
Write-Host "合规设备: $Compliant"
Write-Host "不合规设备: $NonCompliant"
Write-Host "超过30天未同步: $Inactive"

执行后会输出当前 Intune 租户的设备统计摘要。

1
2
3
4
设备总数: 128
合规设备: 112
不合规设备: 9
超过30天未同步: 7

生成设备合规性报告

在日常运维中,我们需要定期生成设备合规性报告,找出不合规的设备并分析原因。以下脚本将不合规设备导出为结构化 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
# 筛选不合规设备并生成报告
$ReportDate = Get-Date -Format 'yyyy-MM-dd'
$OutputPath = "Intune_Compliance_Report_$ReportDate.csv"

$NonCompliantDevices = $Devices | Where-Object {
$_.ComplianceState -eq 'noncompliant'
}

$Report = foreach ($Device in $NonCompliantDevices) {
[PSCustomObject]@{
设备名称 = $Device.DeviceName
操作系统 = $Device.OperatingSystem
系统版本 = $Device.OsVersion
合规状态 = $Device.ComplianceState
管理状态 = $Device.ManagementState
最后同步时间 = $Device.LastSyncDateTime
用户邮箱 = $Device.EmailAddress
设备型号 = $Device.Model
制造商 = $Device.Manufacturer
}
}

$Report | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
Write-Host "合规性报告已导出: $OutputPath ($($Report.Count) 条记录)"

运行后会生成包含不合规设备详情的 CSV 文件。

1
合规性报告已导出: Intune_Compliance_Report_2025-11-17.csv (9 条记录)

按操作系统分组统计

了解不同平台设备的分布情况,有助于制定针对性的管理策略。下面这段代码按操作系统分组统计,并以表格形式展示结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 按操作系统分组统计设备数量
$OsGroups = $Devices | Group-Object -Property OperatingSystem

$OsSummary = foreach ($Group in $OsGroups) {
$GroupDevices = $Group.Group
$CompliantCount = ($GroupDevices | Where-Object {
$_.ComplianceState -eq 'compliant'
}).Count

[PSCustomObject]@{
操作系统 = $Group.Name
设备数量 = $Group.Count
合规数量 = $CompliantCount
合规率 = '{0:P1}' -f ($CompliantCount / $Group.Count)
占总设备比 = '{0:P1}' -f ($Group.Count / $Total)
}
}

$OsSummary | Format-Table -AutoSize

执行结果会以表格形式展示各操作系统的设备分布和合规率。

1
2
3
4
5
6
操作系统    设备数量 合规数量 合规率 占总设备比
--------- -------- -------- ------ ----------
Windows 86 78 90.7% 67.2%
iOS 24 22 91.7% 18.8%
macOS 12 8 66.7% 9.4%
Android 6 4 66.7% 4.7%

批量设备操作

当需要对多台设备执行批量操作(如同步、重启、擦除)时,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
# 查找并同步超过 7 天未联系的设备
$StaleDays = 7
$StaleDevices = $Devices | Where-Object {
$null -ne $_.LastSyncDateTime -and
$_.LastSyncDateTime -lt (Get-Date).AddDays(-$StaleDays)
}

$SyncResults = foreach ($Device in $StaleDevices) {
try {
# 发送设备同步请求
Invoke-MgGraphRequest -Method POST `
-Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$($Device.Id)/syncDevice" `
-ErrorAction Stop

[PSCustomObject]@{
设备 = $Device.DeviceName
状态 = '同步已触发'
最后同步 = $Device.LastSyncDateTime
}
}
catch {
[PSCustomObject]@{
设备 = $Device.DeviceName
状态 = "失败: $($_.Exception.Message)"
最后同步 = $Device.LastSyncDateTime
}
}
}

$SyncResults | Format-Table -AutoSize
Write-Host "共处理 $($StaleDevices.Count) 台设备"

运行后可以看到每台设备的同步触发结果。

1
2
3
4
5
6
设备          状态         最后同步
---- ---- --------
DESKTOP-A01 同步已触发 2025-11-01T10:23:45Z
DESKTOP-B22 同步已触发 2025-11-03T14:12:00Z
LAPTOP-C07 失败: 403 2025-10-28T09:00:00Z
共处理 3 台设备

查询设备配置策略分配

Intune 的配置策略决定了设备的行为和安全基线。通过 Graph API 可以查看哪些策略已分配到哪些设备组,帮助排查策略未生效的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 获取所有设备配置策略
$Policies = Get-MgDeviceManagementDeviceConfiguration -All -Property (
'id', 'displayName', 'description', 'createdDateTime', 'lastModifiedDateTime'
)

Write-Host "已找到 $($Policies.Count) 条配置策略"
Write-Host ''

# 查看每条策略的分配目标
foreach ($Policy in $Policies) {
$Assignments = Get-MgDeviceManagementDeviceConfigurationAssignment `
-DeviceConfigurationId $Policy.Id -ErrorAction SilentlyContinue

$TargetCount = if ($Assignments) { $Assignments.Count } else { 0 }

[PSCustomObject]@{
策略名称 = $Policy.DisplayName
分配目标数 = $TargetCount
创建时间 = $Policy.CreatedDateTime
修改时间 = $Policy.LastModifiedDateTime
}
} | Format-Table -AutoSize

执行后会列出所有配置策略及其分配情况。

1
2
3
4
5
6
7
8
已找到 15 条配置策略

策略名称 分配目标数 创建时间 修改时间
-------- ---------- -------- --------
BitLocker 加密策略 3 2025-03-15T08:00:00 2025-10-20T14:30:00
Windows 更新策略 2 2025-04-01T10:00:00 2025-09-12T09:15:00
Endpoint Protection 基线 5 2025-02-20T07:00:00 2025-11-01T16:00:00
macOS 磁盘加密策略 1 2025-06-10T11:00:00 2025-08-05T10:00:00

注意事项

  1. 权限要求:Intune 管理操作需要通过 Connect-MgGraph 授予对应的 Graph API 权限。建议遵循最小权限原则,只申请实际需要的权限范围,避免使用过宽的权限。对于只读操作,使用 .Read 后缀的权限即可。

  2. API 限流:Microsoft Graph API 有请求频率限制。批量操作大量设备时,建议在循环中加入适当延迟(如 Start-Sleep -Milliseconds 200),避免触发 HTTP 429 Too Many Requests 错误。如果遇到限流,响应头中的 Retry-After 值会告知等待时间。

  3. 模块版本兼容:Microsoft Graph PowerShell SDK 更新频繁,不同版本间 cmdlet 名称和参数可能有差异。生产环境中建议锁定模块版本(RequiredVersion),并在升级前在测试环境验证脚本兼容性。

  4. 分页处理Get-MgDeviceManagementManagedDevice 使用 -All 参数会自动处理分页,一次性拉取所有数据。但设备量非常大(超过 10000 台)时,可能导致内存压力。可以考虑使用 -PageSize 参数分批获取。

  5. 时间格式注意:Graph API 返回的时间为 UTC 格式的 ISO 8601 字符串。在进行时间比较时,务必确保本机时间也转换为 UTC(使用 .ToUniversalTime()),否则可能导致时区偏差,影响过滤结果的准确性。

  6. 断开连接:脚本执行完毕后,务必调用 Disconnect-MgGraph 断开会话。长时间保持活跃的 Graph 会话不仅浪费资源,还可能在共享环境中带来安全隐患。建议在脚本的 finally 块中执行断开操作。

PowerShell 技能连载 - CI/CD 流水线集成

适用于 PowerShell 7.0 及以上版本(跨平台)

持续集成与持续交付(CI/CD)是现代 DevOps 实践的核心环节。无论是 GitHub Actions、Azure DevOps 还是 GitLab CI,流水线的编排本质上都是将一系列自动化步骤串联起来:代码拉取、依赖安装、测试执行、构建打包、环境部署。PowerShell 作为跨平台的脚本语言,天然适合承担这些步骤的”粘合剂”角色——它既能调用系统命令,又能解析结构化数据,还能与 REST API 交互,是构建 CI/CD 流水线的利器。

很多团队在编写 CI/CD 脚本时仍然依赖 Bash 或 Python,但 PowerShell 在 Windows 和 Linux 上行为一致的特性,加上对 JSON、XML、YAML 的原生支持,使得同一套脚本可以在不同运行器(runner)上无缝切换。尤其是在混合环境中管理 .NET 项目、Azure 资源或 Windows 工作负载时,PowerShell 的优势更加明显。将流水线逻辑封装为 PowerShell 模块后,还能实现跨仓库复用,减少重复维护成本。

本文将通过三个实战场景,展示如何用 PowerShell 构建可测试、可复用、可观测的 CI/CD 自动化脚本:包括流水线阶段编排与结果报告、版本号自动管理、以及部署前置检查。每个示例都尽量贴近真实项目中的使用方式,帮助你快速将 PowerShell 集成到现有流水线中。

流水线阶段编排与报告

在复杂的 CI/CD 流水线中,我们通常需要将构建过程拆分为多个阶段(如 lint、test、build、deploy),每个阶段可能包含若干步骤。以下脚本展示了如何用 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
# 定义流水线阶段
$stages = @(
@{
Name = 'Lint'
Script = { markdownlint source/_posts/*.md }
Critical = $false
},
@{
Name = 'Test'
Script = { Invoke-Pester -Path './tests' -Output Minimal }
Critical = $true
},
@{
Name = 'Build'
Script = { npx hexo generate }
Critical = $true
},
@{
Name = 'Deploy'
Script = { npx hexo deploy }
Critical = $true
}
)

# 执行每个阶段并记录结果
$results = foreach ($stage in $stages) {
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$success = $false
$errorMessage = ''

Write-Host "`n========== Stage: $($stage.Name) ==========" -ForegroundColor Cyan

try {
$output = & $stage.Script 2>&1
$success = $LASTEXITCODE -eq 0 -or $null -eq $LASTEXITCODE
if (-not $success) {
$errorMessage = ($output | Select-String -Pattern 'error|fail' -SimpleMatch |
Select-Object -First 3) -join '; '
}
}
catch {
$success = $false
$errorMessage = $_.Exception.Message
}

$stopwatch.Stop()

$statusIcon = if ($success) { 'PASS' } else { 'FAIL' }
Write-Host " [$statusIcon] $($stage.Name) ($($stopwatch.Elapsed.ToString('mm\:ss')))" -ForegroundColor $(if ($success) { 'Green' } else { 'Red' })

[PSCustomObject]@{
Stage = $stage.Name
Status = if ($success) { 'Passed' } else { 'Failed' }
Duration = $stopwatch.Elapsed
Critical = $stage.Critical
Error = $errorMessage
}

# 关键阶段失败则中止流水线
if (-not $success -and $stage.Critical) {
Write-Host "`n流水线因关键阶段 [$($stage.Name)] 失败而中止。" -ForegroundColor Red
break
}
}

# 输出汇总报告
Write-Host "`n===== Pipeline Summary =====" -ForegroundColor Yellow
foreach ($r in $results) {
$icon = if ($r.Status -eq 'Passed') { '[PASS]' } else { '[FAIL]' }
Write-Host (" {0} {1} ({2})" -f $icon, $r.Stage, $r.Duration.ToString('mm\:ss'))
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
========== Stage: Lint ==========
[PASS] Lint (00:03)

========== Stage: Test ==========
[PASS] Test (00:12)

========== Stage: Build ==========
[FAIL] Build (00:08)

流水线因关键阶段 [Build] 失败而中止。

===== Pipeline Summary =====
[PASS] Lint (00:03)
[PASS] Test (00:12)
[FAIL] Build (00:08)

这段脚本的核心设计是 $stages 数组,每个阶段用哈希表描述名称、执行脚本块和是否为关键阶段。Critical 标记决定该阶段失败后是否中止后续阶段。执行结果收集到 $results 中,最终输出汇总报告。这种方式比在 YAML 中堆砌大量 if-failure 条件更直观,也便于在本地调试时单独运行某个阶段。

自动化版本号管理

语义化版本(Semantic Versioning)是 CI/CD 中版本发布的基石。每次发布前手动修改版本号既容易出错,又难以追溯。以下脚本展示了如何基于 Git 标签自动计算下一个版本号,并更新项目文件。

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
# 获取当前最新 Git 标签
$latestTag = git tag --sort=-v:refname | Select-Object -First 1

if ($latestTag -match '^v(\d+)\.(\d+)\.(\d+)$') {
$major = [int]$Matches[1]
$minor = [int]$Matches[2]
$patch = [int]$Matches[3]
}
else {
# 没有有效标签时使用默认版本
$major, $minor, $patch = 0, 1, 0
}

# 根据本次提交的变更内容决定版本递增策略
$commitMessages = git log "$latestTag..HEAD" --pretty=format:'%s'
$bumpType = 'patch' # 默认递增补丁号

foreach ($msg in $commitMessages) {
if ($msg -match '^feat!?:') {
# 包含 feat 提交时递增次版本号
$bumpType = 'minor'
}
if ($msg -match 'BREAKING CHANGE') {
# 包含破坏性变更时递增主版本号
$bumpType = 'major'
break
}
}

# 计算新版本号
switch ($bumpType) {
'major' { $major++; $minor = 0; $patch = 0 }
'minor' { $minor++; $patch = 0 }
'patch' { $patch++ }
}

$newVersion = "{0}.{1}.{2}" -f $major, $minor, $patch
$prerelease = ''
if ($env:GITHUB_REF -and $env:GITHUB_REF -ne "refs/heads/main") {
# 非主分支自动添加预发布标识
$shortSha = git rev-parse --short HEAD
$prerelease = "-beta.$shortSha"
}

$fullVersion = "v${newVersion}${prerelease}"

Write-Host "上一个版本: $latestTag"
Write-Host "版本递增策略: $bumpType"
Write-Host "新版本号: $fullVersion"

# 输出到 GitHub Actions 环境变量(如果在 CI 中运行)
if ($env:GITHUB_OUTPUT) {
"version=$fullVersion" | Out-File -Append -Encoding utf8 $env:GITHUB_OUTPUT
"numeric_version=$newVersion" | Out-File -Append -Encoding utf8 $env:GITHUB_OUTPUT
}

执行结果示例:

1
2
3
上一个版本: v1.3.2
版本递增策略: minor
新版本号: v1.4.0

这段脚本遵循 Conventional Commits 规范来决定版本递增策略:遇到 feat: 提交递增次版本号,遇到 BREAKING CHANGE 递增主版本号,其余情况递增补丁号。非主分支的构建会自动追加预发布标识。通过 $env:GITHUB_OUTPUT 将结果写入 GitHub Actions 的输出变量,后续步骤可以直接引用 ${{ steps.version.outputs.version }}

部署前置检查脚本

在执行正式部署之前,进行一系列环境就绪检查可以有效防止生产事故。以下脚本演示了如何对目标环境进行健康检查、配置验证和依赖可用性探测。

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
# 部署目标配置
$targetConfig = @{
AppName = 'my-webapp'
HealthUrl = 'https://my-webapp.example.com/health'
MinDiskGB = 10
RequiredEnv = @('DATABASE_URL', 'REDIS_URL', 'JWT_SECRET')
DatabaseHost = 'db.internal.example.com'
DatabasePort = 5432
}

$checks = @(
@{
Name = '磁盘空间'
Test = {
$drive = Get-PSDrive -Name ($IsLinux ? '/' : 'C')
$freeGB = [math]::Round($drive.Free / 1GB, 2)
$freeGB -ge $targetConfig.MinDiskGB
}
Detail = {
$drive = Get-PSDrive -Name ($IsLinux ? '/' : 'C')
$freeGB = [math]::Round($drive.Free / 1GB, 2)
"可用 {0:N2} GB(要求 >= {1} GB)" -f $freeGB, $targetConfig.MinDiskGB
}
},
@{
Name = '环境变量'
Test = {
$missing = @()
foreach ($envName in $targetConfig.RequiredEnv) {
if (-not [Environment]::GetEnvironmentVariable($envName)) {
$missing += $envName
}
}
$missing.Count -eq 0
}
Detail = {
$missing = @()
foreach ($envName in $targetConfig.RequiredEnv) {
if (-not [Environment]::GetEnvironmentVariable($envName)) {
$missing += $envName
}
}
if ($missing.Count -gt 0) {
"缺少: " + ($missing -join ', ')
}
else {
'所有必需环境变量已配置'
}
}
},
@{
Name = '数据库连通性'
Test = {
try {
$tcp = [System.Net.Sockets.TcpClient]::new()
$tcp.Connect($targetConfig.DatabaseHost, $targetConfig.DatabasePort)
$tcp.Close()
$true
}
catch {
$false
}
}
Detail = {
"{0}:{1} TCP 连接测试" -f $targetConfig.DatabaseHost, $targetConfig.DatabasePort
}
},
@{
Name = '应用健康端点'
Test = {
try {
$response = Invoke-WebRequest -Uri $targetConfig.HealthUrl `
-TimeoutSec 10 -UseBasicParsing
$response.StatusCode -eq 200
}
catch {
$false
}
}
Detail = {
"GET $($targetConfig.HealthUrl)"
}
}
)

# 执行所有检查
Write-Host "Deploy Pre-flight Checks for [$($targetConfig.AppName)]`n" -ForegroundColor Cyan

$allPassed = $true
foreach ($check in $checks) {
$passed = & $check.Test
$detail = & $check.Detail

$icon = if ($passed) { '[PASS]' } else { '[FAIL]' }
$color = if ($passed) { 'Green' } else { 'Red' }
Write-Host (" {0} {1} - {2}" -f $icon, $check.Name, $detail) -ForegroundColor $color

if (-not $passed) {
$allPassed = $false
}
}

# 根据检查结果决定是否继续部署
if ($allPassed) {
Write-Host "`n所有检查通过,可以继续部署。" -ForegroundColor Green
if ($env:GITHUB_OUTPUT) {
"deploy_ready=true" | Out-File -Append -Encoding utf8 $env:GITHUB_OUTPUT
}
}
else {
Write-Host "`n存在未通过的检查项,请修复后再部署。" -ForegroundColor Red
if ($env:GITHUB_OUTPUT) {
"deploy_ready=false" | Out-File -Append -Encoding utf8 $env:GITHUB_OUTPUT
}
exit 1
}

执行结果示例:

1
2
3
4
5
6
7
8
Deploy Pre-flight Checks for [my-webapp]

[PASS] 磁盘空间 - 可用 45.32 GB(要求 >= 10 GB)
[FAIL] 环境变量 - 缺少: JWT_SECRET
[PASS] 数据库连通性 - db.internal.example.com:5432 TCP 连接测试
[PASS] 应用健康端点 - GET https://my-webapp.example.com/health

存在未通过的检查项,请修复后再部署。

这个脚本的架构是”配置 + 检查列表”模式。每个检查项包含 Test(判断是否通过)和 Detail(输出具体信息)两个脚本块。这种声明式的写法使得新增检查项非常简单——只需在 $checks 数组中追加一个哈希表即可。检查结果通过 $env:GITHUB_OUTPUT 传递给后续流水线步骤,实现”前置检查通过才执行部署”的门控逻辑。

注意事项

  1. 跨平台兼容性:PowerShell 7 在 Linux 和 macOS 上的行为与 Windows 基本一致,但部分命令存在差异。例如 Get-PSDrive 在 Linux 上只返回文件系统挂载点,Windows 上则包含注册表驱动器。编写跨平台脚本时应充分测试,可利用 $IsLinux$IsMacOS$IsWindows 自动变量做条件分支。

  2. 错误处理策略:CI/CD 环境中的脚本应始终使用 $ErrorActionPreference = 'Stop',确保未捕获的异常立即终止脚本,避免”静默失败”导致错误的部署结果。对于预期可能失败的步骤(如网络探测),应使用 try/catch 显式捕获异常并记录原因。

  3. 敏感信息管理:流水线脚本中切勿硬编码密码、Token 等敏感信息。应通过 CI/CD 平台的安全变量(GitHub Secrets、Azure DevOps Variable Groups)注入,脚本通过 $env:SECRET_NAME 读取。输出日志时注意脱敏,避免 Write-Host 打印包含凭据的变量。

  4. 幂等性设计:部署脚本应当是幂等的——重复执行不应产生副作用。例如创建目录前先检查是否已存在,数据库迁移脚本应判断变更是否已应用。这样当流水线因网络超时等原因重试时,不会导致重复部署或数据损坏。

  5. 日志与可观测性:CI/CD 脚本应输出结构化的执行日志,包括时间戳、阶段名称和执行结果。建议在关键节点使用 GitHub Actions 的 ::group::::endgroup:: 标记对日志进行分组,方便在流水线界面中折叠和展开。对于长时间运行的步骤,还应输出进度信息以便排查卡顿。

  6. 脚本模块化与测试:将流水线逻辑封装为 .psm1 模块后,可以使用 Pester 编写单元测试,确保每次修改不会引入回归。模块化的另一个好处是跨仓库复用——通过 Git Submodule 或 PowerShell Gallery 分发,多个项目可以共享同一套经过验证的部署脚本,减少维护负担。

PowerShell 技能连载 - Entra ID 应用注册

适用于 PowerShell 5.1 及以上版本

在企业云环境中,Entra ID(原 Azure AD)的应用注册(App Registration)是构建自动化和集成方案的基础。无论是让 CI/CD 流水线访问 Azure 资源,还是让自定义应用调用 Microsoft Graph API,都需要先在 Entra ID 中注册一个应用程序,并为其配置权限和凭据。手动在 Azure 门户中点击操作不仅效率低下,而且难以保持多个环境(开发、测试、生产)之间的一致性。

通过 PowerShell 的 Microsoft.Graph 模块,我们可以将应用注册的全生命周期管理纳入代码化流程。从创建应用、配置 API 权限、添加客户端密钥,到创建服务主体并授予角色权限,每一步都可以脚本化、参数化,然后集成到基础设施即代码(IaC)流水线中。这种方式不仅提高了效率,更重要的是让安全团队能够审查和审计每一次应用注册的变更。

本文将介绍如何使用 PowerShell 完成 Entra ID 应用注册的常见操作,包括创建应用并配置基本属性、管理客户端密钥和凭据、配置 API 权限委派,以及批量审计租户内的应用注册信息,帮助你构建安全可控的身份管理自动化方案。

创建应用注册并配置基本属性

使用 Microsoft Graph PowerShell SDK 可以快速创建应用注册,并在创建时设置显示名称、回调 URL 等属性。以下示例演示如何创建一个面向 Web 应用的注册。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 连接到 Microsoft Graph(需要 Application.ReadWrite.All 权限)
Connect-MgGraph -Scopes "Application.ReadWrite.All"

# 定义应用注册的基本参数
$appName = "CI-CD-Pipeline-App"
$signInAudience = "AzureADMyOrg"
$redirectUris = @(
"https://localhost:3000/auth/callback",
"https://app.contoso.com/auth/callback"
)

# 创建应用注册
$newApp = New-MgApplication -DisplayName $appName `
-SignInAudience $signInAudience `
-Web @{ RedirectUris = $redirectUris }

Write-Host "应用注册创建成功"
Write-Host " AppId (Client ID): $($newApp.AppId)"
Write-Host " ObjectId: $($newApp.Id)"
Write-Host " DisplayName: $($newApp.DisplayName)"

执行结果示例:

1
2
3
4
应用注册创建成功
AppId (Client ID): a3b2c1d4-e5f6-7890-abcd-ef1234567890
ObjectId: 11111111-2222-3333-4444-555555555555
DisplayName: CI-CD-Pipeline-App

New-MgApplication 是 Microsoft Graph PowerShell SDK 中创建应用注册的核心命令。SignInAudience 参数控制应用的适用范围:AzureADMyOrg 表示仅限当前租户的单租户应用,AzureADMultipleOrgs 为多租户应用,AzureADandPersonalMicrosoftAccount 则同时支持个人微软账户。Web 参数接受一个哈希表,用于配置 Web 平台的 redirect URI 等设置。

管理客户端密钥与凭据

应用注册创建后,通常需要为其添加客户端密钥(Client Secret)或证书凭据,以便应用程序在运行时进行身份验证。以下脚本展示如何添加密钥并管理其生命周期。

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
# 获取目标应用的 ObjectId
$app = Get-MgApplication -Filter "displayName eq 'CI-CD-Pipeline-App'"

# 创建客户端密钥,有效期 6 个月
$secretStartDate = Get-Date
$secretEndDate = $secretStartDate.AddMonths(6)

$passwordCred = @{
DisplayName = "Pipeline-Secret-$(Get-Date -Format 'yyyyMMdd')"
StartDateTime = $secretStartDate
EndDateTime = $secretEndDate
}

$secret = Add-MgApplicationPassword -ApplicationId $app.Id `
-PasswordCredential $passwordCred

Write-Host "客户端密钥已添加"
Write-Host " KeyId: $($secret.KeyId)"
Write-Host " SecretKey: $($secret.SecretText)"
Write-Host " 有效期至: $secretEndDate"
Write-Host ""
Write-Host "重要:请立即将 SecretText 安全保存,此值仅在创建时显示一次。"

# 列出该应用所有凭据,检查即将过期的密钥
$allCreds = Get-MgApplication -ApplicationId $app.Id
$expiredCount = 0
$expiringSoon = @()

foreach ($cred in $allCreds.PasswordCredentials) {
$remaining = ($cred.EndDateTime - (Get-Date)).Days
if ($remaining -le 0) {
$expiredCount++
}
if ($remaining -gt 0 -and $remaining -le 30) {
$expiringSoon += $cred
}
}

Write-Host "`n凭据状态统计:"
Write-Host " 已过期: $expiredCount 个"
Write-Host " 即将过期(30天内): $($expiringSoon.Count) 个"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
客户端密钥已添加
KeyId: aaaabbbb-cccc-dddd-eeee-ffffffffffff
SecretKey: abc123XYZ789~def456.GHI890-ghi
有效期至: 05/13/2026 00:00:00

重要:请立即将 SecretText 安全保存,此值仅在创建时显示一次。

凭据状态统计:
已过期: 1 个
即将过期(30天内): 2 个

Add-MgApplicationPassword 为应用添加密码凭据,返回的 SecretText 是密钥的明文值,仅在此刻可见。务必将其存入密钥管理服务(如 Azure Key Vault),切勿写入代码或配置文件。定期扫描即将过期的密钥并提前轮换,是保障服务连续性的关键措施。建议在 CI/CD 流水线中添加自动化检查,当密钥即将过期时自动触发告警。

配置 API 权限并创建服务主体

应用注册的另一个关键步骤是声明所需的 API 权限,然后创建服务主体(Enterprise Application)以获取实际访问能力。以下脚本演示如何配置 Microsoft Graph 的委派权限并创建服务主体。

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
# 获取 Microsoft Graph 服务主体的 AppId(租户内固定值)
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"

# 定义需要申请的委派权限(Delegated Permission)
# User.Read - 读取当前登录用户的基本信息
# Mail.Read - 读取用户邮件
# Calendars.Read - 读取用户日历
$requiredPermissions = @(
@{ Id = "e1fe6dd8-ba31-4d61-89e7-88639da4683d"; Type = "Scope" }
@{ Id = "810c84a8-4a9e-49e6-bf7d-1441c4f84e72"; Type = "Scope" }
@{ Id = "465a38f9-76ea-45b9-9f34-2e6e3c3c7a0b"; Type = "Scope" }
)

$app = Get-MgApplication -Filter "displayName eq 'CI-CD-Pipeline-App'"

# 构造 requiredResourceAccess 对象
$resourceAccess = foreach ($perm in $requiredPermissions) {
@{
Id = $perm.Id
Type = $perm.Type
}
}

# 更新应用的 API 权限声明
Update-MgApplication -ApplicationId $app.Id `
-RequiredResourceAccess @(
@{
ResourceAppId = $graphSp.AppId
ResourceAccess = @($resourceAccess)
}
)

Write-Host "API 权限已配置,等待管理员同意..."

# 创建服务主体(如已存在则跳过)
$existingSp = Get-MgServicePrincipal -Filter "appId eq '$($app.AppId)'" -ErrorAction SilentlyContinue
if (-not $existingSp) {
$sp = New-MgServicePrincipal -AppId $app.AppId
Write-Host "服务主体已创建,ObjectId: $($sp.Id)"
} else {
Write-Host "服务主体已存在,ObjectId: $($existingSp.Id)"
}

Write-Host "`n注意:委派权限需要管理员通过 Azure 门户或以下 URL 进行同意:"
Write-Host "https://login.microsoftonline.com/$((Get-MgContext).TenantId)/adminconsent?client_id=$($app.AppId)"

执行结果示例:

1
2
3
4
5
API 权限已配置,等待管理员同意...
服务主体已创建,ObjectId: 66666666-7777-8888-9999-000000000000

注意:委派权限需要管理员通过 Azure 门户或以下 URL 进行同意:
https://login.microsoftonline.com/contoso.onmicrosoft.com/adminconsent?client_id=a3b2c1d4-e5f6-7890-abcd-ef1234567890

API 权限分为两种类型:Scope(委派权限,代表用户执行操作)和 Role(应用程序权限,应用以自身身份执行操作)。RequiredResourceAccess 仅声明了应用”需要”哪些权限,实际生效还需要管理员通过同意流程(Admin Consent)审批。权限 ID 可以通过查询目标 API 服务主体的 Oauth2PermissionScopes(委派权限)或 AppRoles(应用程序权限)属性获取。

批量审计租户内的应用注册

对于安全团队和 IT 管理员而言,定期审计租户内的应用注册状况是保障身份安全的重要环节。以下脚本批量获取所有应用注册,并生成包含凭据状态、权限范围等信息的审计报告。

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
# 获取租户内所有应用注册
$allApps = Get-MgApplication -All

$auditDate = Get-Date -Format "yyyyMMdd"
$reportPath = "$HOME/EntraID_AppAudit_$auditDate.csv"

$auditRows = foreach ($application in $allApps) {
# 检查凭据状态
$hasPassword = $application.PasswordCredentials.Count -gt 0
$hasKeyCert = $application.KeyCredentials.Count -gt 0
$expiredCreds = @($application.PasswordCredentials | Where-Object {
$_.EndDateTime -lt (Get-Date)
})
$expiringCreds = @($application.PasswordCredentials | Where-Object {
$remaining = ($_.EndDateTime - (Get-Date)).Days
$remaining -gt 0 -and $remaining -le 90
})

# 统计申请的 API 权限数量
$apiCount = 0
foreach ($resource in $application.RequiredResourceAccess) {
$apiCount += $resource.ResourceAccess.Count
}

[PSCustomObject]@{
AppId = $application.AppId
DisplayName = $application.DisplayName
CreatedDateTime = $application.CreatedDateTime
SignInAudience = $application.SignInAudience
HasClientSecret = $hasPassword
HasCertificate = $hasKeyCert
ExpiredCredCount = $expiredCreds.Count
ExpiringCredCount = $expiringCreds.Count
ApiPermissionCount = $apiCount
PublisherDomain = $application.PublisherDomain
}
}

# 导出审计报告
$auditRows | Export-Csv -Path $reportPath -NoTypeInformation -Encoding UTF8

# 输出概要统计
$totalApps = $auditRows.Count
$withExpiredCreds = @($auditRows | Where-Object { $_.ExpiredCredCount -gt 0 }).Count
$withExpiringCreds = @($auditRows | Where-Object { $_.ExpiringCredCount -gt 0 }).Count
$multiTenant = @($auditRows | Where-Object { $_.SignInAudience -ne "AzureADMyOrg" }).Count

Write-Host "审计报告已导出: $reportPath"
Write-Host "`n概要统计:"
Write-Host " 应用总数: $totalApps"
Write-Host " 含已过期凭据: $withExpiredCreds"
Write-Host " 含即将过期凭据: $withExpiringCreds"
Write-Host " 多租户应用: $multiTenant"

执行结果示例:

1
2
3
4
5
6
7
审计报告已导出: /home/user/EntraID_AppAudit_20251113.csv

概要统计:
应用总数: 87
含已过期凭据: 12
含即将过期凭据: 5
多租户应用: 8

这段脚本对租户内所有应用注册进行了全面审计,重点关注三个安全维度:凭据生命周期(是否存在过期或即将过期的密钥)、权限范围(申请了多少 API 权限)、应用可见性(是否为多租户应用,意味着其他组织的用户也能看到)。建议将此脚本配置为每周自动执行,配合邮件或 Teams 通知,在凭据即将过期或出现异常配置时及时预警。

注意事项

  1. 模块安装与版本:本文使用 Microsoft.Graph.Applications 模块(属于 Microsoft Graph PowerShell SDK v2),安装命令为 Install-Module Microsoft.Graph.Applications -Scope CurrentUser。请确保模块版本不低于 2.0,旧版 AzureAD 模块已停止更新,不建议在新项目中使用。

  2. 权限最小化原则:创建和管理应用注册需要 Application.ReadWrite.All 等高权限范围。在生产环境中,建议使用专用的高权限账户执行管理操作,日常应用开发使用 Application.Read.All 只读权限进行查询。遵循最小权限原则,降低因凭据泄露导致的攻击面。

  3. 密钥安全存储:客户端密钥(Client Secret)仅在创建时返回一次明文值,务必立即存入 Azure Key Vault 或其他受认可的密钥管理系统。禁止将密钥硬编码在代码中、写入配置文件或提交到 Git 仓库。对于生产环境,优先使用证书凭据而非密码凭据,安全性更高。

  4. 管理员同意流程:应用声明 API 权限后,部分高敏感权限(如 Mail.ReadFiles.Read.All)需要全局管理员在 Azure 门户或通过同意 URL 手动审批。自动化场景中可以使用 New-MgOauth2PermissionGrant 命令编程式同意,但这本身也需要 DelegatedPermissionGrant.Write.All 权限。

  5. 多租户应用的安全风险:当 SignInAudience 设为多租户模式时,其他 Entra ID 租户的用户也可以登录并授权该应用。在创建多租户应用前,务必评估是否有业务需求,并在应用中实现必要的租户验证逻辑,防止未预期的跨租户访问。

  6. 定期清理废弃应用:随着时间推移,租户中会积累大量不再使用的应用注册。建议定期运行审计脚本,标记超过 90 天无登录记录、凭据已过期且未续期的应用,经安全团队确认后通过 Remove-MgApplication 删除,减少潜在的攻击入口。

PowerShell 技能连载 - VS Code 扩展开发

适用于 PowerShell 7.0 及以上版本

VS Code 的扩展生态极其丰富,但当现有扩展无法满足需求时,我们就需要自己动手开发。虽然 VS Code 扩展通常用 TypeScript 编写,但 PowerShell 在扩展开发的工程化管理中扮演着不可或缺的角色——从脚手架生成、依赖安装、构建打包到发布上线,整个生命周期都可以用 PowerShell 自动化。特别是在团队协作中,统一的构建和发布脚本能大幅降低出错概率。

本文将介绍如何利用 PowerShell 自动化 VS Code 扩展开发的完整流程,包括环境准备、项目初始化、构建打包和版本发布,帮助你在扩展开发中事半功倍。

环境准备与检查

开发 VS Code 扩展需要 Node.js、npm 以及 Yeoman 生成器等工具。在正式开始之前,先用 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
# 检查 VS Code 扩展开发环境
function Test-VSCodeDevEnvironment {
$requirements = @(
@{ Name = "Node.js"; Command = "node"; VersionArg = "--version" }
@{ Name = "npm"; Command = "npm"; VersionArg = "--version" }
@{ Name = "VS Code CLI"; Command = "code"; VersionArg = "--version" }
@{ Name = "yo (Yeoman)"; Command = "yo"; VersionArg = "--version" }
@{ Name = "vsce (VS Code Extension Manager)"; Command = "vsce"; VersionArg = "--version" }
)

$results = @()
foreach ($req in $requirements) {
$cmd = Get-Command $req.Command -ErrorAction SilentlyContinue
if ($cmd) {
$version = & $req.Command $req.VersionArg 2>$null | Select-Object -First 1
$status = "OK"
}
else {
$version = "未安装"
$status = "MISSING"
}
$results += [PSCustomObject]@{
Component = $req.Name
Status = $status
Version = $version
}
}

$results | Format-Table -AutoSize
$missing = $results | Where-Object { $_.Status -eq "MISSING" }
if ($missing) {
Write-Host "`n缺少以下组件:" -ForegroundColor Yellow
foreach ($m in $missing) {
Write-Host " - $($m.Component)" -ForegroundColor Red
}
}
else {
Write-Host "`n所有组件已就绪!" -ForegroundColor Green
}
}

Test-VSCodeDevEnvironment

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
Component                       Status Version
--------- ------ -------
Node.js OK v20.11.0
npm OK 10.2.4
VS Code CLI OK 1.87.0
yo (Yeoman) MISSING 未安装
vsce (VS Code Extension Manager) MISSING 未安装

缺少以下组件:
- yo (Yeoman)
- vsce (VS Code Extension Manager)

当检测到缺少组件时,可以一键安装。Yeoman 和 generator-code 用于生成扩展脚手架,vsce 用于打包和发布。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 一键安装缺失的开发依赖
function Install-VSCodeDevDependencies {
param(
[switch]$Force
)

$packages = @("yo", "generator-code", "@vscode/vsce")

foreach ($pkg in $packages) {
$installed = npm list -g $pkg 2>$null | Select-String $pkg
if ($Force -or -not $installed) {
Write-Host "正在安装 $pkg ..." -ForegroundColor Cyan
npm install -g $pkg
}
else {
Write-Host "$pkg 已安装,跳过" -ForegroundColor Gray
}
}

Write-Host "`n依赖安装完成" -ForegroundColor Green
}

Install-VSCodeDevDependencies

执行结果示例:

1
2
3
4
5
6
7
8
正在安装 yo ...
added 1 package in 3s
正在安装 generator-code ...
added 1 package in 2s
正在安装 @vscode/vsce ...
added 1 package in 4s

依赖安装完成

项目初始化与脚手架生成

Yeoman 的 VS Code 扩展生成器需要交互式输入。在自动化场景中,我们可以通过 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
# 自动化生成 VS Code 扩展脚手架
function New-VSCodeExtension {
param(
[Parameter(Mandatory)]
[string]$Name,

[string]$DisplayName = $Name,
[string]$Description = "A VS Code extension",
[ValidateSet("typescript", "javascript")]
[string]$Language = "typescript",
[string]$OutputPath = (Get-Location).Path
)

$extensionDir = Join-Path $OutputPath $Name
if (Test-Path $extensionDir) {
Write-Host "目录已存在: $extensionDir" -ForegroundColor Red
return
}

# 创建临时目录用于 yo 生成
$tempDir = Join-Path $env:TEMP "vscode-ext-$Name-$(Get-Random)"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
Push-Location $tempDir

try {
# 构造 yo 的非交互式输入
# yo code 参数顺序: name, display name, description, git init, pkg manager, language
$inputs = @(
$Name,
$DisplayName,
$Description,
"Y", # 初始化 Git 仓库
"npm", # 包管理器
$Language
)

$inputString = $inputs -join "`n"
Write-Host "正在生成扩展项目: $Name ..." -ForegroundColor Cyan

$proc = Start-Process -FilePath "yo" `
-ArgumentList "code" `
-NoNewWindow -Wait -PassThru `
-RedirectStandardInput (Write-Output $inputString | Out-File -FilePath "$tempDir\input.txt" -Encoding utf8; "$tempDir\input.txt")

# 将生成的项目移动到目标位置
$generated = Get-ChildItem $tempDir -Directory | Select-Object -First 1
if ($generated) {
Move-Item $generated.FullName $extensionDir
Write-Host "扩展项目已创建: $extensionDir" -ForegroundColor Green

# 安装依赖
Push-Location $extensionDir
npm install
Pop-Location

Write-Host "`n项目结构:" -ForegroundColor Cyan
Get-ChildItem $extensionDir -Depth 1 | ForEach-Object {
$indent = if ($_.PSIsContainer) { "[DIR] " } else { "[FILE] " }
Write-Host " $indent $($_.Name)"
}
}
}
finally {
Pop-Location
if (Test-Path $tempDir) {
Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
}

New-VSCodeExtension -Name "my-hello-extension" `
-DisplayName "My Hello Extension" `
-Description "A simple hello world extension"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
正在生成扩展项目: my-hello-extension ...

added 156 packages in 8s

扩展项目已创建: /home/user/my-hello-extension

项目结构:
[DIR] src
[FILE] .gitignore
[FILE] .vscodeignore
[FILE] CHANGELOG.md
[FILE] LICENSE
[FILE] README.md
[FILE] package.json
[FILE] tsconfig.json
[FILE] vsc-extension-quickstart.md

生成的 package.json 是扩展的核心配置文件。我们可以用 PowerShell 读取并修改其中的关键字段,比如自动更新版本号或添加贡献点。

构建与打包自动化

VS Code 扩展在开发和发布阶段有不同的构建需求。开发时需要编译 TypeScript 并在本地调试,发布时需要用 vsce 打包为 .vsix 文件。下面的脚本封装了完整的构建流程。

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
# VS Code 扩展构建与打包
function Build-VSCodeExtension {
param(
[Parameter(Mandatory)]
[string]$ProjectPath,

[ValidateSet("dev", "prod")]
[string]$Mode = "dev",

[string]$OutputPath
)

if (-not (Test-Path $ProjectPath)) {
Write-Host "项目路径不存在: $ProjectPath" -ForegroundColor Red
return
}

Push-Location $ProjectPath
try {
# 读取 package.json 获取扩展信息
$pkg = Get-Content "package.json" -Raw | ConvertFrom-Json
Write-Host "扩展: $($pkg.displayName) v$($pkg.version)" -ForegroundColor Cyan
Write-Host "构建模式: $Mode`n"

# 清理旧的构建产物
if (Test-Path "out") {
Remove-Item "out" -Recurse -Force
}

# 编译 TypeScript
Write-Host "[1/3] 编译 TypeScript..." -ForegroundColor Yellow
npm run compile 2>&1 | ForEach-Object { Write-Host " $_" }
if ($LASTEXITCODE -ne 0) {
Write-Host "编译失败!" -ForegroundColor Red
return
}

# 运行测试(仅 dev 模式)
if ($Mode -eq "dev") {
Write-Host "[2/3] 运行测试..." -ForegroundColor Yellow
npm test 2>&1 | ForEach-Object { Write-Host " $_" }
}
else {
Write-Host "[2/3] 跳过测试(prod 模式)" -ForegroundColor Gray
}

# 打包为 .vsix(仅 prod 模式)
if ($Mode -eq "prod") {
Write-Host "[3/3] 打包 .vsix 文件..." -ForegroundColor Yellow
$vsixOutput = vsce package 2>&1
$vsixOutput | ForEach-Object { Write-Host " $_" }

$vsixFile = Get-ChildItem -Path "." -Filter "*.vsix" |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1

if ($vsixFile -and $OutputPath) {
$dest = Join-Path $OutputPath $vsixFile.Name
Move-Item $vsixFile.FullName $dest -Force
Write-Host "`n已输出: $dest" -ForegroundColor Green
}
elseif ($vsixFile) {
Write-Host "`n已生成: $($vsixFile.FullName)" -ForegroundColor Green
Write-Host "文件大小: $([math]::Round($vsixFile.Length / 1KB, 2)) KB" -ForegroundColor Gray
}
}
else {
Write-Host "[3/3] 开发构建完成,跳过打包" -ForegroundColor Gray
}

Write-Host "`n构建成功!" -ForegroundColor Green
}
finally {
Pop-Location
}
}

Build-VSCodeExtension -ProjectPath "./my-hello-extension" -Mode prod

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
扩展: My Hello Extension v0.0.1
构建模式: prod

[1/3] 编译 TypeScript...
src/extension.ts -> out/extension.js
[2/3] 跳过测试(prod 模式)
[3/3] 打包 .vsix 文件...
DONE Packaged: my-hello-extension-0.0.1.vsix (3.14 KB)

已生成: /home/user/my-hello-extension/my-hello-extension-0.0.1.vsix
文件大小: 3.14 KB

构建成功!

版本管理与发布

扩展的版本管理需要遵循语义化版本规范。以下脚本实现了版本号自动递增、Git 打标签、打包并发布到 VS Code Marketplace 的完整流程。

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
# VS Code 扩展版本管理与发布
function Publish-VSCodeExtension {
param(
[Parameter(Mandatory)]
[string]$ProjectPath,

[Parameter(Mandatory)]
[ValidateSet("patch", "minor", "major")]
[string]$VersionBump,

[string]$PersonalAccessToken,

[switch]$DryRun
)

Push-Location $ProjectPath
try {
$pkg = Get-Content "package.json" -Raw | ConvertFrom-Json
$oldVersion = $pkg.version
$parts = $oldVersion.Split(".")
$major = [int]$parts[0]
$minor = [int]$parts[1]
$patch = [int]$parts[2]

switch ($VersionBump) {
"patch" { $patch++ }
"minor" { $minor++; $patch = 0 }
"major" { $major++; $minor = 0; $patch = 0 }
}

$newVersion = "$major.$minor.$patch"
Write-Host "版本变更: $oldVersion -> $newVersion" -ForegroundColor Cyan

if ($DryRun) {
Write-Host "[DryRun] 仅显示操作,不执行" -ForegroundColor Yellow
Write-Host " - 更新 package.json 版本号"
Write-Host " - Git commit & tag v$newVersion"
Write-Host " - vsce publish"
return
}

# 更新 package.json 中的版本号
$pkg.version = $newVersion
$pkg | ConvertTo-Json -Depth 10 | Set-Content "package.json" -Encoding UTF8
Write-Host "[1/4] 已更新 package.json" -ForegroundColor Green

# 更新 CHANGELOG
$changelogPath = "CHANGELOG.md"
$date = Get-Date -Format "yyyy-MM-dd"
$newEntry = "## [$newVersion] - $date`n`n- Updated extension`n"
if (Test-Path $changelogPath) {
$content = Get-Content $changelogPath -Raw
$content = $content -replace "(## \[Unreleased\]|^#)", "`$0`n$newEntry"
Set-Content $changelogPath $content -Encoding UTF8
}
Write-Host "[2/4] 已更新 CHANGELOG.md" -ForegroundColor Green

# Git 提交和打标签
git add package.json CHANGELOG.md
git commit -m "chore: bump version to $newVersion"
git tag "v$newVersion"
Write-Host "[3/4] Git commit & tag 完成" -ForegroundColor Green

# 发布到 Marketplace
if ($PersonalAccessToken) {
$env:VSCE_PAT = $PersonalAccessToken
}
vsce publish $newVersion 2>&1 | ForEach-Object { Write-Host " $_" }
Write-Host "[4/4] 发布完成!" -ForegroundColor Green

Write-Host "`n扩展 $newVersion 已发布到 VS Code Marketplace" -ForegroundColor Green
}
finally {
Pop-Location
}
}

# 预演模式:查看操作但不执行
Publish-VSCodeExtension -ProjectPath "./my-hello-extension" `
-VersionBump patch -DryRun

# 正式发布(需要提供 PAT)
# Publish-VSCodeExtension -ProjectPath "./my-hello-extension" `
# -VersionBump patch `
# -PersonalAccessToken "your-pat-here"

执行结果示例:

1
2
3
4
5
版本变更: 0.0.1 -> 0.0.2
[DryRun] 仅显示操作,不执行
- 更新 package.json 版本号
- Git commit & tag v0.0.2
- vsce publish

去掉 -DryRun 并提供 Personal Access Token 后,脚本将自动完成版本递增、Git 打标签和 Marketplace 发布。

注意事项

  • Node.js 版本兼容性:VS Code 扩展开发推荐使用 LTS 版本的 Node.js(v18 或 v20)。过高或过低的版本可能导致编译失败或运行时不兼容。使用 nvmfnm 管理多版本 Node.js 是比较好的实践。
  • vsce 安全性:发布扩展时需要提供 Personal Access Token(PAT)。切勿将 PAT 硬编码在脚本中,应通过环境变量、Azure Key Vault 或交互式提示获取。PAT 应设置最小权限和有效期。
  • TypeScript 严格模式:建议在 tsconfig.json 中开启 strict: true。VS Code 扩展 API 的类型定义非常完善,严格模式可以在编译阶段捕获大量潜在问题,减少运行时错误。
  • 扩展清单校验:发布前务必检查 package.json 中的必填字段(namedisplayNamedescriptionversionengines.vscodecategorieskeywordsicon)。缺少这些字段可能导致 Marketplace 审核不通过或扩展显示异常。
  • .vscodeignore 配置:正确配置 .vscodeignore 文件,排除 src/tsconfig.json.eslintrc.* 等开发文件,只打包编译后的 out/ 目录和必要资源,可以有效减小 .vsix 文件体积。
  • CI/CD 集成:将构建和发布脚本集成到 GitHub Actions 或 Azure Pipeline 中,可以实现提交代码后自动编译、测试、打包。发布操作建议设置为手动触发,避免误操作将未就绪的版本推送到 Marketplace。