Skip to content

Commit 9fafb7c

Browse files
feat: add AzureLocalVMHydration PowerShell module
Adds the full PSGallery-publishable module alongside the existing standalone scripts, completing the two-part repo design: - AzureLocalVMHydration.psm1 root module (dot-sources Private + Public) - AzureLocalVMHydration.psd1 manifest (v0.1.0, GUID 83e5c34f, PS 7.0+) - Modules/Private/Common-Functions.ps1 shared helpers + Assert-AdminElevation - Modules/Private/Test-HydrationPrerequisites.ps1 10-check pre-flight (internal) - Modules/Public/Invoke-VMHydration.ps1 hydrate unmanaged Hyper-V VM into Azure Local - Modules/Public/Invoke-VMReconnect.ps1 reconnect restored VM to Azure after cross-cluster restore - Modules/Public/Test-VMHydrationPrerequisites.ps1 public bool-returning pre-flight wrapper Install: Install-Module AzureLocalVMHydration -Scope CurrentUser Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 14e011c commit 9fafb7c

7 files changed

Lines changed: 1127 additions & 0 deletions

AzureLocalVMHydration.psd1

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
@{
2+
RootModule = 'AzureLocalVMHydration.psm1'
3+
ModuleVersion = '0.1.0'
4+
CompatiblePSEditions = @('Core')
5+
GUID = '83e5c34f-9da7-4130-b695-b7741e59446f'
6+
Author = 'Azure Local Cloud'
7+
CompanyName = 'Azure Local Cloud'
8+
Copyright = '(c) 2026 Azure Local Cloud. All rights reserved.'
9+
Description = 'AzureLocalVMHydration provides PowerShell cmdlets for adopting existing Hyper-V VMs into Azure Local management without re-imaging or Sysprepping. Supports VM Hydration (in-place onboarding) and VM Reconnect (cross-cluster restore recovery) for both Gen1 and Gen2 VMs.'
10+
PowerShellVersion = '7.0'
11+
12+
FunctionsToExport = @(
13+
'Invoke-VMHydration',
14+
'Invoke-VMReconnect',
15+
'Test-VMHydrationPrerequisites'
16+
)
17+
CmdletsToExport = @()
18+
VariablesToExport = @()
19+
AliasesToExport = @()
20+
21+
PrivateData = @{
22+
PSData = @{
23+
Tags = @(
24+
'AzureLocal', 'AzureStackHCI', 'HCI', 'Arc', 'HyperV',
25+
'VMHydration', 'VMReconnect', 'Migration', 'PowerShell'
26+
)
27+
LicenseUri = 'https://github.com/AzureLocal/azurelocal-vm-hydration/blob/main/LICENSE'
28+
ProjectUri = 'https://github.com/AzureLocal/azurelocal-vm-hydration'
29+
IconUri = 'https://azurelocal.github.io/azurelocal-vm-hydration/assets/images/azurelocal-vm-hydration-icon.svg'
30+
ReleaseNotes = 'Initial release. Provides Invoke-VMHydration, Invoke-VMReconnect, and Test-VMHydrationPrerequisites.'
31+
}
32+
}
33+
}

AzureLocalVMHydration.psm1

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# AzureLocalVMHydration root module
2+
3+
$moduleRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
4+
5+
foreach ($folder in @('Modules\Private', 'Modules\Public')) {
6+
$path = Join-Path $moduleRoot $folder
7+
if (Test-Path $path) {
8+
Get-ChildItem -Path $path -Filter '*.ps1' -File -ErrorAction SilentlyContinue |
9+
Sort-Object FullName |
10+
ForEach-Object { . $_.FullName }
11+
}
12+
}
13+
14+
Export-ModuleMember -Function @(
15+
'Invoke-VMHydration',
16+
'Invoke-VMReconnect',
17+
'Test-VMHydrationPrerequisites'
18+
)
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
<#
2+
.SYNOPSIS
3+
Shared helper functions for AzureLocalVMHydration module.
4+
.NOTES
5+
Loaded automatically by AzureLocalVMHydration.psm1. Do not dot-source directly.
6+
#>
7+
8+
#region ── Console Output Helpers ─────────────────────────────────────────────
9+
10+
function Write-Step {
11+
param([string]$Message)
12+
Write-Host "`n[*] $Message" -ForegroundColor Cyan
13+
}
14+
15+
function Write-OK {
16+
param([string]$Message)
17+
Write-Host " [OK] $Message" -ForegroundColor Green
18+
}
19+
20+
function Write-Warn {
21+
param([string]$Message)
22+
Write-Host " [WARN] $Message" -ForegroundColor Yellow
23+
}
24+
25+
function Write-Fail {
26+
param([string]$Message)
27+
Write-Host " [FAIL] $Message" -ForegroundColor Red
28+
}
29+
30+
function Write-Info {
31+
param([string]$Message)
32+
Write-Host " [INFO] $Message" -ForegroundColor Gray
33+
}
34+
35+
#endregion
36+
37+
#region ── Banner ─────────────────────────────────────────────────────────────
38+
39+
function Write-HydrationBanner {
40+
param(
41+
[string]$Title,
42+
[hashtable]$Parameters
43+
)
44+
$width = 72
45+
$border = '' * $width
46+
Write-Host "`n$border" -ForegroundColor Cyan
47+
Write-Host (" {0}" -f $Title) -ForegroundColor White
48+
Write-Host $border -ForegroundColor Cyan
49+
if ($Parameters) {
50+
foreach ($key in $Parameters.Keys) {
51+
if ($null -ne $Parameters[$key] -and $Parameters[$key] -ne '') {
52+
Write-Host (" {0,-30} {1}" -f "$key :", $Parameters[$key]) -ForegroundColor Gray
53+
}
54+
}
55+
}
56+
Write-Host "$border`n" -ForegroundColor Cyan
57+
}
58+
59+
#endregion
60+
61+
#region ── Admin Elevation Check ──────────────────────────────────────────────
62+
63+
function Assert-AdminElevation {
64+
param([string]$FunctionName)
65+
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
66+
[Security.Principal.WindowsBuiltInRole]::Administrator
67+
)
68+
if (-not $isAdmin) {
69+
throw "$FunctionName requires elevation. Run PowerShell as Administrator."
70+
}
71+
}
72+
73+
#endregion
74+
75+
#region ── Azure CLI Wrapper ──────────────────────────────────────────────────
76+
77+
function Invoke-AzCli {
78+
[CmdletBinding(SupportsShouldProcess)]
79+
param(
80+
[Parameter(Mandatory)]
81+
[string[]]$Arguments,
82+
[string]$StepName = 'az command',
83+
[switch]$AllowEmpty
84+
)
85+
86+
$cmdDisplay = "az $($Arguments -join ' ')"
87+
88+
if ($WhatIfPreference) {
89+
Write-Host " [WhatIf] $cmdDisplay" -ForegroundColor DarkGray
90+
return $null
91+
}
92+
93+
Write-Info $cmdDisplay
94+
95+
$output = & az @Arguments 2>&1
96+
if ($LASTEXITCODE -ne 0) {
97+
Write-Fail "$StepName failed (exit $LASTEXITCODE)."
98+
Write-Fail ($output | Out-String).Trim()
99+
throw "$StepName failed."
100+
}
101+
102+
if (-not $output -and -not $AllowEmpty) { return $null }
103+
104+
$json = $output | ConvertFrom-Json -ErrorAction SilentlyContinue
105+
return $json ?? $output
106+
}
107+
108+
#endregion
109+
110+
#region ── ARM REST API Wrapper ───────────────────────────────────────────────
111+
112+
function Invoke-ArmRestApi {
113+
[CmdletBinding(SupportsShouldProcess)]
114+
param(
115+
[Parameter(Mandatory)]
116+
[ValidateSet('GET', 'PUT', 'POST', 'PATCH', 'DELETE')]
117+
[string]$Method,
118+
[Parameter(Mandatory)]
119+
[string]$Uri,
120+
[object]$Body,
121+
[string]$StepName = 'ARM REST call'
122+
)
123+
124+
if ($WhatIfPreference) {
125+
Write-Host " [WhatIf] az rest --method $Method --uri <$Uri>" -ForegroundColor DarkGray
126+
return $null
127+
}
128+
129+
$azArgs = @('rest', '--method', $Method, '--uri', $Uri, '--output', 'json')
130+
if ($Body) { $azArgs += @('--body', ($Body | ConvertTo-Json -Depth 10 -Compress)) }
131+
132+
Write-Info "az rest --method $Method --uri $Uri"
133+
134+
$output = & az @azArgs 2>&1
135+
if ($LASTEXITCODE -ne 0) {
136+
Write-Fail "$StepName failed (exit $LASTEXITCODE)."
137+
Write-Fail ($output | Out-String).Trim()
138+
throw "$StepName failed."
139+
}
140+
141+
return $output | ConvertFrom-Json -ErrorAction SilentlyContinue
142+
}
143+
144+
#endregion
145+
146+
#region ── Azure CLI Extension Check ──────────────────────────────────────────
147+
148+
function Test-AzCliExtensionVersion {
149+
param([string]$MinVersion = '1.11.9')
150+
151+
$output = & az extension show --name stack-hci-vm --output json 2>&1
152+
if ($LASTEXITCODE -ne 0) { return $false }
153+
154+
$ext = $output | ConvertFrom-Json -ErrorAction SilentlyContinue
155+
if (-not $ext) { return $false }
156+
157+
return ([Version]$ext.version) -ge ([Version]$MinVersion)
158+
}
159+
160+
#endregion
161+
162+
#region ── GUID Folder Validation ─────────────────────────────────────────────
163+
164+
function Test-GuidFolderPath {
165+
param(
166+
[Parameter(Mandatory)]
167+
[string]$Path
168+
)
169+
return $Path -match '\\[0-9a-f]{13,}\\'
170+
}
171+
172+
#endregion
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
function Test-HydrationPrerequisites {
2+
<#
3+
.SYNOPSIS
4+
Runs all pre-flight checks and returns a list of failure messages.
5+
.PARAMETER VMName
6+
Hyper-V VM name to validate.
7+
.PARAMETER RequireRunning
8+
If set, the VM must be in Running state (required for reconnect).
9+
.PARAMETER SkipClusterCheck
10+
Skip the HA/cluster check for non-clustered test environments.
11+
.OUTPUTS
12+
[System.Collections.Generic.List[string]] — empty means all passed.
13+
#>
14+
[CmdletBinding()]
15+
[OutputType([System.Collections.Generic.List[string]])]
16+
param(
17+
[Parameter(Mandatory)]
18+
[string]$VMName,
19+
[switch]$RequireRunning,
20+
[switch]$SkipClusterCheck
21+
)
22+
23+
$failures = [System.Collections.Generic.List[string]]::new()
24+
25+
#region ── 1. stack-hci-vm Extension Version ──────────────────────────────
26+
Write-Step "Checking stack-hci-vm CLI extension version (>= 1.11.9)"
27+
if (-not (Test-AzCliExtensionVersion -MinVersion '1.11.9')) {
28+
$failures.Add("stack-hci-vm extension is missing or below 1.11.9. Upgrade: az extension add --upgrade --name stack-hci-vm --version 1.11.9")
29+
} else {
30+
Write-OK "stack-hci-vm extension >= 1.11.9"
31+
}
32+
#endregion
33+
34+
#region ── 2. VM Exists in Hyper-V ───────────────────────────────────────
35+
Write-Step "Checking VM '$VMName' exists in Hyper-V"
36+
$vm = $null
37+
try {
38+
$vm = Get-VM -Name $VMName -ErrorAction Stop
39+
Write-OK "VM found (State: $($vm.State), Generation: $($vm.Generation))"
40+
} catch {
41+
$failures.Add("VM '$VMName' not found in Hyper-V on this node.")
42+
return $failures
43+
}
44+
#endregion
45+
46+
#region ── 3. VM Running State ────────────────────────────────────────────
47+
if ($RequireRunning) {
48+
Write-Step "Checking VM is in Running state"
49+
if ($vm.State -ne 'Running') {
50+
$failures.Add("VM '$VMName' must be Running before reconnect. Current state: $($vm.State).")
51+
} else {
52+
Write-OK "VM is Running"
53+
}
54+
}
55+
#endregion
56+
57+
#region ── 4. Highly Available ───────────────────────────────────────────
58+
if (-not $SkipClusterCheck) {
59+
Write-Step "Checking VM is configured as Highly Available"
60+
try {
61+
$cr = Get-ClusterResource -ErrorAction Stop |
62+
Where-Object { $_.ResourceType -eq 'Virtual Machine' -and $_.Name -like "*$VMName*" }
63+
if (-not $cr) {
64+
$failures.Add("VM '$VMName' is not HA in Failover Cluster Manager. Configure HA before proceeding.")
65+
} else {
66+
Write-OK "VM is HA-configured (cluster resource: $($cr.Name))"
67+
}
68+
} catch {
69+
Write-Warn "Could not check cluster state. Skipping HA check."
70+
}
71+
}
72+
#endregion
73+
74+
#region ── 5. No GPU Attached ────────────────────────────────────────────
75+
Write-Step "Checking VM has no GPU attached (DDA or GPU-P)"
76+
try {
77+
$gpuDevices = Get-VMAssignableDevice -VMName $VMName -ErrorAction SilentlyContinue
78+
if ($gpuDevices) {
79+
$failures.Add("VM '$VMName' has $($gpuDevices.Count) DDA device(s) attached. Remove all GPUs before proceeding.")
80+
} else {
81+
Write-OK "No DDA GPU devices attached"
82+
}
83+
} catch {
84+
Write-Warn "Could not enumerate assignable devices. Verify no GPU is attached."
85+
}
86+
#endregion
87+
88+
#region ── 6. Not a Trusted Launch VM ────────────────────────────────────
89+
Write-Step "Checking VM is not a Trusted Launch VM"
90+
$secureBootEnabled = (Get-VMFirmware -VMName $VMName -ErrorAction SilentlyContinue).SecureBoot -eq 'On'
91+
$vtpmEnabled = (Get-VMSecurity -VMName $VMName -ErrorAction SilentlyContinue).TpmEnabled
92+
if ($secureBootEnabled -and $vtpmEnabled) {
93+
Write-Warn "VM has both Secure Boot and vTPM enabled. If this is a Trusted Launch VM, reconnect is not supported."
94+
} else {
95+
Write-OK "VM does not appear to be a Trusted Launch VM"
96+
}
97+
#endregion
98+
99+
#region ── 7. KVP Integration Service ────────────────────────────────────
100+
Write-Step "Checking Hyper-V Data Exchange (KVP) integration service"
101+
try {
102+
$kvp = Get-VMIntegrationService -VMName $VMName -Name 'Key-Value Pair Exchange' -ErrorAction Stop
103+
if (-not $kvp.Enabled) {
104+
$failures.Add("KVP (Data Exchange) integration service is disabled on '$VMName'. Enable it in Hyper-V Manager > Integration Services.")
105+
} else {
106+
Write-OK "Data Exchange (KVP) integration service is enabled"
107+
}
108+
} catch {
109+
$failures.Add("Could not check KVP integration service on '$VMName': $_")
110+
}
111+
#endregion
112+
113+
#region ── 8. Guest Service Interface ────────────────────────────────────
114+
Write-Step "Checking Hyper-V Guest Service Interface"
115+
try {
116+
$gsi = Get-VMIntegrationService -VMName $VMName -Name 'Guest Service Interface' -ErrorAction Stop
117+
if (-not $gsi.Enabled) {
118+
$failures.Add("Guest Service Interface is disabled on '$VMName'. Enable it in Hyper-V Manager > Integration Services.")
119+
} else {
120+
Write-OK "Guest Service Interface is enabled"
121+
}
122+
} catch {
123+
$failures.Add("Could not check Guest Service Interface on '$VMName': $_")
124+
}
125+
#endregion
126+
127+
#region ── 9. GUID Folder ─────────────────────────────────────────────────
128+
Write-Step "Checking VM configuration path is under a GUID folder"
129+
if (-not (Test-GuidFolderPath -Path $vm.ConfigurationLocation)) {
130+
$failures.Add("VM '$VMName' config path '$($vm.ConfigurationLocation)' is not under a GUID folder. Move VM files into the correct GUID subfolder.")
131+
} else {
132+
Write-OK "Configuration path is under a GUID folder: $($vm.ConfigurationLocation)"
133+
}
134+
#endregion
135+
136+
#region ── 10. Backup Services (advisory) ────────────────────────────────
137+
Write-Step "Checking for backup services (advisory)"
138+
$running = @('VeeamBackupSvc', 'ArcasAgent', 'MSBackup', 'wbengine') | Where-Object {
139+
(Get-Service -Name $_ -ErrorAction SilentlyContinue)?.Status -eq 'Running'
140+
}
141+
if ($running) {
142+
Write-Warn "Backup service(s) detected: $($running -join ', '). Microsoft recommends no backup services run during reconnect."
143+
} else {
144+
Write-OK "No known backup services detected"
145+
}
146+
#endregion
147+
148+
return $failures
149+
}

0 commit comments

Comments
 (0)