This article was created before Azure Stack HCI was renamed to Azure Local (link) in November 2024, which is why some references or hardcoded URLs may still point to Azure Stack HCI. However, the content has been updated accordingly, and if you find any errors, I would greatly appreciate it if you could report them either through the comment function or by emailing blog@schmitt-nieto.com
Azure Local: Demolab
Azure Local is a hyperconverged infrastructure solution that combines software-defined compute, storage, and networking. While it’s a powerful tool for modernizing your data center and integrating with Azure services, setting it up for testing or development can be resource-intensive.
Deploying Azure Local can often seem daunting, especially when constrained by hardware resources. Traditional methods typically require substantial infrastructure, making it challenging for individuals or small teams to set up testing and lab environments. In this article, I’ll explore a streamlined approach using the AzSHCI scripts, which allow you to deploy Azure Local on minimal hardware setups, such as a laptop.
Existing Solutions and Their Limitations
Several tools and guides exist to help deploy Azure Local in lab environments. However, they often come with limitations that make them less suitable for minimal hardware setups.
MSLab by Jaromir Kaspar
MSLab is a comprehensive solution that allows you to deploy various Microsoft products in a lab environment.
- Pros:
- Supports a wide range of scenarios and configurations.
- Highly customizable and feature-rich.
- Served as the foundation for my lab setups for a long time.
- Maintained by the community.
- Cons:
- Setup process involves more steps and hardware resources compared to AzSHCI.
Additionally, the DellGEOS Repository offers more current scenarios and use cases. Most scenarios in the DellGEOS repository are designed for infrastructures somewhat more robust than a simple laptop. In the past, DellGEOS has been the base for my configurations on Azure Local with Dell hardware, providing very current troubleshooting scenarios.
Azure Arc Jumpstart by Lior Kamrat
Azure Arc Jumpstart provides automated deployment scenarios for Azure Arc-enabled resources.
- Pros:
- Automates Azure Arc deployments with ease.
- Contains beautifully documented infrastructure with detailed diagrams.
- Cons:
- Makes it difficult to carry out PoCs that extend beyond HCI, such as AVD on HCI.
- Lack of flexibility in networking due to virtualization and Azure-based implementation, limiting access to VM consoles.
- Costs approximate around $3,000 per month, not accounting for additional expenses. Cost Details
Manual Deployment
Deploying Azure Local manually using the Deployment Guide is another option. However, this manual approach has several drawbacks:
- Pros:
- Direct control over the deployment process.
- Cons:
- The configuration hasn’t been updated since April this year.
- Missing steps and incomplete documentation make the process cumbersome.
- Performing a manual deployment is much more tedious and time-consuming compared to using AzSHCI scripts.
Why AzSHCI?
The AzSHCI scripts are designed to bridge this gap by providing a lightweight, efficient way to deploy Azure Local on minimal hardware. Key advantages include:
- Resource Efficiency: Optimized for environments with limited CPU, RAM, and storage.
- Simplicity: Straightforward scripts that are easy to understand and modify.
- Quick Deployment: Allows you to set up a single-node Azure Local cluster rapidly for testing and development.
Prerequisites
Before you begin, ensure you meet the following requirements:
- Hardware Requirements:
- A computer with a TPM chip.
- A processor capable of running Hyper-V.
- Recommended RAM: 64 GB or more.
- Minimum RAM for Basic Testing: 32 GB.
- Software Requirements:
- Azure Subscription: With the necessary permissions.
Getting Started with AzSHCI
Understanding Nested Virtualization
The AzSHCI scripts leverage Nested Virtualization to create a fully functional Azure Local environment within your existing hypervisor setup. This means you can run virtual machines (VMs) inside other VMs, allowing you to simulate a complex infrastructure on minimal hardware.
The deployment process involves the following key steps:
-
Installation of Windows Server 2025 Evaluation: The scripts automate the setup of a Windows Server 2025 VM, which will be configured as an Active Directory Domain Controller (DC). This includes installing updates and preparing the environment for cluster registration, which is essential for setting up Azure Local.
-
Setup of the Azure Local Node: Another VM is created to serve as the Azure Local node. Thanks to Nested Virtualization, this node can act as a hypervisor itself, allowing you to run additional workloads or services on top of it.
By utilizing Nested Virtualization, you can simulate a realistic Azure Local deployment without the need for extensive physical hardware, making it ideal for testing and development purposes.
Repository Overview
The AzSHCI scripts are hosted on GitHub: github.com/schmittnieto/AzSHCI
The repository is structured as follows:
AzSHCI/
│
├── scripts/
│ ├── 01Lab/
│ │ ├── Set-LabEnv.ps1
│ │ ├── .env.example
│ │ ├── 00_AzurePreRequisites.ps1
│ │ ├── 00_Infra_AzHCI.ps1
│ │ ├── 01_DC.ps1
│ │ ├── 02_Cluster.ps1
│ │ ├── 03_TroubleshootingExtensions.ps1
│ │ └── 99_Offboarding.ps1
│ ├── 02Day2/
│ │ ├── 10_StartStopAzSHCI.ps1
│ │ ├── 11_ImageBuilderAzSHCI.ps1
│ │ ├── 11_ImageBuilderAL.ps1
│ │ ├── 12_AKSArcServiceToken.ps1
│ │ └── 13_VHDXOptimization.ps1
│ ├── 03VMDeployment/
│ │ └── 20_SSHRDPArcVM.ps1
│ └── 04AVD/
│ ├── 30_AVDAzureLocal.ps1
│ ├── 31_FSLogixFileShare.ps1
│ └── SessionHostScripts/
│
├── terraform/
├── README.md
└── LICENSE
Configuration with the .env file
The 01Lab scripts now read their configuration from a single scripts/01Lab/.env file that you load once per session with Set-LabEnv.ps1. Every value lives in that one file: Azure identifiers, ISO paths, lab root folder, VM sizing and lab credentials. The fully interactive 00_AzurePreRequisites.ps1 is the only script that does not use .env, because it selects subscription, resource group and principal through prompts.
Copy the template once per machine:
Copy-Item scripts\01Lab\.env.example scripts\01Lab\.env
notepad scripts\01Lab\.env
At a minimum set these keys before running any script:
AZSHCI_LAB_ROOT_FOLDER,AZSHCI_ISO_PATH_HCI,AZSHCI_ISO_PATH_DCfor the local infrastructureAZSHCI_SUBSCRIPTION_ID,AZSHCI_TENANT_ID,AZSHCI_RESOURCE_GROUP,AZSHCI_LOCATIONfor the Arc registrationAZSHCI_DEFAULT_ADMIN_PASSWORD,AZSHCI_DC_LCM_PASSWORD,AZSHCI_NODE_SETUP_PASSWORDfor the lab credentials
At the start of each session, load the variables once:
.\scripts\01Lab\Set-LabEnv.ps1
The configuration-based 01Lab scripts use these variables in the same PowerShell session and load the file automatically when needed. Run the loader again after editing .env; values already loaded into a session do not change when you save the file. The loader does not print secrets. The prerequisite script does print a newly created SPN secret once, so keep its output private. scripts/01Lab/.env is gitignored and must never be committed.
Review sizing before starting: the current template allocates 96 GB of RAM and 32 virtual processors to the Azure Local node. Those are my larger lab settings. Reduce AZSHCI_HCI_VM_MEMORY_GB and AZSHCI_HCI_VM_PROCESSORS to fit your host while leaving capacity for the DC and the host OS. Replace every example password in the template.
Which identity does each step use?
The Azure deployment identity and the accounts inside the lab have different jobs:
| Step | Identity and configuration |
|---|---|
| Azure prerequisite setup | Interactive Azure user with permission to assign the requested roles and register providers; creating an application also needs the relevant Entra rights |
| Node Arc registration | SPN from AZSHCI_SPN_APP_ID and AZSHCI_SPN_SECRET, or device code login when both are empty |
| Local VM preparation | Guest administrator credentials from .env, used through PowerShell Direct |
| Terraform | Azure CLI session established by terraform/Connect-Spn.ps1, using the SPN values in terraform.tfvars |
| AVD deployment and management | 04AVD/30_AVDAzureLocal.ps1 checks permissions for the selected operation; Azure RBAC and Graph consent are separate |
Run 00_AzurePreRequisites.ps1 as the bootstrap user. It signs out a cached SPN session before prompting for user authentication. Its principal menu supports an existing user, a new SPN or an existing SPN. For the existing-SPN option, enter the application/client ID or a unique display name. The script resolves the service principal object ID for RBAC; it does not retrieve or rotate the existing secret.
Azure role assignments do not grant Microsoft Graph application permissions. Review any failed role assignments in the prerequisite output before continuing. The AVD workflow adds its own operation-specific checks, including Graph consent for directory searches or optional Entra device deletion. The Entra joined AVD article explains that workflow.
Script Breakdown
00_AzurePreRequisites.ps1
Azure Pre-requisites Setup Script
- Purpose: Prepares Azure providers and the requested role assignments for the selected subscription, resource group and principal. Run it again when those targets change.
- Features:
- Checks and installs required Az PowerShell modules (
Az.Accounts,Az.Resources). - Authenticates to Azure via device code login and prompts you to select a subscription.
- Lets you choose an existing resource group or create a new one (recommended name:
rg-azlocal-lab). - Assigns all required RBAC roles at subscription scope (
Azure Stack HCI Administrator,Reader) and resource group scope (Key Vault Data Access Administrator,Key Vault Secrets Officer,Key Vault Contributor,Storage Account Contributor,Azure Connected Machine Onboarding,Azure Connected Machine Resource Administrator). - Registers all necessary Azure resource providers (
Microsoft.AzureStackHCI,Microsoft.HybridCompute,Microsoft.KeyVault, and others). - Assigns roles to an existing user, an existing SPN or a newly created SPN. Only the new-SPN path creates and prints a secret. Store the client ID and secret in the private
.envfile for non-interactive Arc registration.
- Checks and installs required Az PowerShell modules (
00_Infra_AzHCI.ps1
Configuration and VM Creation Script
- Purpose: Sets up virtual networking, creates necessary folder structures, and deploys both the HCI node (
AZLN01) and Domain Controller (DC) VMs. - Features:
- Configures an internal virtual switch with NAT (
172.19.18.0/24, gateway172.19.18.1). - Adds an inbound ICMPv4 firewall rule so VMs can ping the NAT gateway.
- Creates the lab folder structure under
E:\AzureLocalLab(subfoldersVM\andDisk\). - Automates VM creation with vTPM, nested virtualization, MAC spoofing, and boot-from-ISO configuration.
- Default ISO paths:
E:\ISO\AzureLocal24H2.iso(HCI node) andE:\ISO\WS2025.iso(DC). AdjustAZSHCI_ISO_PATH_HCI,AZSHCI_ISO_PATH_DCandAZSHCI_LAB_ROOT_FOLDERinscripts/01Lab/.envif your paths differ.
- Configures an internal virtual switch with NAT (
01_DC.ps1
Domain Controller Configuration Script
- Purpose: Configures the Domain Controller VM, including network settings and Active Directory setup.
- Features:
- Removes ISO media from the VM.
- Renames the VM and sets static IP (
172.19.18.2). - Sets the time zone and installs necessary Windows features.
- Promotes the server to a Domain Controller (
azurelocal.local). - Configures DNS forwarders and creates a full Organizational Unit (OU) structure in Active Directory.
- Runs
New-HciAdObjectsPreCreationto pre-create the AD objects required by Azure Local LCM.
02_Cluster.ps1
Cluster Node Configuration Script
- Purpose: Configures the Azure Local node VM (
AZLN01) and registers it with Azure Arc. - Execution modes (selected interactively at startup):
- Mode 1 – Full setup: ISO removal, user creation, NIC configuration and Arc registration. Use on first run.
- Mode 2 – Arc only: Retries only the Arc registration step without repeating node configuration. Use to recover from a failed registration without starting over.
- Features:
- Removes ISO media from the VM.
- Creates a local administrator account (
Setupuser) and renames the VM toAZLN01. - Configures network adapters with static IPs and RDMA (
MGMT1:172.19.18.10,MGMT2: RDMA only). - Installs essential Windows features.
- Registers the node with Azure Arc via
Invoke-AzStackHciArcInitialization, with automatic retry for transientBootstrapOobeServiceconnection errors. - Optional SPN authentication: if
AZSHCI_SPN_APP_IDandAZSHCI_SPN_SECRETare set inscripts/01Lab/.env(output from00_AzurePreRequisites.ps1), the script authenticates non-interactively via Service Principal instead of an interactive device code prompt.
03_TroubleshootingExtensions.ps1
Troubleshooting Azure Connected Machine Extensions
- Purpose: Manages and reconciles Azure Connected Machine extensions for the HCI nodes. No longer required before portal deployment (extensions are installed automatically since version 2503), but still useful for troubleshooting failed or mismatched extensions.
- Features:
- Installs required PowerShell modules (
Az.ComputeandAz.StackHCI) if not already installed. - Connects to Azure using device code authentication or SPN and allows you to select a subscription and resource group.
- Retrieves Azure Arc VMs filtering for machines with
CloudMetadataProviderset toAzSHCI. - Validates that required Azure Connected Machine extensions are installed and at the correct version.
- Fixes any failed or version-mismatched extensions by removing locks, deleting, and reinstalling them.
- Adds any missing extensions based on a predefined list.
- After the extension checks, attempts a lab-specific
DownloadHelpers.psm1patch through Arc Run Command. The current script pins extension versions and a10.2601.0.1162package path. Review those targets against the installed release before using it; this is not a general repair for every Azure Local version.
- Installs required PowerShell modules (
99_Offboarding.ps1
Offboarding Script to Clean Up Configurations
- Purpose: Removes the local Hyper-V lab, including VMs, VHD files, virtual switches, NAT settings and the lab folder structure. It does not delete Azure resources or Entra objects.
- Features:
- Stops and removes
AZLN01andDCVMs and their associated VHD files. - Removes the HgsGuardian entries for both VMs.
- Removes the NAT object and virtual switch (
azurelocal). - Deletes the entire
E:\AzureLocalLabfolder tree.
- Stops and removes
Step-by-Step Deployment Guide
Below is the exact deployment process to set up your Azure Local environment using the AzSHCI scripts. This guide will help you get everything up and running smoothly.
1. Download the Scripts from the Repository
Clone the AzSHCI repository to your local machine:
git clone https://github.com/schmittnieto/AzSHCI.git
Or download the repository manually: Download Here
2. Download the Necessary ISO Files
- Windows Server 2025 Evaluation ISO: Download Here
- Azure Local OS ISO: Download directly from the Azure Portal.
Place both ISO files in a directory accessible to the host. The scripts default to E:\ISO\AzureLocal24H2.iso (Azure Local node) and E:\ISO\WS2025.iso (Domain Controller). If your drive letter or filenames differ, set AZSHCI_ISO_PATH_HCI, AZSHCI_ISO_PATH_DC and AZSHCI_LAB_ROOT_FOLDER in scripts/01Lab/.env before running the scripts. See the Configuration with the .env file section above.
3. Preparing Your Environment
Navigate to the scripts directory:
cd AzSHCI/scripts/01Lab
Ensure your execution policy allows script execution:
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
Copy the environment template and open it. Fill in your ISO paths, Azure identifiers and lab credentials as described in Configuration with the .env file:
Copy-Item .env.example .env
notepad .env
Then load the variables into the current session:
.\Set-LabEnv.ps1
Every 01Lab script you run afterwards picks the values up automatically.
4. Prepare Azure Pre-requisites
Before setting up any local infrastructure, run 00_AzurePreRequisites.ps1 to automate all Azure-side setup:
.\00_AzurePreRequisites.ps1
This interactive script will:
- Install required Az modules if missing.
- Authenticate to Azure and let you select a subscription.
- Create or select the resource group (
rg-azlocal-labis the recommended default). - Assign all required RBAC roles at subscription and resource group scope.
- Register all required Azure resource providers.
- Assign roles to an existing user, an existing SPN or a new SPN. If you create one, save the printed client ID and secret privately. If you reuse one, supply its existing valid secret yourself. Step 9 uses these values for non-interactive Arc registration.
Copy the chosen subscription, tenant and resource group into .env, then reload it with Set-LabEnv.ps1. The prerequisite script does not write those selections back to your configuration file.
5. Initial infrastructure script
If you have not loaded the lab configuration yet in this session, run it once so the AZSHCI_* variables are available to every script:
.\Set-LabEnv.ps1
Then run the infrastructure script:
.\00_Infra_AzHCI.ps1
This script will set up the virtual networking, create necessary folder structures, and deploy the VMs for the Domain Controller (DC) and the Azure Local node.
6. Manual Installation of the DC Operating System
- Start the DC VM: Open Hyper-V Manager and start the
DCVM. - Follow the Installation Wizard: Proceed through the Windows Server 2025 installation wizard.
- Perform First Login: Log in as the Administrator for the first time.
No additional configuration is needed at this stage.
7. Domain Controller configuration script
Make sure AZSHCI_DEFAULT_ADMIN_USER and AZSHCI_DEFAULT_ADMIN_PASSWORD in scripts/01Lab/.env match the local administrator credentials you set during the Windows Server 2025 installation on the DC VM.
# In scripts/01Lab/.env
AZSHCI_DEFAULT_ADMIN_USER="Administrator"
AZSHCI_DEFAULT_ADMIN_PASSWORD="YourAdminPassword"
Then execute the script:
.\01_DC.ps1
This process takes approximately 30 minutes due to Windows Updates. The script will:
- Remove the ISO from the VM.
- Configure network settings.
- Promote the server to a Domain Controller.
- Install updates and prepare the environment for cluster registration.
8. Manual Installation of the Node Operating System
- Start the AZLN01 VM: Open Hyper-V Manager and start the
AZLN01VM. - Follow the Installation Wizard: Proceed through the Azure Local OS installation wizard.
- Perform First Login: Log in as the Administrator for the first time.
No additional configuration is needed at this stage.
9. Node configuration and ARC registration
All values for this script live in scripts/01Lab/.env. Confirm the following keys are set:
# Credentials (must match the password used during OS installation)
AZSHCI_DEFAULT_ADMIN_USER="Administrator"
AZSHCI_DEFAULT_ADMIN_PASSWORD="YourAdminPassword"
# Azure identifiers
AZSHCI_SUBSCRIPTION_ID="your-subscription-id"
AZSHCI_TENANT_ID="your-tenant-id"
AZSHCI_RESOURCE_GROUP="rg-azlocal-lab"
AZSHCI_LOCATION="westeurope"
If you created or selected a service principal in Step 4, enable non-interactive Arc registration by setting its client ID and valid secret:
AZSHCI_SPN_APP_ID="your-application-client-id"
AZSHCI_SPN_SECRET="your-client-secret"
If both values are left empty, the script falls back to an interactive device code login on the node. Once the values are in place, execute the script:
.\02_Cluster.ps1
When prompted, select the execution mode:
- Mode 1 – Full setup: runs the complete flow (ISO removal, user creation, NIC configuration, Arc registration). Use this on the first run.
- Mode 2 – Arc only: retries only the Arc registration step without touching the node configuration. Use this to recover from a failed registration.
This process takes approximately 10 minutes. The script will:
- Remove the ISO from the VM.
- Create a setup user and rename the node to
AZLN01. - Configure network adapters with static IPs and RDMA.
- Install required Windows features.
- Register the node with Azure Arc (with automatic retry for transient connection errors).
10. Understand Extension Installation
Arc registration prepares the node for deployment. The current Terraform path installs the four required Azure Local extensions during its validation stage; 03_TroubleshootingExtensions.ps1 is not a prerequisite to run before terraform apply. Continue to cluster deployment after registration, then inspect extension status if validation fails:
- Go to the Azure Portal.
- Navigate to the Azure Arc machines.
- Check that all extensions are installed and in a successful state.
If the failure calls for extension repair, inspect the version-specific settings in Script 03 before running it in the lab:
.\03_TroubleshootingExtensions.ps1
The script can remove locks, replace extensions and apply the lab-specific LcmController patch described above. Use the troubleshooting guide for diagnosis before applying it to a different release.
11. Registering the Cluster
Once the node is Arc-registered, follow the portal path below or use the Terraform alternative at the end of this section:
-
Assign Required Rights:
Check the role-assignment results from Step 4. The script assigns Azure RBAC to the selected principal, which may be an SPN rather than the user opening the portal. The portal operator needs the relevant deployment rights too. Azure RBAC assignment does not grant Entra directory roles. For the portal path, review:
- Subscription Level:
Azure Stack HCI Administrator,Reader. - Resource Group Level:
Key Vault Data Access Administrator,Key Vault Secrets Officer,Key Vault Contributor,Storage Account Contributor. - Microsoft Entra Roles and Administrators:
Cloud Application Administratorfor the user performing the deployment.
- Subscription Level:
-
Initial Cluster Registration:
-
Use the Azure Portal for initial cluster registration: Azure Local Deployment via Portal
- Extension Installation
- Network Configuration:
- Custom Location and User Configuration:
- Security Options:
- Finalize Configuration:
- Leave the remaining settings at default. The system will be ready for provisioning after the validation, which take approximately 20 minutes.
-
-
Perform Cloud Deployment:
- Initiate the cloud deployment and wait approximately 2 hours for the cluster to be ready for subsequent steps.
Terraform alternative: The repository also includes a
terraform/folder that provides an Infrastructure-as-Code alternative to clicking through the portal wizard. It deploys the cluster using a local fork of the Azure Verified Module for Azure Local. For a full walkthrough of that path, including the bugs I hit and how I fixed them, see Azure Local: Terraform Deployment.
Cost Considerations
One of the advantages of using the AzSHCI scripts and Nested Virtualization is the minimal cost involved in setting up your Azure Local environment.
- Hardware Costs: 0€
- I use my regular work laptop for deployment, so there are no additional hardware expenses.
- License Costs for the Domain Controller: 0€
- We use the Windows Server 2025 Evaluation version, which is free for 90 days, ample time for testing and development.
- License Costs for the Node: 0€
- The Azure Local OS does not incur costs.
- Azure Local Costs: 0€
- Azure Local is free for the first 60 days. Since we won’t exceed this period for our testing, no costs will be incurred.
When rebuilding the lab, remove the Azure deployment before removing the local VMs. For a Terraform-managed deployment, review and run terraform destroy from terraform/ while the node and Terraform state are still available. Then run scripts/01Lab/99_Offboarding.ps1 on the outer host to remove the local lab. Resources created outside Terraform, such as workloads or the bootstrap SPN, need their own cleanup. Deleting the local VMs alone leaves Azure resources and role assignments behind.
Conclusion
By following these steps, you should have a functional Azure Local environment running on minimal hardware. The AzSHCI scripts are designed to make the deployment process as smooth as possible, allowing you to focus on exploring Azure Local features without worrying about extensive setup procedures.
Additional Resources
- AzSHCI GitHub Repository: github.com/schmittnieto/AzSHCI
- MSLab Repository: github.com/microsoft/MSLab
- DellGEOS AzureStackHOLs: github.com/DellGEOS/AzureStackHOLs
- Azure Arc Jumpstart: azurearcjumpstart.io
- Azure Local Documentation: Microsoft Docs
Workloads and Ongoing Operations
The repository now includes Entra joined AVD deployment and management under scripts/04AVD/. For cluster deployment through code, follow the Terraform article. The Day 2 guide covers logical networks, images and the updated graceful start/stop workflow. You can also continue with Arc VM deployment or AKS on Azure Local.
Contributing
Contributions are welcome! If you have suggestions, improvements, or bug fixes, feel free to fork the repository and submit a pull request.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Contact
For any questions or issues, please open an issue in the repository.
Thank you for using AzSHCI! I hope these scripts simplify your Azure Local deployment process and enable efficient testing and development in your environment.
RAW Scripts
To make it easier to read I have moved all the RAW scripts to the end of the article, they will be automatically synchronized via Github Actions to the article. That is to say, in case there are modifications in the Github repository where they are located, these will be picked up in the scripts here.
00_Infra_AzHCI.ps1
# 00_Infra_AzHCI.ps1
# Configuration and VM Creation Script
<#
.SYNOPSIS
Configures virtual networking, creates required folder structures, and deploys HCI Node and Domain Controller VMs.
.DESCRIPTION
This script performs the following tasks:
- Checks required prerequisites.
- Configures an internal virtual switch with NAT.
- Ensures the required folder structures exist.
- Creates two virtual machines: an HCI Node and a Domain Controller.
- Configures networking, storage, security, and boot settings for the VMs.
.NOTES
- Designed by Cristian Schmitt Nieto. For details: https://schmitt-nieto.com/blog/azure-stack-hci-demolab/
- Run this script with administrative privileges.
- Ensure ISO paths are correct before execution.
- Execution Policy may need to allow script execution:
- Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
- Approved PowerShell verb usage:
- Functions and cmdlets use approved verbs such as `New` for creation.
- Updates:
- 2025/11/10: Adding ICMP allow rule to permit pinging the NAT gateway from VMs.
#>
#region Variables
# Load the lab configuration into this session if it has not been loaded yet.
# Tip: run scripts\01Lab\Set-LabEnv.ps1 once per session to set everything from
# scripts\01Lab\.env. See scripts\01Lab\.env.example for the full list of keys.
if ($env:AZSHCI_ENV_LOADED -ne '1') { & "$PSScriptRoot\Set-LabEnv.ps1" }
# Virtual Switch and Network Configuration
$vSwitchName = $env:AZSHCI_VSWITCH_NAME
$vSwitchNIC = "vEthernet ($vSwitchName)"
$vNetIPNetwork = $env:AZSHCI_LAB_SUBNET
$vIPNetworkPrefixLength = ($vNetIPNetwork -split '/')[1]
$natName = $env:AZSHCI_NAT_NAME
$HCIRootFolder = $env:AZSHCI_LAB_ROOT_FOLDER
# ISO Paths
$isoPath_HCI = $env:AZSHCI_ISO_PATH_HCI # HCI Node ISO
$isoPath_DC = $env:AZSHCI_ISO_PATH_DC # Domain Controller ISO
# Management network adapter names (shared by both VMs)
$NIC1 = $env:AZSHCI_MGMT_NIC1
$NIC2 = $env:AZSHCI_MGMT_NIC2
# HCI Node VM Configuration
$HCIVMName = $env:AZSHCI_HCI_VM_NAME
$HCI_Memory = [int64]$env:AZSHCI_HCI_VM_MEMORY_GB * 1GB
$HCI_Processors = [int]$env:AZSHCI_HCI_VM_PROCESSORS
$HCI_OSDiskSize = [int64]$env:AZSHCI_HCI_OS_DISK_GB * 1GB
$HCI_S2DDiskSize = [int64]$env:AZSHCI_HCI_S2D_DISK_GB * 1GB
$HCI_S2DDiskCount = [int]$env:AZSHCI_HCI_S2D_DISK_COUNT
$HCI_Disks = @(
@{ Path = "${HCIVMName}_C.vhdx"; Size = $HCI_OSDiskSize }
)
for ($s2d = 1; $s2d -le $HCI_S2DDiskCount; $s2d++) {
$HCI_Disks += @{ Path = "s2d$s2d.vhdx"; Size = $HCI_S2DDiskSize }
}
$HCI_NetworkAdapters = @($NIC1, $NIC2)
# Domain Controller VM Configuration
$DCVMName = $env:AZSHCI_DC_VM_NAME
$DC_Memory = [int64]$env:AZSHCI_DC_VM_MEMORY_GB * 1GB
$DC_Processors = [int]$env:AZSHCI_DC_VM_PROCESSORS
$DC_Disks = @(
@{ Path = "${DCVMName}_C.vhdx"; Size = [int64]$env:AZSHCI_DC_OS_DISK_GB * 1GB }
)
$DC_NetworkAdapters = @($NIC1)
# Tasks for Progress Bar
$tasks = @(
"Checking Prerequisites",
"Configuring Virtual Switch and NAT",
"Setting Up Folder Structures",
"Creating HCI Node VM",
"Creating Domain Controller VM"
)
$totalTasks = $tasks.Count
$currentTask = 0
#endregion
#region Functions
function Write-Message {
param(
[string]$Message,
[ValidateSet("Info","Success","Warning","Error")]
[string]$Type = "Info"
)
switch ($Type) {
"Info" { Write-Host $Message -ForegroundColor Cyan }
"Success" { Write-Host $Message -ForegroundColor Green }
"Warning" { Write-Host $Message -ForegroundColor Yellow }
"Error" { Write-Host $Message -ForegroundColor Red }
}
# Optional: log to file
# Add-Content -Path "C:\Path\To\LogFile.txt" -Value "$((Get-Date).ToString('yyyy-MM-dd HH:mm:ss')) [$Type] $Message"
}
function Set-FolderStructure {
param(
[string]$BaseFolder
)
$vmFolder = Join-Path -Path $BaseFolder -ChildPath "VM"
$diskFolder = Join-Path -Path $BaseFolder -ChildPath "Disk"
try {
if (-not (Test-Path -Path $BaseFolder)) {
Write-Message "Base folder does not exist. Creating: $BaseFolder" -Type "Info"
New-Item -Path $BaseFolder -ItemType Directory -Force | Out-Null
Write-Message "Base folder created: $BaseFolder" -Type "Success"
} else {
Write-Message "Base folder already exists: $BaseFolder" -Type "Info"
}
if (-not (Test-Path -Path $vmFolder)) {
Write-Message "VM folder does not exist. Creating: $vmFolder" -Type "Info"
New-Item -Path $vmFolder -ItemType Directory -Force | Out-Null
Write-Message "VM folder created: $vmFolder" -Type "Success"
} else {
Write-Message "VM folder already exists: $vmFolder" -Type "Info"
}
if (-not (Test-Path -Path $diskFolder)) {
Write-Message "Disk folder does not exist. Creating: $diskFolder" -Type "Info"
New-Item -Path $diskFolder -ItemType Directory -Force | Out-Null
Write-Message "Disk folder created: $diskFolder" -Type "Success"
} else {
Write-Message "Disk folder already exists: $diskFolder" -Type "Info"
}
} catch {
Write-Message "Failed to set up folder structure. Error: $_" -Type "Error"
throw
}
}
function Invoke-InternalVMSwitch {
param(
[string]$VMSwitchName
)
try {
$existingSwitch = Get-VMSwitch -Name $VMSwitchName -ErrorAction SilentlyContinue
if ($null -ne $existingSwitch) {
Write-Message "Internal VM switch '$VMSwitchName' already exists." -Type "Success"
} else {
Write-Message "Internal VM switch '$VMSwitchName' does not exist. Creating it..." -Type "Info"
New-VMSwitch -Name $VMSwitchName -SwitchType Internal -ErrorAction Stop | Out-Null
Write-Message "Internal VM switch '$VMSwitchName' created." -Type "Success"
}
} catch {
Write-Message "Failed to create internal VM switch '$VMSwitchName'. Error: $_" -Type "Error"
throw
}
}
function Get-Gateway {
param(
[string]$IPNetwork
)
try {
$ip, $cidr = $IPNetwork -split '/'
$base = [System.Net.IPAddress]::Parse($ip)
$bytes = $base.GetAddressBytes()
$bytes[3] += 1
return [System.Net.IPAddress]::new($bytes)
} catch {
Write-Message "Invalid IP network format: $IPNetwork. Error: $_" -Type "Error"
throw
}
}
function New-VMCreation {
param(
[string]$VMName,
[string]$VMFolder,
[string]$DiskFolder,
[string]$ISOPath,
[long]$Memory,
[int]$Processors,
[array]$Disks,
[array]$NetworkAdapters
)
try {
# OS disk
$VHDName = $Disks[0].Path
$VHDPath = Join-Path -Path $DiskFolder -ChildPath $VHDName
if (-not (Test-Path -Path $VHDPath)) {
New-VHD -Path $VHDPath -SizeBytes $Disks[0].Size -ErrorAction Stop | Out-Null
Write-Message "VHD created at '$VHDPath'." -Type "Success"
} else {
Write-Message "VHD already exists at '$VHDPath'. Skipping." -Type "Warning"
}
# VM
if (-not (Get-VM -Name $VMName -ErrorAction SilentlyContinue)) {
New-VM -Name $VMName -MemoryStartupBytes $Memory -VHDPath $VHDPath -Generation 2 -Path $VMFolder -ErrorAction Stop | Out-Null
Write-Message "VM '$VMName' created." -Type "Success"
} else {
Write-Message "VM '$VMName' already exists. Skipping creation." -Type "Warning"
return
}
# Memory and CPU
Set-VMMemory -VMName $VMName -DynamicMemoryEnabled $false -ErrorAction Stop | Out-Null
Set-VMProcessor -VMName $VMName -Count $Processors -ErrorAction Stop | Out-Null
Write-Message "Memory and processors configured for '$VMName'." -Type "Success"
# Disable checkpoints
Set-VM -VMName $VMName -CheckpointType Disabled -ErrorAction Stop | Out-Null
Write-Message "Checkpoints disabled for '$VMName'." -Type "Success"
# Remove default NIC
Get-VMNetworkAdapter -VMName $VMName | Remove-VMNetworkAdapter -ErrorAction Stop | Out-Null
Write-Message "Default NIC removed from '$VMName'." -Type "Success"
# Add NICs and connect
foreach ($nic in $NetworkAdapters) {
Add-VMNetworkAdapter -VMName $VMName -Name $nic -ErrorAction Stop | Out-Null
Connect-VMNetworkAdapter -VMName $VMName -Name $nic -SwitchName $vSwitchName -ErrorAction Stop | Out-Null
Write-Message "NIC '$nic' added and connected to '$vSwitchName' for '$VMName'." -Type "Success"
}
# Enable MAC spoofing
Get-VMNetworkAdapter -VMName $VMName | Set-VMNetworkAdapter -MacAddressSpoofing On -ErrorAction Stop | Out-Null
Write-Message "MAC spoofing enabled for '$VMName'." -Type "Success"
# Key Protector and vTPM
$GuardianName = $VMName
$existingGuardian = Get-HgsGuardian -Name $GuardianName -ErrorAction SilentlyContinue
if ($null -ne $existingGuardian) {
Write-Message "HgsGuardian '$GuardianName' exists. Deleting and recreating..." -Type "Warning"
Remove-HgsGuardian -Name $GuardianName -ErrorAction Stop | Out-Null
Write-Message "HgsGuardian '$GuardianName' deleted." -Type "Success"
} else {
Write-Message "Creating HgsGuardian '$GuardianName'..." -Type "Info"
}
$newGuardian = New-HgsGuardian -Name $GuardianName -GenerateCertificates -ErrorAction Stop
Write-Message "HgsGuardian '$GuardianName' created." -Type "Success"
$kp = New-HgsKeyProtector -Owner $newGuardian -AllowUntrustedRoot -ErrorAction Stop
Set-VMKeyProtector -VMName $VMName -KeyProtector $kp.RawData -ErrorAction Stop | Out-Null
Enable-VMTPM -VMName $VMName -ErrorAction Stop | Out-Null
Write-Message "Key Protector and vTPM applied to '$VMName'." -Type "Success"
# Additional data disks
for ($i = 1; $i -lt $Disks.Count; $i++) {
$disk = $Disks[$i]
$diskPath = Join-Path -Path $DiskFolder -ChildPath $disk.Path
if (-not (Test-Path -Path $diskPath)) {
New-VHD -Path $diskPath -SizeBytes $disk.Size -ErrorAction Stop | Out-Null
Write-Message "Additional VHD created at '$diskPath'." -Type "Success"
} else {
Write-Message "Additional VHD already exists at '$diskPath'. Skipping." -Type "Warning"
}
Add-VMHardDiskDrive -VMName $VMName -Path $diskPath -ErrorAction Stop | Out-Null
Write-Message "Additional disk '$($disk.Path)' attached to '$VMName'." -Type "Success"
}
# Nested virtualization
Set-VMProcessor -VMName $VMName -ExposeVirtualizationExtensions $true -ErrorAction Stop | Out-Null
Write-Message "Nested virtualization enabled for '$VMName'." -Type "Success"
# Boot media
Add-VMDvdDrive -VMName $VMName -Path $ISOPath -ErrorAction Stop | Out-Null
Write-Message "ISO '$ISOPath' mounted for '$VMName'." -Type "Success"
# Boot order
$firm = Get-VMFirmware -VMName $VMName -ErrorAction Stop
$dvdBoot = ($firm.BootOrder | Where-Object { $_.Device -like '*Dvd*' })[0]
if ($dvdBoot) {
Set-VMFirmware -VMName $VMName -FirstBootDevice $dvdBoot -ErrorAction Stop | Out-Null
Write-Message "Boot order set to DVD first for '$VMName'." -Type "Success"
} else {
Write-Message "DVD device not found in boot order for '$VMName'." -Type "Warning"
}
# Host start and stop actions
Set-VM -Name $VMName -AutomaticStopAction ShutDown -ErrorAction Stop | Out-Null
Set-VM -Name $VMName -AutomaticStartAction Nothing -ErrorAction Stop | Out-Null
Write-Message "Start and stop actions configured for '$VMName'." -Type "Success"
} catch {
Write-Message "Failed to create or configure VM '$VMName'. Error: $_" -Type "Error"
throw
}
}
function Test-TPM {
try {
$tpm = Get-TPM -ErrorAction SilentlyContinue
if ($null -eq $tpm) {
Write-Message "TPM not found on host. A TPM is required for Key Protector and vTPM." -Type "Error"
exit 1
} elseif (-not $tpm.TpmEnabled) {
Write-Message "TPM is present but not enabled. Enable it in BIOS or UEFI." -Type "Error"
exit 1
} else {
Write-Message "TPM is present and enabled." -Type "Success"
}
} catch {
Write-Message "Failed to check TPM status. Error: $_" -Type "Error"
exit 1
}
}
function Test-HyperV {
try {
$feature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -ErrorAction Stop
if ($feature.State -eq "Enabled") {
Write-Message "Hyper-V role already installed." -Type Success
return
}
Write-Message "Installing Hyper-V role and management tools..." -Type Info
Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All -NoRestart -ErrorAction Stop | Out-Null
Write-Message "Hyper-V installed. Please reboot and run the script again." -Type Warning
exit 0
} catch {
Write-Message "Failed to install Hyper-V. $($_)" -Type Error
exit 1
}
}
function Update-ProgressBarMain {
param(
[int]$CurrentStep,
[int]$TotalSteps,
[string]$StatusMessage
)
$percent = [math]::Round(($CurrentStep / $TotalSteps) * 100)
Write-Progress -Id 1 -Activity "Overall Progress" -Status $StatusMessage -PercentComplete $percent
}
function Start-SleepWithProgress {
param(
[int]$Seconds,
[string]$Activity = "Waiting",
[string]$Status = "Please wait..."
)
Write-Message "$Activity : $Status" -Type "Info"
for ($i = 1; $i -le $Seconds; $i++) {
$percent = [math]::Round(($i / $Seconds) * 100)
Write-Progress -Id 2 -Activity "Sleep Progress" -Status "$Activity : $i/$Seconds seconds elapsed..." -PercentComplete $percent
Start-Sleep -Seconds 1
}
Write-Progress -Id 2 -Activity "Sleep Progress" -Completed
Write-Message "$Activity : Completed." -Type "Success"
}
<# ============================================================
NEW: Allow ICMP ping to the NAT gateway on the host interface
============================================================ #>
function Enable-GatewayIcmp {
param(
[Parameter(Mandatory = $true)][string]$InterfaceAlias,
[Parameter(Mandatory = $true)][string]$Gateway,
[Parameter(Mandatory = $true)][string]$SubnetCidr
)
try {
# Ensure Private profile for that interface (helps avoid profile-based blocks)
$netprofile = Get-NetConnectionProfile -InterfaceAlias $InterfaceAlias -ErrorAction SilentlyContinue
if ($netprofile -and $netprofile.NetworkCategory -ne 'Private') {
Set-NetConnectionProfile -InterfaceAlias $InterfaceAlias -NetworkCategory Private | Out-Null
Write-Message "Network profile for '$InterfaceAlias' set to Private." -Type "Info"
}
$ruleName = "Allow-ICMPv4-$Gateway"
$existing = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
if (-not $existing) {
New-NetFirewallRule `
-DisplayName $ruleName `
-Direction Inbound `
-Action Allow `
-Protocol ICMPv4 `
-IcmpType 8 `
-LocalAddress $Gateway `
-RemoteAddress $SubnetCidr `
-Profile Any | Out-Null
Write-Message "Firewall rule created to allow ICMPv4 Echo to $Gateway from $SubnetCidr." -Type "Success"
} else {
Write-Message "Firewall rule '$ruleName' already exists." -Type "Info"
}
} catch {
Write-Message "Could not configure ICMP rule. Error: $_" -Type "Error"
throw
}
}
# Misspelling-friendly alias so both names work
Set-Alias -Name Enable-GetawayIcmp -Value Enable-GatewayIcmp -ErrorAction SilentlyContinue
#endregion
#region Script Execution
foreach ($task in $tasks) {
$currentTask++
Update-ProgressBarMain -CurrentStep $currentTask -TotalSteps $totalTasks -StatusMessage "$task..."
switch ($task) {
"Checking Prerequisites" {
Write-Message "Checking prerequisites..." -Type "Info"
Test-TPM
Test-HyperV
Write-Message "Prerequisite checks completed successfully." -Type "Success"
}
"Configuring Virtual Switch and NAT" {
Write-Message "Configuring virtual switch and NAT settings..." -Type "Info"
Invoke-InternalVMSwitch -VMSwitchName $vSwitchName
Start-Sleep -Seconds 3
# Calculate gateway address, assign IP to host vSwitch NIC, and create NAT
try {
$vIPNetworkGW = Get-Gateway -IPNetwork $vNetIPNetwork
Write-Message "Calculated gateway: $vIPNetworkGW" -Type "Info"
} catch {
Write-Message "Failed to calculate gateway. Exiting." -Type "Error"
exit 1
}
try {
# Assign IP address to host vSwitch interface
$existingIPAddress = Get-NetIPAddress -IPAddress $vIPNetworkGW -InterfaceAlias $vSwitchNIC -ErrorAction SilentlyContinue
if ($null -eq $existingIPAddress) {
Write-Message "Assigning $vIPNetworkGW to $vSwitchNIC" -Type "Info"
New-NetIPAddress -IPAddress $vIPNetworkGW -PrefixLength $vIPNetworkPrefixLength -InterfaceAlias $vSwitchNIC -ErrorAction Stop | Out-Null
Write-Message "Assigned $vIPNetworkGW to $vSwitchNIC." -Type "Success"
} else {
Write-Message "IP $vIPNetworkGW already present on $vSwitchNIC. Skipping assignment." -Type "Warning"
}
# Create NAT if missing
$existingNat = Get-NetNat -Name $natName -ErrorAction SilentlyContinue
if ($null -eq $existingNat) {
Write-Message "Creating NAT '$natName' for $vNetIPNetwork" -Type "Info"
New-NetNat -Name $natName -InternalIPInterfaceAddressPrefix $vNetIPNetwork -ErrorAction Stop | Out-Null
Write-Message "NAT '$natName' created." -Type "Success"
} else {
Write-Message "NAT '$natName' already exists. Skipping creation." -Type "Warning"
}
} catch {
Write-Message "Failed to configure IP or NAT. Error: $_" -Type "Error"
exit 1
}
# Allow ICMP echo to the gateway on the host interface for VMs in the lab subnet
try {
Enable-GatewayIcmp -InterfaceAlias $vSwitchNIC -Gateway "$vIPNetworkGW" -SubnetCidr $vNetIPNetwork
} catch {
Write-Message "Failed to enable ICMP echo to gateway. Error: $_" -Type "Error"
exit 1
}
Start-Sleep -Seconds 3
Write-Message "Virtual switch and NAT configuration completed." -Type "Success"
}
"Setting Up Folder Structures" {
Write-Message "Setting up folder structures..." -Type "Info"
try {
Set-FolderStructure -BaseFolder $HCIRootFolder
$HCIDiskFolder = Join-Path -Path $HCIRootFolder -ChildPath "Disk"
$HCIVMFolder = Join-Path -Path $HCIRootFolder -ChildPath "VM"
Write-Message "Folder structures ready." -Type "Success"
} catch {
Write-Message "Failed to set up folder structures. Error: $_" -Type "Error"
exit 1
}
}
"Creating HCI Node VM" {
Write-Message "Creating HCI Node VM..." -Type "Info"
try {
New-VMCreation -VMName $HCIVMName `
-VMFolder $HCIVMFolder `
-DiskFolder $HCIDiskFolder `
-ISOPath $isoPath_HCI `
-Memory $HCI_Memory `
-Processors $HCI_Processors `
-Disks $HCI_Disks `
-NetworkAdapters $HCI_NetworkAdapters
# Disable time synchronization integration service
Get-VMIntegrationService -VMName $HCIVMName | Where-Object { $_.Name -like "*Sync*" } | Disable-VMIntegrationService -ErrorAction Stop | Out-Null
Write-Message "Time synchronization disabled for '$HCIVMName'." -Type "Success"
} catch {
Write-Message "Failed to create HCI Node VM '$HCIVMName'. Error: $_" -Type "Error"
exit 1
}
}
"Creating Domain Controller VM" {
Write-Message "Creating Domain Controller VM..." -Type "Info"
try {
New-VMCreation -VMName $DCVMName `
-VMFolder $HCIVMFolder `
-DiskFolder $HCIDiskFolder `
-ISOPath $isoPath_DC `
-Memory $DC_Memory `
-Processors $DC_Processors `
-Disks $DC_Disks `
-NetworkAdapters $DC_NetworkAdapters
# Disable time synchronization integration service
Get-VMIntegrationService -VMName $DCVMName | Where-Object { $_.Name -like "*Sync*" } | Disable-VMIntegrationService -ErrorAction Stop | Out-Null
Write-Message "Time synchronization disabled for '$DCVMName'." -Type "Success"
} catch {
Write-Message "Failed to create Domain Controller VM '$DCVMName'. Error: $_" -Type "Error"
exit 1
}
}
}
}
Write-Progress -Id 1 -Activity "Configuring Infrastructure and Creating VMs" -Completed -Status "All tasks completed."
Write-Message "All configurations and VM creations completed successfully." -Type "Success"
#endregion
01_DC.ps1
# 01_DC.ps1
# Domain Controller Configuration Script
<#
.SYNOPSIS
Configures the Domain Controller VM, sets network settings, promotes it to a Domain Controller, installs updates, and sets up Active Directory Organizational Units.
.DESCRIPTION
This script performs the following tasks:
- Sets up credentials and network configurations.
- Removes the ISO from the DC VM.
- Renames the Domain Controller VM.
- Configures network adapters with static IP settings.
- Sets the time zone.
- Promotes the VM to a Domain Controller.
- Installs Windows Updates.
- Configures DNS forwarders.
- Creates Organizational Units (OUs) in Active Directory.
- Installs necessary modules and creates Azure Local AD objects.
.NOTES
- Designed by Cristian Schmitt Nieto. For more information and usage, visit: https://schmitt-nieto.com/blog/azure-stack-hci-demolab/
- Run this script with administrative privileges.
- Ensure the Execution Policy allows the script to run. To set the execution policy, you can run:
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
#>
#region Variables
# Load the lab configuration into this session if it has not been loaded yet.
# Tip: run scripts\01Lab\Set-LabEnv.ps1 once per session to set everything from
# scripts\01Lab\.env. See scripts\01Lab\.env.example for the full list of keys.
if ($env:AZSHCI_ENV_LOADED -ne '1') { & "$PSScriptRoot\Set-LabEnv.ps1" }
# Define credentials and variables
$defaultUser = $env:AZSHCI_DEFAULT_ADMIN_USER
$defaultPwd = $env:AZSHCI_DEFAULT_ADMIN_PASSWORD
$DefaultSecuredPassword = ConvertTo-SecureString $defaultPwd -AsPlainText -Force
$DefaultCredentials = New-Object System.Management.Automation.PSCredential ($defaultUser, $DefaultSecuredPassword)
# VM and Domain Variables
$dcVMName = $env:AZSHCI_DC_VM_NAME
$domainName = $env:AZSHCI_DOMAIN_NAME
$netBIOSName = $env:AZSHCI_DOMAIN_NETBIOS
$NIC1 = $env:AZSHCI_MGMT_NIC1
$nic1IP = $env:AZSHCI_DC_IP
$nic1GW = $env:AZSHCI_LAB_GATEWAY
$nic1DNS = $env:AZSHCI_DC_IP
# Variables for DNS forwarder and time zone
$dnsForwarder = $env:AZSHCI_DNS_FORWARDER
$timeZone = $env:AZSHCI_DC_TIMEZONE # Use "Get-TimeZone -ListAvailable" to get a list of available Time Zones
# User for Azure Local LCM User (to be used later)
$setupUser = $env:AZSHCI_DC_LCM_USER
$setupPwd = $env:AZSHCI_DC_LCM_PASSWORD
# Sleep durations in seconds
$SleepRename = [int]$env:AZSHCI_DC_SLEEP_RENAME # Sleep Timer for after PC Renaming
$SleepDomain = [int]$env:AZSHCI_DC_SLEEP_DOMAIN # Sleep Timer for after Domain Making
$SleepUpdates = [int]$env:AZSHCI_DC_SLEEP_UPDATES # Sleep Timer for after Update Installation
# $SleepADServices = 30 # Increased Sleep Timer after DC promotion before configuring AD
# Total number of steps for progress calculation
$totalSteps = 13
$currentStep = 0
#endregion
#region Functions
# Function to Display Messages with Colors
function Write-Message {
param (
[string]$Message,
[ValidateSet("Info", "Success", "Warning", "Error")]
[string]$Type = "Info"
)
switch ($Type) {
"Info" { Write-Host $Message -ForegroundColor Cyan }
"Success" { Write-Host $Message -ForegroundColor Green }
"Warning" { Write-Host $Message -ForegroundColor Yellow }
"Error" { Write-Host $Message -ForegroundColor Red }
}
# Optional: Log messages to a file
# Uncomment and configure the following lines to enable logging
# $LogFile = "C:\HCI\DeploymentLogs\01_DC_Log_$(Get-Date -Format 'yyyyMMdd_HHmmss').txt"
# New-Item -Path (Split-Path $LogFile) -ItemType Directory -Force | Out-Null
# Add-Content -Path $LogFile -Value "$((Get-Date).ToString('yyyy-MM-dd HH:mm:ss')) [$Type] $Message"
}
# Function to Update Progress Bar (Main Progress)
function Update-ProgressBar {
param (
[int]$CurrentStep,
[int]$TotalSteps,
[string]$StatusMessage
)
$percent = [math]::Round(($CurrentStep / $TotalSteps) * 100)
Write-Progress -Id 1 -Activity "Overall Progress" -Status $StatusMessage -PercentComplete $percent
}
# Function to Start Sleep with Progress Message and Additional Progress Bar (Subtask)
function Start-SleepWithProgress {
param(
[int]$Seconds,
[string]$Activity = "Waiting",
[string]$Status = "Please wait..."
)
Write-Message "$Activity : $Status" -Type "Info"
for ($i = 1; $i -le $Seconds; $i++) {
# Check if a key has been pressed
if ([Console]::KeyAvailable) {
$key = [Console]::ReadKey($true)
if ($key.Key -eq 'Spacebar') {
Write-Message "Sleep skipped by user." -Type "Warning"
break
}
}
$percent = [math]::Round(($i / $Seconds) * 100)
Write-Progress -Id 2 -Activity "Sleep Progress" -Status "$Activity : $i/$Seconds seconds elapsed... Use Spacebar to Skip" -PercentComplete $percent
Start-Sleep -Seconds 1
}
Write-Progress -Id 2 -Activity "Sleep Progress" -Completed
Write-Message "$Activity : Completed." -Type "Success"
}
# Function to Wait Until Active Directory is Ready
function Wait-UntilADReady {
param(
[string]$VMName,
[int]$Timeout = 600 # Timeout in seconds (10 minutes)
)
$elapsed = 0
$interval = 10
Write-Message "Checking if Active Directory services are operational on VM '$VMName'..." -Type "Info"
while ($elapsed -lt $Timeout) {
try {
Invoke-Command -VMName $VMName -Credential $DomainAdminCredentials -ScriptBlock {
Get-ADDomain -ErrorAction Stop
} -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
Write-Message "Active Directory services are operational on VM '$VMName'." -Type "Success"
return
} catch {
Write-Message "Active Directory services not ready yet. Waiting..." -Type "Info"
Start-Sleep -Seconds $interval
$elapsed += $interval
}
}
Write-Message "Active Directory services did not become operational within the expected time." -Type "Error"
exit 1
}
#endregion
#region Script Execution
# Step 1: Remove ISO from DC VM
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Removing ISO from VM..."
Write-Message "Removing ISO from DC VM '$dcVMName'..." -Type "Info"
try {
Get-VMDvdDrive -VMName $dcVMName | Where-Object { $_.DvdMediaType -eq "ISO" } | Remove-VMDvdDrive -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
Write-Message "ISO removed from VM '$dcVMName'." -Type "Success"
} catch {
Write-Message "Failed to remove ISO from VM '$dcVMName'. Error: $_" -Type "Error"
exit 1
}
# Step 2: Retrieve and format MAC address
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Retrieving MAC address..."
Write-Message "Retrieving MAC address for network adapter '$NIC1' on VM '$dcVMName'..." -Type "Info"
try {
$dcMacNIC1 = Get-VMNetworkAdapter -VMName $dcVMName -Name $NIC1 -ErrorAction Stop -WarningAction SilentlyContinue
$dcMacNIC1Address = $dcMacNIC1.MacAddress
$dcFinalMacNIC1 = $dcMacNIC1Address.Insert(2,"-").Insert(5,"-").Insert(8,"-").Insert(11,"-").Insert(14,"-").ToUpper()
Write-Message "Formatted MAC address for '$NIC1': $dcFinalMacNIC1" -Type "Success"
} catch {
Write-Message "Failed to retrieve MAC address for '$NIC1'. Error: $_" -Type "Error"
exit 1
}
# Step 3: Rename the DC VM
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Renaming VM..."
Write-Message "Renaming VM '$dcVMName'..." -Type "Info"
try {
Invoke-Command -VMName $dcVMName -Credential $DefaultCredentials -ScriptBlock {
param($dcVMName)
$ErrorActionPreference = 'Stop'
$WarningPreference = 'SilentlyContinue'
$VerbosePreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
# Rename computer
Rename-Computer -NewName $dcVMName -Force -ErrorAction Stop
# Restart computer
Restart-Computer -Force -ErrorAction Stop
} -ArgumentList $dcVMName -ErrorAction Stop -WarningAction SilentlyContinue -Verbose:$false | Out-Null
Write-Message "VM '$dcVMName' has been renamed and will restart to apply changes." -Type "Success"
# Restart the DC VM to apply changes
Write-Message "VM '$dcVMName' is restarting..." -Type "Info"
Start-SleepWithProgress -Seconds $SleepRename -Activity "Restarting VM" -Status "Waiting for VM to restart" # 20 Seconds
} catch {
Write-Message "Failed to rename or restart VM '$dcVMName'. Error: $_" -Type "Error"
exit 1
}
# Step 4: Configure Network Settings
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Configuring network settings..."
Write-Message "Configuring network settings for VM '$dcVMName'..." -Type "Info"
try {
Invoke-Command -VMName $dcVMName -Credential $DefaultCredentials -ScriptBlock {
param($NIC1, $nic1IP, $nic1GW, $nic1DNS, $dcFinalMacNIC1)
$ErrorActionPreference = 'Stop'
$WarningPreference = 'SilentlyContinue'
$VerbosePreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
# Rename network adapter based on MAC address
Get-NetAdapter -Physical | Where-Object { $_.MacAddress -eq $dcFinalMacNIC1 } | Rename-NetAdapter -NewName $NIC1 -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
# Disable DHCP and set static IP
Set-NetIPInterface -InterfaceAlias $NIC1 -Dhcp Disabled -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
New-NetIPAddress -InterfaceAlias $NIC1 -IPAddress $nic1IP -PrefixLength 24 -DefaultGateway $nic1GW -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
Set-DnsClientServerAddress -InterfaceAlias $NIC1 -ServerAddresses $nic1DNS -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
} -ArgumentList $NIC1, $nic1IP, $nic1GW, $nic1DNS, $dcFinalMacNIC1 -ErrorAction Stop -WarningAction SilentlyContinue -Verbose:$false | Out-Null
Write-Message "The IP address of NIC '$NIC1' is $nic1IP." -Type "Success"
} catch {
Write-Message "Failed to configure network settings for VM '$dcVMName'. Error: $_" -Type "Error"
exit 1
}
# Step 5: Set the time zone
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Setting time zone..."
Write-Message "Setting time zone for VM '$dcVMName'..." -Type "Info"
try {
Invoke-Command -VMName $dcVMName -Credential $DefaultCredentials -ScriptBlock {
param($timeZone)
$ErrorActionPreference = 'Stop'
$WarningPreference = 'SilentlyContinue'
$VerbosePreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
# Set the time zone
Set-TimeZone -Name $timeZone -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
} -ArgumentList $timeZone -ErrorAction Stop -WarningAction SilentlyContinue -Verbose:$false | Out-Null
Write-Message "Time zone set to '$timeZone' for VM '$dcVMName'." -Type "Success"
} catch {
Write-Message "Failed to set time zone for VM '$dcVMName'. Error: $_" -Type "Error"
exit 1
}
# Step 6: Promote the DC VM to a Domain Controller
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Promoting to Domain Controller..."
Write-Message "Promoting VM '$dcVMName' to a Domain Controller..." -Type "Info"
try {
Invoke-Command -VMName $dcVMName -Credential $DefaultCredentials -ScriptBlock {
param($domainName, $netBIOSName, $defaultPwd)
$ErrorActionPreference = 'Stop'
$WarningPreference = 'SilentlyContinue'
$VerbosePreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
# Intall Hyper-V and Failover Clustering management features
Install-WindowsFeature -Name RSAT-Clustering, RSAT-Clustering-Mgmt, RSAT-Clustering-PowerShell, RSAT-Clustering-CmdInterface -ErrorAction Stop | Out-Null
Install-WindowsFeature -Name Hyper-V-Tools, Hyper-V-PowerShell -ErrorAction Stop | Out-Null
# Install Active Directory Domain Services
Install-WindowsFeature -Name AD-Domain-Services -IncludeManagementTools -ErrorAction Stop | Out-Null
# Import the ADDSDeployment module after installing the feature
Import-Module ADDSDeployment -ErrorAction Stop | Out-Null
# Secure password for DSRM
$SecureDSRMPassword = ConvertTo-SecureString $defaultPwd -AsPlainText -Force
# Promote to Domain Controller
Install-ADDSForest `
-DomainName $domainName `
-DomainNetbiosName $netBIOSName `
-SafeModeAdministratorPassword $SecureDSRMPassword `
-InstallDns `
-Force:$true `
-Confirm:$false `
-ErrorAction Stop | Out-Null
} -ArgumentList $domainName, $netBIOSName, $defaultPwd -ErrorAction Stop -WarningAction SilentlyContinue -Verbose:$false | Out-Null
Write-Message "Domain Controller promotion initiated for VM '$dcVMName'." -Type "Success"
# Wait for DC promotion to complete
Write-Message "Waiting for Domain Controller promotion to complete..." -Type "Info"
Start-SleepWithProgress -Seconds $SleepDomain -Activity "Waiting for DC promotion" -Status "Waiting for VM to restart and apply domain changes" # 360 Seconds (6 Minutes)
} catch {
Write-Message "Failed to promote VM '$dcVMName' to a Domain Controller. Error: $_" -Type "Error"
exit 1
}
# Step 7: Update credentials to use the domain Administrator account
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Updating credentials..."
Write-Message "Updating credentials to use the domain Administrator account..." -Type "Info"
try {
$domainAdminUser = "$netBIOSName\$defaultUser"
$DomainAdminCredentials = New-Object System.Management.Automation.PSCredential ($domainAdminUser, $DefaultSecuredPassword)
Write-Message "Credentials updated successfully." -Type "Success"
} catch {
Write-Message "Failed to update credentials. Error: $_" -Type "Error"
exit 1
}
# Step 8: Configure DNS Forwarder and Time Server
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Configuring DNS forwarder..."
Write-Message "Configuring DNS forwarder on VM '$dcVMName'..." -Type "Info"
try {
Invoke-Command -VMName $dcVMName -Credential $DomainAdminCredentials -ScriptBlock {
param($dnsForwarder)
$ErrorActionPreference = 'Stop'
$WarningPreference = 'SilentlyContinue'
$VerbosePreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
# Import DNS Server module
Import-Module DNSServer -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
# Add DNS forwarder
Add-DnsServerForwarder -IPAddress $dnsForwarder -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
# Configure time synchronization
w32tm /config /manualpeerlist:europe.pool.ntp.org /syncfromflags:manual /reliable:yes /update | Out-Null
Restart-Service w32time -Force | Out-Null
w32tm /resync | Out-Null
} -ArgumentList $dnsForwarder -ErrorAction Stop -WarningAction SilentlyContinue -Verbose:$false | Out-Null
Write-Message "DNS forwarder to $dnsForwarder added successfully on VM '$dcVMName'." -Type "Success"
} catch {
Write-Message "Failed to configure DNS forwarder on VM '$dcVMName'. Error: $_" -Type "Error"
exit 1
}
# Step 9: Enable Remote Desktop (RDP) on the Domain Controller
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Enabling Remote Desktop on DC..."
Write-Message "Enabling Remote Desktop (RDP) on VM '$dcVMName'..." -Type "Info"
try {
Invoke-Command -VMName $dcVMName -Credential $DomainAdminCredentials -ScriptBlock {
$ErrorActionPreference = 'Stop'
$WarningPreference = 'SilentlyContinue'
$VerbosePreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
# Allow RDP connections in the registry
Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name "fDenyTSConnections" -Value 0 -ErrorAction Stop
# Ensure Network Level Authentication (NLA) is enabled (recommended)
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "UserAuthentication" -Value 1 -ErrorAction SilentlyContinue
# Make sure TermService is set to Automatic and started
Set-Service -Name TermService -StartupType Automatic -ErrorAction Stop
Start-Service -Name TermService -ErrorAction Stop
# Enable built-in Remote Desktop firewall rules
try {
# Use display group to cover the common RDP rules
Enable-NetFirewallRule -DisplayGroup "Remote Desktop" -ErrorAction Stop
} catch {
# Fallback to common rule names if the display group isn't available
$ruleNames = @("RemoteDesktop-UserMode-In-TCP","RemoteDesktop-UserMode-In-UDP","RemoteDesktop-UserMode-Public-In-TCP")
foreach ($r in $ruleNames) {
if (Get-NetFirewallRule -Name $r -ErrorAction SilentlyContinue) {
Enable-NetFirewallRule -Name $r -ErrorAction SilentlyContinue
}
}
}
} -ErrorAction Stop -WarningAction SilentlyContinue -Verbose:$false | Out-Null
Write-Message "RDP enabled and firewall rules updated on VM '$dcVMName'." -Type "Success"
} catch {
Write-Message "Failed to enable RDP on VM '$dcVMName'. Error: $_" -Type "Error"
exit 1
}
# Step 10: Install Windows Updates
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Installing Windows Updates..."
Write-Message "Installing Windows Updates on VM '$dcVMName'..." -Type "Info"
try {
Invoke-Command -VMName $dcVMName -Credential $DomainAdminCredentials -ScriptBlock {
$ErrorActionPreference = 'Stop'
$WarningPreference = 'SilentlyContinue'
$VerbosePreference = 'SilentlyContinue'
$ProgressPreference = 'Continue'
# Import the PSWindowsUpdate module
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
Install-Module PSWindowsUpdate -Force -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
Import-Module PSWindowsUpdate -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
# Install available updates
Install-WindowsUpdate -MicrosoftUpdate -AcceptAll -AutoReboot -IgnoreReboot -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
} -ErrorAction Stop -WarningAction SilentlyContinue -Verbose:$false | Out-Null
Write-Message "Windows Updates installation initiated on VM '$dcVMName'." -Type "Success"
# Wait for the DC VM to restart after updates
Write-Message "Waiting for the Domain Controller to restart after updates..." -Type "Info"
Start-SleepWithProgress -Seconds $SleepUpdates -Activity "Waiting for VM to restart" -Status "Waiting for VM to restart and apply updates" # 240 Seconds (4 Minutes)
} catch {
Write-Message "Failed to install Windows Updates on VM '$dcVMName'. Error: $_" -Type "Error"
exit 1
}
# Step 11: Create Organizational Units (OUs)
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Creating Organizational Units..."
Write-Message "Creating Organizational Units (OUs) in Active Directory on VM '$dcVMName'..." -Type "Info"
try {
# Wait until AD is ready
Wait-UntilADReady -VMName $dcVMName -Timeout 600 # 10 minutes
# Proceed to create OUs
Invoke-Command -VMName $dcVMName -Credential $DomainAdminCredentials -ScriptBlock {
param($netBIOSName, $domainName)
$ErrorActionPreference = 'Stop'
$WarningPreference = 'SilentlyContinue'
$VerbosePreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
try {
# Import Active Directory module
Import-Module ActiveDirectory -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
# Get the current domain
$domainDN = (Get-ADDomain).DistinguishedName
# Define the root OU path
$rootOU = "OU=_LAB,$domainDN"
# Create the root OU "_LAB" (if it doesn't exist)
if (-not (Get-ADOrganizationalUnit -Filter { Name -eq "_LAB" } -SearchBase $domainDN -ErrorAction SilentlyContinue )) {
New-ADOrganizationalUnit -Name "_LAB" -Path $domainDN -ErrorAction Stop | Out-Null
Write-Host "Created root OU '_LAB'."
}
# Create sub-OUs for Users
$userOUs = @("Users", "Administrative", "Technical", "Financial", "Workers")
foreach ($ou in $userOUs) {
if ($ou -eq "Users") {
$path = $rootOU
} else {
$path = "OU=Users,$rootOU"
}
if (-not (Get-ADOrganizationalUnit -Filter { Name -eq $ou } -SearchBase $path -ErrorAction SilentlyContinue )) {
New-ADOrganizationalUnit -Name $ou -Path $path -ErrorAction Stop | Out-Null
Write-Host "Created OU '$ou' under '$path'."
}
}
# Create sub-OUs for Servers
$serverOUs = @("Servers", "Windows", "Linux", "HCI")
foreach ($ou in $serverOUs) {
if ($ou -eq "Servers") {
$path = $rootOU
} else {
$path = "OU=Servers,$rootOU"
}
if (-not (Get-ADOrganizationalUnit -Filter { Name -eq $ou } -SearchBase $path -ErrorAction SilentlyContinue )) {
New-ADOrganizationalUnit -Name $ou -Path $path -ErrorAction Stop | Out-Null
Write-Host "Created OU '$ou' under '$path'."
}
}
# Create sub-OUs for Groups
$groupOUs = @("Groups", "Security", "Distribution")
foreach ($ou in $groupOUs) {
if ($ou -eq "Groups") {
$path = $rootOU
} else {
$path = "OU=Groups,$rootOU"
}
if (-not (Get-ADOrganizationalUnit -Filter { Name -eq $ou } -SearchBase $path -ErrorAction SilentlyContinue )) {
New-ADOrganizationalUnit -Name $ou -Path $path -ErrorAction Stop | Out-Null
Write-Host "Created OU '$ou' under '$path'."
}
}
# Create sub-OUs for Computers
$computerOUs = @("Computers", "Desktops", "Laptops", "AVD")
foreach ($ou in $computerOUs) {
if ($ou -eq "Computers") {
$path = $rootOU
} else {
$path = "OU=Computers,$rootOU"
}
if (-not (Get-ADOrganizationalUnit -Filter { Name -eq $ou } -SearchBase $path -ErrorAction SilentlyContinue )) {
New-ADOrganizationalUnit -Name $ou -Path $path -ErrorAction Stop | Out-Null
Write-Host "Created OU '$ou' under '$path'."
}
}
} catch {
Write-Error "An error occurred while creating OUs: $_"
throw $_
}
} -ArgumentList $netBIOSName, $domainName -ErrorAction Stop -WarningAction SilentlyContinue -Verbose:$false | Out-Null
Write-Message "Organizational Units (OUs) created successfully in Active Directory." -Type "Success"
} catch {
Write-Message "Failed to create Organizational Units (OUs) in Active Directory. Error: $_" -Type "Error"
exit 1
}
# Step 12: Install Azure Local AD Artifacts
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Installing Azure Local AD Artifacts..."
Write-Message "Installing Azure Local AD Artifacts Pre-Creation Tool and creating AD objects..." -Type "Info"
try {
Invoke-Command -VMName $dcVMName -Credential $DomainAdminCredentials -ScriptBlock {
param($setupUser, $setupPwd)
$ErrorActionPreference = 'Stop'
$WarningPreference = 'SilentlyContinue'
$VerbosePreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
# Suppress informational messages
$InformationPreference = 'SilentlyContinue'
# Import Active Directory module
Import-Module ActiveDirectory -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
# Suppress confirmation prompts
$ConfirmPreferenceBackup = $ConfirmPreference
$ConfirmPreference = 'None'
try {
# Install the NuGet package provider if not already installed
if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) {
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
}
# Install the AsHciADArtifactsPreCreationTool module from PSGallery
if (-not (Get-Module -ListAvailable -Name AsHciADArtifactsPreCreationTool)) {
Install-Module AsHciADArtifactsPreCreationTool -Repository PSGallery -Force -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
}
# Define the OU path for Azure Local
$AsHciOUPath = "OU=HCI,OU=Servers,OU=_LAB," + (Get-ADDomain).DistinguishedName
# Secure credentials for Azure Local user
$SecurePassword = ConvertTo-SecureString $setupPwd -AsPlainText -Force
$AzureStackLCMUserCredential = New-Object System.Management.Automation.PSCredential ($setupUser, $SecurePassword)
# Create the AD objects and suppress all outputs
New-HciAdObjectsPreCreation -AzureStackLCMUserCredential $AzureStackLCMUserCredential -AsHciOUName $AsHciOUPath -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
} catch {
throw $_
} finally {
# Restore the original ConfirmPreference
$ConfirmPreference = $ConfirmPreferenceBackup
}
} -ArgumentList $setupUser, $setupPwd -ErrorAction Stop -WarningAction SilentlyContinue -Verbose:$false | Out-Null
Write-Message "Azure Local AD objects created successfully." -Type "Success"
} catch {
Write-Message "Failed to create Azure Local AD objects. Error: $_" -Type "Error"
exit 1
}
# Step 13: Final Completion
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Finalizing..."
Write-Message "Domain Controller configuration completed successfully." -Type "Success"
# Complete the overall progress bar
$currentStep = $totalSteps
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "All tasks completed."
#endregion
02_Cluster.ps1
# 02_Cluster.ps1
# Cluster Node Creation Script
<#
.SYNOPSIS
Configures the cluster node VM, sets up network settings, installs required features, and registers the node with Azure Arc.
.DESCRIPTION
This script performs the following tasks:
- Sets up credentials and network configurations.
- Removes the ISO from the node VM.
- Renames the node VM.
- Configures network adapters with static IP settings.
- Installs required Windows features.
- Registers the node with Azure Arc.
Two execution modes are available:
- Full setup: Runs all steps (ISO removal, user creation, NIC config, Arc registration).
- Arc only: Runs only the Arc registration step. Use this to retry a failed registration
without repeating the full node configuration.
Arc registration authentication:
- If $SPNAppId and $SPNSecret are set, the script authenticates via SPN,
obtains an ARM access token and passes it to Invoke-AzStackHciArcInitialization
using -AccountId and -ArmAccessToken. The cmdlet does not use the current
Az session, so the token must be supplied explicitly.
- If either value is empty, the node falls back to an interactive device code login.
.NOTES
- Designed by Cristian Schmitt Nieto. For more information and usage, visit: https://schmitt-nieto.com/blog/azure-stack-hci-demolab/
- Run this script with administrative privileges.
- Ensure the Execution Policy allows the script to run. To set the execution policy, you can run:
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
- Updates:
- 2024/11/28: Changing Module Versions and ISO for 2411
- 2025/07/01: Update the scripts for version 2505
- 2025/07/03: Update the scripts for version 2506
- 2025/08/26: Update the scripts for version 2508
- 2025/09/15: Arc initialization now runs on node without ArmAccessToken or AccountID
- 2026/03/26: Added Arc-only mode, optional SPN authentication and BootstrapOobeService retry
#>
#region Variables
# Load the lab configuration into this session if it has not been loaded yet.
# Tip: run scripts\01Lab\Set-LabEnv.ps1 once per session to set everything from
# scripts\01Lab\.env. See scripts\01Lab\.env.example for the full list of keys.
if ($env:AZSHCI_ENV_LOADED -ne '1') { & "$PSScriptRoot\Set-LabEnv.ps1" }
# Credentials and User Configuration
$defaultUser = $env:AZSHCI_DEFAULT_ADMIN_USER
$defaultPwd = $env:AZSHCI_DEFAULT_ADMIN_PASSWORD
$DefaultSecuredPassword = ConvertTo-SecureString $defaultPwd -AsPlainText -Force
$DefaultCredentials = New-Object System.Management.Automation.PSCredential ($defaultUser, $DefaultSecuredPassword)
$setupUser = $env:AZSHCI_NODE_SETUP_USER
$setupPwd = $env:AZSHCI_NODE_SETUP_PASSWORD
# Node Configuration
$nodeName = $env:AZSHCI_HCI_VM_NAME
$NIC1 = $env:AZSHCI_MGMT_NIC1
$NIC2 = $env:AZSHCI_MGMT_NIC2
$nic1IP = $env:AZSHCI_NODE_IP
$nic1GW = $env:AZSHCI_LAB_GATEWAY
$nic1DNS = $env:AZSHCI_DC_IP
# Azure Configuration (set these in scripts\01Lab\.env)
$Location = $env:AZSHCI_LOCATION
$Cloud = $env:AZSHCI_CLOUD
$SubscriptionID = [string]$env:AZSHCI_SUBSCRIPTION_ID
$resourceGroupName = [string]$env:AZSHCI_RESOURCE_GROUP
$TenantID = [string]$env:AZSHCI_TENANT_ID
# SPN Configuration (optional)
# If both values are set, the script obtains an ARM access token from the SPN
# and passes it to Invoke-AzStackHciArcInitialization via -AccountId / -ArmAccessToken.
# If either is empty, the node falls back to an interactive device code login.
# Use the AppId and Secret generated by scripts/01Lab/00_AzurePreRequisites.ps1.
$SPNAppId = [string]$env:AZSHCI_SPN_APP_ID # Application (client) ID
$SPNSecret = [string]$env:AZSHCI_SPN_SECRET # Client secret
# Sleep durations in seconds
$SleepRestart = [int]$env:AZSHCI_NODE_SLEEP_RESTART # Sleep after VM restart
$SleepFeatures = [int]$env:AZSHCI_NODE_SLEEP_FEATURES # Sleep after feature installation and restart
$SleepModules = [int]$env:AZSHCI_NODE_SLEEP_MODULES # Sleep after module installation
# Arc registration retry settings
# If Invoke-AzStackHciArcInitialization fails with a BootstrapOobeService
# connection error, the script waits $SleepBootstrap seconds and retries.
$ArcRetryCount = [int]$env:AZSHCI_ARC_RETRY_COUNT # Maximum number of attempts
$SleepBootstrap = [int]$env:AZSHCI_ARC_SLEEP_BOOTSTRAP # Seconds to wait between retries
#endregion
#region Functions
# Function to Display Messages with Colors
function Write-Message {
param (
[string]$Message,
[ValidateSet("Info", "Success", "Warning", "Error")]
[string]$Type = "Info"
)
switch ($Type) {
"Info" { Write-Host $Message -ForegroundColor Cyan }
"Success" { Write-Host $Message -ForegroundColor Green }
"Warning" { Write-Host $Message -ForegroundColor Yellow }
"Error" { Write-Host $Message -ForegroundColor Red }
}
}
# Function to Format MAC Addresses
function Format-MacAddress {
param (
[string]$mac
)
return $mac.Insert(2,"-").Insert(5,"-").Insert(8,"-").Insert(11,"-").Insert(14,"-").ToUpper()
}
# Function to Update Progress Bar (Main Progress)
function Update-ProgressBar {
param (
[int]$CurrentStep,
[int]$TotalSteps,
[string]$StatusMessage
)
$percent = [math]::Round(($CurrentStep / $TotalSteps) * 100)
Write-Progress -Id 1 -Activity "Overall Progress" -Status $StatusMessage -PercentComplete $percent
}
# Function to Start Sleep with Progress Message and Additional Progress Bar (Subtask)
function Start-SleepWithProgress {
param(
[int]$Seconds,
[string]$Activity = "Waiting",
[string]$Status = "Please wait..."
)
Write-Message "$Activity : $Status" -Type "Info"
for ($i = 1; $i -le $Seconds; $i++) {
if ([Console]::KeyAvailable) {
$key = [Console]::ReadKey($true)
if ($key.Key -eq 'Spacebar') {
Write-Message "Sleep skipped by user." -Type "Warning"
break
}
}
$percent = [math]::Round(($i / $Seconds) * 100)
Write-Progress -Id 2 -Activity "Sleep Progress" -Status "$Activity : $i/$Seconds seconds elapsed... Use Spacebar to Break" -PercentComplete $percent
Start-Sleep -Seconds 1
}
Write-Progress -Id 2 -Activity "Sleep Progress" -Completed
Write-Message "$Activity : Completed." -Type "Success"
}
#endregion
#region Mode Selection
Write-Host ""
Write-Message "Select execution mode:" -Type "Info"
Write-Message " 1 Full setup (ISO removal, user creation, NIC config, Arc registration)" -Type "Info"
Write-Message " 2 Arc only (retry Arc registration only, node already configured)" -Type "Info"
Write-Host ""
do {
$modeInput = Read-Host "Enter choice (1 or 2)"
} while ($modeInput -notin @("1","2"))
$runMode = [int]$modeInput
if ($runMode -eq 1) {
Write-Message "Mode: Full setup selected." -Type "Success"
} else {
Write-Message "Mode: Arc registration only selected." -Type "Success"
}
Write-Host ""
#endregion
#region Script Execution
$totalSteps = if ($runMode -eq 1) { 5 } else { 1 }
$currentStep = 0
# ---------------------------------------------------------------------------
# Steps 1-4: Full setup only
# ---------------------------------------------------------------------------
if ($runMode -eq 1) {
# Step 1: Remove ISO from VM
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Removing ISO from VM..."
Write-Message "Removing ISO from VM '$nodeName'..." -Type "Info"
try {
Get-VMDvdDrive -VMName $nodeName | Where-Object { $_.DvdMediaType -eq "ISO" } | Remove-VMDvdDrive -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
Write-Message "ISO removed from VM '$nodeName'." -Type "Success"
} catch {
Write-Message "Failed to remove ISO from VM '$nodeName'. Error: $_" -Type "Error"
exit 1
}
# Step 2: Create setup user and rename the node
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Creating setup user and renaming VM..."
Write-Message "Creating setup user and renaming VM '$nodeName'..." -Type "Info"
try {
Invoke-Command -VMName $nodeName -Credential $DefaultCredentials -ScriptBlock {
param($setupUser, $setupPwd, $nodeName)
$ErrorActionPreference = 'Stop'; $WarningPreference = 'SilentlyContinue'; $VerbosePreference = 'SilentlyContinue'; $ProgressPreference = 'SilentlyContinue'; $InformationPreference = 'SilentlyContinue'
Try {
New-LocalUser -Name $setupUser -Password (ConvertTo-SecureString $setupPwd -AsPlainText -Force) -FullName $setupUser -Description "Setup user" -ErrorAction Stop | Out-Null
Write-Host "User $setupUser created." -ForegroundColor Green | Out-Null
Add-LocalGroupMember -Group "Administrators" -Member $setupUser -ErrorAction Stop | Out-Null
Write-Host "User $setupUser added to Administrators." -ForegroundColor Green | Out-Null
} Catch {
Write-Host "Error occurred: $_" -ForegroundColor Red | Out-Null; throw $_
}
Rename-Computer -NewName $nodeName -Force -ErrorAction Stop | Out-Null
Restart-Computer -ErrorAction Stop -Force | Out-Null
} -ArgumentList $setupUser, $setupPwd, $nodeName -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
Write-Message "Setup user created and VM '$nodeName' is restarting..." -Type "Success"
Start-SleepWithProgress -Seconds $SleepRestart -Activity "Restarting VM" -Status "Waiting for VM to restart"
} catch {
Write-Message "Failed to create setup user or rename VM '$nodeName'. Error: $_" -Type "Error"
exit 1
}
# Step 3: Retrieve and format MAC addresses of network adapters
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Retrieving and formatting MAC addresses..."
Write-Message "Retrieving and formatting MAC addresses for VM '$nodeName'..." -Type "Info"
try {
$nodeMacNIC1 = Get-VMNetworkAdapter -VMName $nodeName -Name $NIC1 -ErrorAction Stop
$nodeMacNIC1Address = Format-MacAddress $nodeMacNIC1.MacAddress
$nodeMacNIC2 = Get-VMNetworkAdapter -VMName $nodeName -Name $NIC2 -ErrorAction Stop
$nodeMacNIC2Address = Format-MacAddress $nodeMacNIC2.MacAddress
Write-Message "MAC addresses formatted successfully." -Type "Success"
} catch {
Write-Message "Failed to retrieve or format MAC addresses. Error: $_" -Type "Error"
exit 1
}
# Step 4: Configure Network Settings
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Configuring network settings..."
Write-Message "Configuring network settings for VM '$nodeName'..." -Type "Info"
try {
Invoke-Command -VMName $nodeName -Credential $DefaultCredentials -ScriptBlock {
param($NIC1, $NIC2, $nodeMacNIC1Address, $nodeMacNIC2Address, $nic1IP, $nic1GW, $nic1DNS)
$ErrorActionPreference = 'Stop'; $WarningPreference = 'SilentlyContinue'; $VerbosePreference = 'SilentlyContinue'; $ProgressPreference = 'SilentlyContinue'; $InformationPreference = 'SilentlyContinue'
Get-NetAdapter -Physical | Where-Object { $_.MacAddress -eq $nodeMacNIC1Address } | Rename-NetAdapter -NewName $NIC1 -ErrorAction Stop | Out-Null
Get-NetAdapter -Physical | Where-Object { $_.MacAddress -eq $nodeMacNIC2Address } | Rename-NetAdapter -NewName $NIC2 -ErrorAction Stop | Out-Null
foreach ($nic in @($NIC1, $NIC2)) {
Set-NetIPInterface -InterfaceAlias $nic -Dhcp Disabled -ErrorAction Stop | Out-Null
Enable-NetAdapterRdma -Name $nic -ErrorAction Stop | Out-Null
}
New-NetIPAddress -InterfaceAlias $NIC1 -IPAddress $nic1IP -PrefixLength 24 -DefaultGateway $nic1GW -ErrorAction Stop | Out-Null
Set-DnsClientServerAddress -InterfaceAlias $NIC1 -ServerAddresses $nic1DNS -ErrorAction Stop | Out-Null
w32tm /config /manualpeerlist:$nic1DNS /syncfromflags:manual /update | Out-Null
Restart-Service w32time -Force | Out-Null
w32tm /resync | Out-Null
Set-TimeZone -Id "UTC"
Write-Host "Network settings configured." -ForegroundColor Green | Out-Null
Restart-Computer -ErrorAction Stop | Out-Null
} -ArgumentList $NIC1, $NIC2, $nodeMacNIC1Address, $nodeMacNIC2Address, $nic1IP, $nic1GW, $nic1DNS -ErrorAction Stop | Out-Null
Write-Message "VM '$nodeName' is restarting..." -Type "Success"
Start-SleepWithProgress -Seconds $SleepFeatures -Activity "Restarting VM" -Status "Waiting for VM to restart"
Write-Message "Network settings configured successfully for VM '$nodeName'." -Type "Success"
} catch {
Write-Message "Failed to configure network settings for VM '$nodeName'. Error: $_" -Type "Error"
exit 1
}
} # end if ($runMode -eq 1)
# ---------------------------------------------------------------------------
# Step 5: Azure Arc registration (runs in both modes)
# ---------------------------------------------------------------------------
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Registering node with Azure Arc..."
Write-Message "Registering VM '$nodeName' with Azure Arc..." -Type "Info"
if ($SPNAppId -ne "" -and $SPNSecret -ne "") {
Write-Message "SPN authentication will be used for Arc registration (AppId: $SPNAppId)." -Type "Info"
} else {
Write-Message "No SPN configured. The node will use an interactive device code login." -Type "Warning"
}
try {
Start-SleepWithProgress -Seconds $SleepModules -Activity "Waiting for PowerShell Modules" -Status "Preparing to register"
Invoke-Command -VMName $nodeName -Credential $DefaultCredentials -ScriptBlock {
param($Cloud, $Location, $SubscriptionID, $resourceGroupName, $TenantID, $SPNAppId, $SPNSecret, $ArcRetryCount, $SleepBootstrap)
$ErrorActionPreference = 'Stop'
$WarningPreference = 'SilentlyContinue'
$VerbosePreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
$InformationPreference = 'SilentlyContinue'
# Optional: start image customization task if present
$task = Get-ScheduledTask -TaskName ImageCustomizationScheduledTask -ErrorAction SilentlyContinue
if ($task -and $task.State -eq 'Ready') {
Start-ScheduledTask -InputObject $task
Write-Host "ImageCustomizationScheduledTask was in 'Ready' state and has been started." -ForegroundColor Cyan
} else {
Write-Host "ImageCustomizationScheduledTask not started or not present." -ForegroundColor Yellow
}
# Ensure modules are ready
Start-Sleep -Seconds 20
# Parameter hashtable for Invoke-AzStackHciArcInitialization
$arcParams = @{
SubscriptionID = $SubscriptionID
ResourceGroup = $resourceGroupName
TenantID = $TenantID
Cloud = $Cloud
Region = $Location
ErrorAction = 'Stop'
}
# If SPN credentials are supplied, obtain an ARM access token and pass
# it directly to Invoke-AzStackHciArcInitialization via -AccountId and
# -ArmAccessToken. The cmdlet does not use the current Az session context,
# so the token must be provided explicitly.
# Az module v5+ returns the token as a SecureString; older versions return plaintext.
if ($SPNAppId -ne "" -and $SPNSecret -ne "") {
$spnSecure = ConvertTo-SecureString $SPNSecret -AsPlainText -Force
$spnCred = New-Object System.Management.Automation.PSCredential($SPNAppId, $spnSecure)
Connect-AzAccount -ServicePrincipal -Credential $spnCred -TenantId $TenantID -Subscription $SubscriptionID -ErrorAction Stop | Out-Null
$rawToken = (Get-AzAccessToken -ResourceUrl "https://management.azure.com/" -ErrorAction Stop).Token
if ($rawToken -is [System.Security.SecureString]) {
$armToken = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
[System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($rawToken)
)
Write-Host "ARM access token obtained (SecureString, converted to plaintext)." -ForegroundColor Green
} else {
$armToken = $rawToken
Write-Host "ARM access token obtained (plaintext)." -ForegroundColor Green
}
$arcParams["AccountId"] = (Get-AzContext).Account.Id
$arcParams["ArmAccessToken"] = $armToken
}
# Register with retry on BootstrapOobeService transient error
$attempt = 0
$registered = $false
while (-not $registered -and $attempt -lt $ArcRetryCount) {
$attempt++
try {
Invoke-AzStackHciArcInitialization @arcParams | Out-Null
$registered = $true
Write-Host "VM '$env:COMPUTERNAME' registered with Azure Arc successfully." -ForegroundColor Green
} catch {
if ($_.Exception.Message -like "*BootstrapOobeService*" -and $attempt -lt $ArcRetryCount) {
Write-Host "BootstrapOobeService not ready (attempt $attempt of $ArcRetryCount). Waiting $SleepBootstrap seconds before retry..." -ForegroundColor Yellow
Start-Sleep -Seconds $SleepBootstrap
} else {
throw
}
}
}
} -ArgumentList $Cloud, $Location, $SubscriptionID, $resourceGroupName, $TenantID, $SPNAppId, $SPNSecret, $ArcRetryCount, $SleepBootstrap -ErrorAction Stop | Out-Null
} catch {
Write-Message "Failed to register VM '$nodeName' with Azure Arc. Error: $_" -Type "Error"
exit 1
}
# Complete the overall progress bar
Update-ProgressBar -CurrentStep $totalSteps -TotalSteps $totalSteps -StatusMessage "All tasks completed."
Write-Message "Cluster node configuration completed successfully." -Type "Success"
#endregion
03_TroubleshootingExtensions.ps1
# 03_TroubleshootingExtensions.ps1
# Troubleshooting Azure Connected Machine Extensions for ARC VMs
<#
.SYNOPSIS
Troubleshoots and manages Azure Connected Machine extensions for ARC VMs.
.DESCRIPTION
NOTE (2026-04-02): As of the current Terraform configuration, the four required Arc extensions
(AzureEdgeTelemetryAndDiagnostics, AzureEdgeDeviceManagement, AzureEdgeLifecycleManager,
AzureEdgeRemoteSupport) are now installed automatically during the first Terraform apply
(validate stage / validatedeploymentsetting resource). This script is therefore NO LONGER
required as a pre-requisite step before running `terraform apply`.
It remains useful for troubleshooting environments where extensions are in a Failed state,
stuck at a wrong version, or need to be reconciled manually outside of Terraform.
This script performs the following tasks:
- Installs the Az.Compute, Az.StackHCI and Az.ConnectedMachine modules if not already installed.
- Connects to Azure using one of three methods (see Authentication section below).
- Selects the target Subscription and Resource Group (from variables or interactively).
- Retrieves ARC VMs from Azure filtering for Arc Machines with CloudMetadataProvider "AzSHCI".
- Validates that required Azure Connected Machine extensions are installed.
- Fixes any failed extensions by removing locks, deleting and reinstalling them.
- Adds any missing extensions based on a predefined list.
- Waits until all extensions reach Succeeded state (up to 10 minutes).
Authentication:
- If $SPNAppId and $SPNSecret are set, the script authenticates via SPN using Connect-AzAccount
-ServicePrincipal. $TenantID must also be set.
- If either SPN value is empty but an existing Az session is found (Get-AzContext), that session
is reused without prompting.
- If neither condition applies, the script falls back to an interactive device code login.
Subscription and Resource Group:
- If $SubscriptionID is set, it is used directly. Otherwise the user is prompted to select.
- If $ResourceGroupName is set, it is used directly. Otherwise the user is prompted to select.
.NOTES
- Based on Jaromir´s aproach: https://github.com/DellGEOS/AzureStackHOLs/tree/main/tips%26tricks/05-FixExtensions
- Designed by Cristian Schmitt Nieto. For more information and usage, visit: https://schmitt-nieto.com/blog/azure-stack-hci-demolab/
- Run this script with administrative privileges.
- Ensure the Execution Policy allows the script to run. To set the execution policy, you can run:
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
#>
#region Variables
# Load the lab configuration into this session if it has not been loaded yet.
# Tip: run scripts\01Lab\Set-LabEnv.ps1 once per session to set everything from
# scripts\01Lab\.env. See scripts\01Lab\.env.example for the full list of keys.
if ($env:AZSHCI_ENV_LOADED -ne '1') { & "$PSScriptRoot\Set-LabEnv.ps1" }
# Azure Configuration
# Leave AZSHCI_SUBSCRIPTION_ID empty in scripts\01Lab\.env to select interactively from available subscriptions.
# Leave AZSHCI_RESOURCE_GROUP empty to select interactively from available resource groups.
$SubscriptionID = [string]$env:AZSHCI_SUBSCRIPTION_ID # empty selects interactively
$ResourceGroupName = [string]$env:AZSHCI_RESOURCE_GROUP # default lab resource group
$TenantID = [string]$env:AZSHCI_TENANT_ID # required only when authenticating via SPN
# SPN Configuration (optional)
# If both values are set, the script authenticates via SPN.
# If either is empty, an existing Az session is reused or device code login is used as fallback.
# Use the AppId and Secret generated by scripts/01Lab/00_AzurePreRequisites.ps1.
$SPNAppId = [string]$env:AZSHCI_SPN_APP_ID # Application (client) ID
$SPNSecret = [string]$env:AZSHCI_SPN_SECRET # Client secret
# Extension Settings
$Location = $env:AZSHCI_LOCATION
$Settings = @{
"CloudName" = $env:AZSHCI_CLOUD;
"RegionName" = $Location;
"DeviceType" = "AzureEdge"
}
$ExtensionList = @(
@{ Name = "AzureEdgeTelemetryAndDiagnostics"; Publisher = "Microsoft.AzureStack.Observability"; MachineExtensionType = "TelemetryAndDiagnostics"; EnableAutoUpgrade = $true; TypeHandlerVersion = "2.0.33.0" },
@{ Name = "AzureEdgeDeviceManagement"; Publisher = "Microsoft.Edge"; MachineExtensionType = "DeviceManagementExtension"; EnableAutoUpgrade = $false; TypeHandlerVersion = "1.2602.2.3116" },
@{ Name = "AzureEdgeLifecycleManager"; Publisher = "Microsoft.AzureStack.Orchestration"; MachineExtensionType = "LcmController"; EnableAutoUpgrade = $false; TypeHandlerVersion = "30.2601.0.1162"},
@{ Name = "AzureEdgeRemoteSupport"; Publisher = "Microsoft.AzureStack.Observability"; MachineExtensionType = "EdgeRemoteSupport"; EnableAutoUpgrade = $true; TypeHandlerVersion = "1.0.11.2" }
)
# Total number of steps for progress calculation
$totalSteps = 11
$currentStep = 0
#endregion
#region Functions
# Function to Display Messages with Colors
function Write-Message {
param (
[string]$Message,
[ValidateSet("Info", "Success", "Warning", "Error")]
[string]$Type = "Info"
)
switch ($Type) {
"Info" { Write-Host $Message -ForegroundColor Cyan }
"Success" { Write-Host $Message -ForegroundColor Green }
"Warning" { Write-Host $Message -ForegroundColor Yellow }
"Error" { Write-Host $Message -ForegroundColor Red }
}
# Optional: Log messages to a file
# Uncomment and configure the following lines to enable logging
# $LogFile = "C:\Path\To\Your\LogFile.txt"
# Add-Content -Path $LogFile -Value "$((Get-Date).ToString('yyyy-MM-dd HH:mm:ss')) [$Type] $Message"
}
# Function to Update Progress Bar (Main Progress)
function Update-ProgressBar {
param (
[int]$CurrentStep,
[int]$TotalSteps,
[string]$StatusMessage
)
$percent = [math]::Round(($CurrentStep / $TotalSteps) * 100)
Write-Progress -Id 1 -Activity "Overall Progress" -Status $StatusMessage -PercentComplete $percent
}
# Function to Install Az.Compute Module if Not Installed
function Install-AzComputeModule {
if (-not (Get-Module -ListAvailable -Name Az.Compute)) {
Write-Message "Az.Compute module not found. Installing..." -Type "Info"
try {
Install-Module -Name Az.Compute -Repository PSGallery -Force -AllowClobber -ErrorAction Stop
Write-Message "Az.Compute module installed successfully." -Type "Success"
} catch {
Write-Message "Failed to install Az.Compute module. Error: $_" -Type "Error"
exit 1
}
} else {
Write-Message "Az.Compute module is already installed." -Type "Info"
}
}
# Function to Install Az.StackHCI Module if Not Installed
function Install-AzStackHCIModule {
if (-not (Get-Module -ListAvailable -Name Az.StackHCI)) {
Write-Message "Az.StackHCI module not found. Installing..." -Type "Info"
try {
Install-Module -Name Az.StackHCI -Repository PSGallery -Force -AllowClobber -ErrorAction Stop
Write-Message "Az.StackHCI module installed successfully." -Type "Success"
} catch {
Write-Message "Failed to install Az.StackHCI module. Error: $_" -Type "Error"
exit 1
}
} else {
Write-Message "Az.StackHCI module is already installed." -Type "Info"
}
}
function Install-AzConnectedMachineModule {
if (-not (Get-Module -ListAvailable -Name Az.ConnectedMachine)) {
Write-Message "Az.ConnectedMachine module not found. Installing..." -Type "Info"
try {
Install-Module -Name Az.ConnectedMachine -Repository PSGallery -Force -AllowClobber -ErrorAction Stop
Write-Message "Az.ConnectedMachine module installed successfully." -Type "Success"
} catch {
Write-Message "Failed to install Az.ConnectedMachine module. Error: $_" -Type "Error"
exit 1
}
} else {
Write-Message "Az.ConnectedMachine module is already installed." -Type "Info"
}
}
# Function to Allow Selection from a List
function Get-Option {
param (
[string]$cmd,
[string]$filterproperty
)
$items = @("")
$selection = $null
$filteredItems = @()
$i = 0
try {
$cmdOutput = Invoke-Expression -Command $cmd | Sort-Object $filterproperty
foreach ($item in $cmdOutput) {
$items += "{0}. {1}" -f $i, $item.$filterproperty
$i++
}
} catch {
Write-Message "Failed to execute command '$cmd'. Error: $_" -Type "Error"
exit 1
}
$filteredItems += $items | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
$filteredItems | Format-Wide { $_ } -Column 4 -Force | Out-Host
do {
$r = Read-Host "Select by number"
if ($r -match '^\d+$' -and $r -lt $filteredItems.Count) {
$selection = $filteredItems[$r] -split "\.\s" | Select-Object -Last 1
Write-Host "Selecting $($filteredItems[$r])" -ForegroundColor Green
} else {
Write-Host "You must make a valid selection" -ForegroundColor Red
$selection = $null
}
} until ($null -ne $selection)
return $selection
}
# Function to Retrieve ARC VMs from Azure using Az.StackHCI
function Get-ARCVMsFromAzure {
param(
[string]$SubscriptionId,
[string]$ResourceGroupName
)
try {
# Retrieve all connected machines in the resource group
$connectedMachines = Get-AzConnectedMachine -ResourceGroupName $ResourceGroupName -ErrorAction Stop
if ($connectedMachines.Count -eq 0) {
Write-Message "No connected machines found in resource group '$ResourceGroupName'." -Type "Warning"
return @()
}
# Filter connected machines where CloudMetadataProvider is "AzSHCI"
$ARCVMs = $connectedMachines | Where-Object { $_.CloudMetadataProvider -eq "AzSHCI" }
if ($ARCVMs.Count -eq 0) {
Write-Message "No ARC VMs (Machines with CloudMetadataProvider 'AzSHCI') found in resource group '$ResourceGroupName'." -Type "Warning"
} else {
Write-Message "Retrieved $($ARCVMs.Count) ARC VM(s) from Azure." -Type "Success"
}
return $ARCVMs
} catch {
Write-Message "Failed to retrieve ARC VMs from Azure. Error: $_" -Type "Error"
exit 1
}
}
# Function to Validate Installed Extensions
function Test-Extensions {
param(
[array]$ARCVMs,
[string]$ResourceGroupName
)
$Extensions = @()
foreach ($ARCVM in $ARCVMs) {
try {
$extensions = Get-AzConnectedMachineExtension -ResourceGroupName $ResourceGroupName -MachineName $ARCVM.Name -ErrorAction Stop
$Extensions += $extensions
} catch {
Write-Message "Failed to retrieve extensions for ARC VM '$($ARCVM.Name)'. Error: $_" -Type "Error"
exit 1
}
}
return $Extensions
}
#endregion
#region Script Execution
# Step 1: Install Az.Compute Module
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Ensuring Az.Compute module is installed..."
Write-Message "Checking for Az.Compute module..." -Type "Info"
try {
Install-AzComputeModule
} catch {
Write-Message "An error occurred while ensuring Az.Compute module is installed. Error: $_" -Type "Error"
exit 1
}
# Step 2: Install Az.StackHCI Module & Az.ConnectedMachine Module
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Ensuring Az.StackHCI module is installed..."
Write-Message "Checking for Az.StackHCI module..." -Type "Info"
try {
Install-AzStackHCIModule
} catch {
Write-Message "An error occurred while ensuring Az.StackHCI module is installed. Error: $_" -Type "Error"
exit 1
}
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Ensuring Az.ConnectedMachine module is installed..."
Write-Message "Checking for Az.ConnectedMachine module..." -Type "Info"
try {
Install-AzConnectedMachineModule
} catch {
Write-Message "An error occurred while ensuring Az.ConnectedMachine module is installed. Error: $_" -Type "Error"
exit 1
}
# Step 3: Connect to Azure
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Connecting to Azure..."
Write-Message "Connecting to Azure..." -Type "Info"
try {
if ($SPNAppId -ne "" -and $SPNSecret -ne "") {
Write-Message "SPN credentials provided. Authenticating via Service Principal (AppId: $SPNAppId)." -Type "Info"
$spnSecure = ConvertTo-SecureString $SPNSecret -AsPlainText -Force
$spnCred = New-Object System.Management.Automation.PSCredential($SPNAppId, $spnSecure)
$connectParams = @{
ServicePrincipal = $true
Credential = $spnCred
TenantId = $TenantID
ErrorAction = "Stop"
}
if ($SubscriptionID -ne "") { $connectParams["Subscription"] = $SubscriptionID }
Connect-AzAccount @connectParams | Out-Null
Write-Message "Connected to Azure via Service Principal." -Type "Success"
} else {
$existingContext = Get-AzContext -ErrorAction SilentlyContinue
if ($null -ne $existingContext -and $null -ne $existingContext.Account) {
Write-Message "Existing Azure session found (Account: $($existingContext.Account.Id))." -Type "Info"
Write-Host ""
Write-Message " 1 Continue with existing session" -Type "Info"
Write-Message " 2 Start a new device code login" -Type "Info"
Write-Host ""
do {
$sessionChoice = Read-Host "Enter choice (1 or 2)"
} while ($sessionChoice -notin @("1", "2"))
if ($sessionChoice -eq "2") {
Write-Message "Starting device code login..." -Type "Info"
Connect-AzAccount -UseDeviceAuthentication -ErrorAction Stop | Out-Null
Write-Message "Connected to Azure via device code." -Type "Success"
} else {
Write-Message "Reusing existing session." -Type "Success"
}
} else {
Write-Message "No SPN configured and no existing session found. Starting device code login..." -Type "Warning"
Connect-AzAccount -UseDeviceAuthentication -ErrorAction Stop | Out-Null
Write-Message "Connected to Azure via device code." -Type "Success"
}
}
} catch {
Write-Message "Failed to connect to Azure. Error: $_" -Type "Error"
exit 1
}
# Step 4: Select Subscription
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Selecting Azure Subscription..."
try {
if ($SubscriptionID -ne "") {
Write-Message "Using configured SubscriptionID: $SubscriptionID" -Type "Info"
Set-AzContext -SubscriptionId $SubscriptionID -ErrorAction Stop | Out-Null
} else {
Write-Message "No SubscriptionID configured. Retrieving available subscriptions..." -Type "Info"
$selectedSubName = Get-Option "Get-AzSubscription" "Name"
Set-AzContext -SubscriptionName $selectedSubName -ErrorAction Stop | Out-Null
}
$selectedSubscription = Get-AzContext -ErrorAction Stop
$SubscriptionID = $selectedSubscription.Subscription.Id
Write-Message "Active subscription: $($selectedSubscription.Subscription.Name) ($SubscriptionID)" -Type "Success"
} catch {
Write-Message "Failed to set Azure Subscription. Error: $_" -Type "Error"
exit 1
}
# Step 5: Select Resource Group
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Selecting Resource Group..."
try {
if ($ResourceGroupName -ne "") {
Write-Message "Using configured ResourceGroupName: $ResourceGroupName" -Type "Info"
$rgCheck = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue
if ($null -eq $rgCheck) {
Write-Message "Resource group '$ResourceGroupName' not found in subscription '$($selectedSubscription.Subscription.Name)'." -Type "Error"
exit 1
}
Write-Message "Resource group '$ResourceGroupName' confirmed." -Type "Success"
} else {
Write-Message "No ResourceGroupName configured. Retrieving available resource groups..." -Type "Info"
$ResourceGroupName = Get-Option "Get-AzResourceGroup" "ResourceGroupName"
Write-Message "Selected Resource Group: $ResourceGroupName" -Type "Success"
}
} catch {
Write-Message "Failed to select Resource Group. Error: $_" -Type "Error"
exit 1
}
# Step 6: Retrieve ARC VMs from Azure
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Retrieving ARC VMs from Azure..."
Write-Message "Retrieving ARC VMs from Azure..." -Type "Info"
try {
$ARCVMs = Get-ARCVMsFromAzure -SubscriptionId $SubscriptionId -ResourceGroupName $ResourceGroupName
if ($ARCVMs.Count -eq 0) {
Write-Message "No ARC VMs found. Exiting script." -Type "Error"
exit 1
}
} catch {
Write-Message "An error occurred while retrieving ARC VMs. Error: $_" -Type "Error"
exit 1
}
# Step 7: Select ARC VM
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Selecting ARC VM..."
Write-Message "Selecting an ARC VM..." -Type "Info"
try {
# Prepare the list for selection
# $arcVmNames = $ARCVMs | Select-Object -ExpandProperty Name
# Construct the command string for Get-Option
# Create a temporary array to hold objects with Name property
$tempList = @()
foreach ($arcVm in $ARCVMs) {
$tempList += [PSCustomObject]@{ Name = $arcVm.Name }
}
# Export the temporary list to a temporary file
$tempList | Export-Clixml -Path "$env:TEMP\ARCVMList.xml"
# Use Get-Option by importing the temporary list
function Get-Option-FromList {
param (
[string]$filterproperty
)
$items = @("")
$selection = $null
$filteredItems = @()
$i = 0
$cmdOutput = Import-Clixml -Path "$env:TEMP\ARCVMList.xml" | Sort-Object $filterproperty
foreach ($item in $cmdOutput) {
$items += "{0}. {1}" -f $i, $item.$filterproperty
$i++
}
$filteredItems += $items | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
$filteredItems | Format-Wide { $_ } -Column 4 -Force | Out-Host
do {
$r = Read-Host "Select by number"
if ($r -match '^\d+$' -and $r -lt $filteredItems.Count) {
$selection = $filteredItems[$r] -split "\.\s" | Select-Object -Last 1
Write-Host "Selecting $($filteredItems[$r])" -ForegroundColor Green
} else {
Write-Host "You must make a valid selection" -ForegroundColor Red
$selection = $null
}
} until ($null -ne $selection)
return $selection
}
# Allow user to select an ARC VM
$selectedARCVMName = Get-Option-FromList "Name"
# Find the selected ARC VM object
$selectedARCVM = $ARCVMs | Where-Object { $_.Name -eq $selectedARCVMName }
if (-not $selectedARCVM) {
Write-Message "No ARC VM selected. Exiting script." -Type "Error"
exit 1
} else {
Write-Message "Selected ARC VM: $($selectedARCVM.Name)" -Type "Success"
}
# Remove temporary XML file
Remove-Item -Path "$env:TEMP\ARCVMList.xml" -Force -ErrorAction SilentlyContinue
} catch {
Write-Message "An error occurred while selecting ARC VM. Error: $_" -Type "Error"
exit 1
}
# Step 8: Validate Installed Extensions
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Validating installed extensions..."
Write-Message "Validating installed Azure Connected Machine extensions on ARC VM '$($selectedARCVM.Name)'..." -Type "Info"
try {
$Extensions = Test-Extensions -ARCVMs @($selectedARCVM) -ResourceGroupName $ResourceGroupName
Write-Message "Extension validation completed." -Type "Success"
} catch {
Write-Message "An error occurred during extension validation. Error: $_" -Type "Error"
exit 1
}
# Step 9: Reconcile extensions — install missing, reinstall failed or wrong-version
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Reconciling extensions..."
Write-Message "Reconciling extensions on ARC VM '$($selectedARCVM.Name)'..." -Type "Info"
try {
foreach ($Extension in $ExtensionList) {
$installed = $Extensions | Where-Object { $_.Name -eq $Extension.Name -and $_.MachineName -eq $selectedARCVM.Name } | Select-Object -First 1
if (-not $installed) {
# ----------------------------------------------------------------
# Case 1: extension is missing — install it
# ----------------------------------------------------------------
Write-Message "[$($Extension.Name)] Not installed. Installing version $($Extension.TypeHandlerVersion)..." -Type "Info"
$installParams = @{
Name = $Extension.Name
ResourceGroupName = $ResourceGroupName
MachineName = $selectedARCVM.Name
Location = $Location
Publisher = $Extension.Publisher
Settings = $Settings
ExtensionType = $Extension.MachineExtensionType
EnableAutomaticUpgrade = $Extension.EnableAutoUpgrade
TypeHandlerVersion = $Extension.TypeHandlerVersion
ErrorAction = "Stop"
}
New-AzConnectedMachineExtension @installParams | Out-Null
Write-Message "[$($Extension.Name)] Installed successfully." -Type "Success"
} elseif ($installed.ProvisioningState -eq "Failed") {
# ----------------------------------------------------------------
# Case 2: extension is in Failed state — remove (with lock check) and reinstall
# ----------------------------------------------------------------
$Server = $installed.MachineName
Write-Message "[$($Extension.Name)] State is Failed. Removing and reinstalling version $($Extension.TypeHandlerVersion)..." -Type "Warning"
$Locks = Get-AzResourceLock -ResourceGroupName $ResourceGroupName |
Where-Object ResourceID -like "*HybridCompute/machines/$Server*"
foreach ($lock in $Locks) {
Write-Message "[$($Extension.Name)] Removing lock '$($lock.Name)'..." -Type "Info"
Remove-AzResourceLock -LockId $lock.LockId -Force -ErrorAction Stop
}
Remove-AzConnectedMachineExtension -Name $installed.Name -ResourceGroupName $ResourceGroupName -MachineName $Server -ErrorAction Stop
Write-Message "[$($Extension.Name)] Removed." -Type "Info"
$reinstallParams = @{
Name = $Extension.Name
ResourceGroupName = $ResourceGroupName
MachineName = $Server
Location = $installed.Location
Publisher = $Extension.Publisher
Settings = $Settings
ExtensionType = $Extension.MachineExtensionType
EnableAutomaticUpgrade = $Extension.EnableAutoUpgrade
TypeHandlerVersion = $Extension.TypeHandlerVersion
ErrorAction = "Stop"
}
New-AzConnectedMachineExtension @reinstallParams | Out-Null
Write-Message "[$($Extension.Name)] Reinstalled at version $($Extension.TypeHandlerVersion)." -Type "Success"
} elseif ($installed.TypeHandlerVersion -ne $Extension.TypeHandlerVersion) {
# ----------------------------------------------------------------
# Case 3: extension is installed but version does not match — remove and reinstall
# ----------------------------------------------------------------
$Server = $installed.MachineName
Write-Message "[$($Extension.Name)] Version mismatch (installed: $($installed.TypeHandlerVersion), expected: $($Extension.TypeHandlerVersion)). Reinstalling..." -Type "Warning"
Remove-AzConnectedMachineExtension -Name $installed.Name -ResourceGroupName $ResourceGroupName -MachineName $Server -ErrorAction Stop
Write-Message "[$($Extension.Name)] Removed version $($installed.TypeHandlerVersion)." -Type "Info"
$reinstallParams = @{
Name = $Extension.Name
ResourceGroupName = $ResourceGroupName
MachineName = $Server
Location = $installed.Location
Publisher = $Extension.Publisher
Settings = $Settings
ExtensionType = $Extension.MachineExtensionType
EnableAutomaticUpgrade = $Extension.EnableAutoUpgrade
TypeHandlerVersion = $Extension.TypeHandlerVersion
ErrorAction = "Stop"
}
New-AzConnectedMachineExtension @reinstallParams | Out-Null
Write-Message "[$($Extension.Name)] Reinstalled at version $($Extension.TypeHandlerVersion)." -Type "Success"
} else {
# ----------------------------------------------------------------
# Case 4: extension is installed at the correct version — nothing to do
# ----------------------------------------------------------------
Write-Message "[$($Extension.Name)] OK — version $($installed.TypeHandlerVersion), state $($installed.ProvisioningState)." -Type "Success"
}
}
} catch {
Write-Message "An error occurred while reconciling extensions. Error: $_" -Type "Error"
exit 1
}
# Step 10: Wait for all extensions to reach Succeeded state
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Waiting for all extensions to reach Succeeded state..."
Write-Message "Waiting for extensions to finish provisioning on '$($selectedARCVM.Name)'..." -Type "Info"
try {
$maxWaitSeconds = 600
$pollInterval = 20
$elapsed = 0
do {
Start-Sleep -Seconds $pollInterval
$elapsed += $pollInterval
$current = Get-AzConnectedMachineExtension -ResourceGroupName $ResourceGroupName -MachineName $selectedARCVM.Name -ErrorAction Stop
$pending = @($current | Where-Object { $_.ProvisioningState -notin @("Succeeded", "Failed") })
$failed = @($current | Where-Object { $_.ProvisioningState -eq "Failed" })
Write-Message " [$elapsed/$maxWaitSeconds s] Provisioning: $($pending.Count) pending, $($failed.Count) failed." -Type "Info"
} while ($pending.Count -gt 0 -and $elapsed -lt $maxWaitSeconds)
if ($failed.Count -gt 0) {
foreach ($f in $failed) {
Write-Message "Extension '$($f.Name)' is in Failed state after provisioning." -Type "Error"
}
} else {
Write-Message "All extensions reached Succeeded state." -Type "Success"
}
} catch {
Write-Message "Could not verify extension provisioning state. Error: $_" -Type "Error"
}
# Step 11: Apply LcmController NuGet hotfix via Arc Run Command
# Bug in Microsoft.AzureStack.Role.Deployment.Service 10.2601.x: GetTargetBuildManifest checks
# [string]::IsNullOrEmpty($assemblyPayload) but $assemblyPayload is a non-null PSCustomObject
# {CloudName, DeviceType, RegionName} when Azure pushes minimal publicSettings. The IsNullOrEmpty
# check returns $false and the cloud manifest download fallback never activates, leaving all four
# package download URLs empty. Fix: also check $assemblyPayload.AssemblyDeployPackage is non-empty.
$currentStep++
Update-ProgressBar -CurrentStep $currentStep -TotalSteps $totalSteps -StatusMessage "Applying LcmController NuGet hotfix via Arc Run Command..."
Write-Message "Applying LcmController NuGet hotfix on '$($selectedARCVM.Name)'..." -Type "Info"
try {
$patchScript = @'
$pkgPath = "C:\NugetStore\Microsoft.AzureStack.Role.Deployment.Service.10.2601.0.1162\content\Classes\DeploymentService\Helpers\DownloadHelpers.psm1"
if (-not (Test-Path $pkgPath)) {
Write-Output "NuGet package 10.2601.0.1162 not found at expected path - skipping patch"
exit 0
}
$content = Get-Content $pkgPath -Raw
$old = 'if ([string]::IsNullOrEmpty($assemblyPayload)) {'
$new = 'if ([string]::IsNullOrEmpty($assemblyPayload) -or [string]::IsNullOrEmpty($assemblyPayload.AssemblyDeployPackage)) {'
if ($content -match [regex]::Escape($new)) {
Write-Output "Already patched - no action needed"
exit 0
}
if ($content -notmatch [regex]::Escape($old)) {
Write-Output "Target line not found in file - NuGet version may differ or file was already modified"
exit 0
}
$patched = $content -replace [regex]::Escape($old), $new
Set-Content $pkgPath -Value $patched -NoNewline
Restart-Service LcmController -Force -ErrorAction SilentlyContinue
Write-Output "Patch applied and LcmController service restarted"
'@
$runCommandBody = @{
location = $Location
properties = @{
source = @{ script = $patchScript }
asyncExecution = $false
timeoutInSeconds = 120
}
} | ConvertTo-Json -Depth 5
$machineId = "/subscriptions/$SubscriptionID/resourceGroups/$ResourceGroupName/providers/Microsoft.HybridCompute/machines/$($selectedARCVM.Name)"
$uri = "https://management.azure.com$machineId/runCommands/PatchLcmNuget?api-version=2024-07-31-preview"
$response = Invoke-AzRestMethod -Method PUT -Uri $uri -Payload $runCommandBody -ErrorAction Stop
if ($response.StatusCode -in 200, 201) {
$result = $response.Content | ConvertFrom-Json
$output = $result.properties.instanceView.output
$err = $result.properties.instanceView.error
if ($output) { Write-Message "[$($selectedARCVM.Name)] $output" -Type "Success" }
if ($err) { Write-Message "[$($selectedARCVM.Name)] Run command error: $err" -Type "Warning" }
} else {
Write-Message "Arc Run Command returned HTTP $($response.StatusCode) — patch may need to be applied manually." -Type "Warning"
Write-Message $response.Content -Type "Warning"
}
} catch {
Write-Message "Failed to apply LcmController NuGet hotfix via Arc Run Command. Error: $_" -Type "Warning"
}
# Complete the overall progress bar
Update-ProgressBar -CurrentStep $totalSteps -TotalSteps $totalSteps -StatusMessage "All tasks completed."
# Final success message
Write-Message "Azure Connected Machine extensions troubleshooting completed successfully." -Type "Success"
#endregion
99_Offboarding.ps1
# Offboarding Script to Clean Up Configurations
<#
.SYNOPSIS
Cleans up VM configurations, virtual switches, NAT settings, and associated folders.
.DESCRIPTION
This script performs the following tasks:
- Stops and removes specified VMs.
- Deletes associated VHD files.
- Removes HgsGuardian entries.
- Deletes virtual switches and NAT configurations.
- Removes designated folder structures.
.NOTES
- Designed by Cristian Schmitt Nieto. For more information and usage, visit: https://schmitt-nieto.com/blog/azure-stack-hci-demolab/
- Run this script with administrative privileges.
- Ensure the Execution Policy allows the script to run. To set the execution policy, you can run:
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
#>
#region Variables
# Load the lab configuration into this session if it has not been loaded yet.
# These values must match the ones used during deployment so teardown targets
# the right objects. Run scripts\01Lab\Set-LabEnv.ps1 once per session to set
# everything from scripts\01Lab\.env.
if ($env:AZSHCI_ENV_LOADED -ne '1') { & "$PSScriptRoot\Set-LabEnv.ps1" }
# Define VM Names
$HCIVMName = $env:AZSHCI_HCI_VM_NAME
$DCVMName = $env:AZSHCI_DC_VM_NAME
# Define Virtual Switch and NAT Configuration
$vSwitchName = $env:AZSHCI_VSWITCH_NAME
$vSwitchNIC = "vEthernet ($vSwitchName)"
$natName = $env:AZSHCI_NAT_NAME
$vNetIPNetwork = $env:AZSHCI_LAB_SUBNET
# Define Root Folder for VMs and Disks
$HCIRootFolder = $env:AZSHCI_LAB_ROOT_FOLDER
$HCIDiskFolder = Join-Path -Path $HCIRootFolder -ChildPath "Disk"
# Define Tasks for Progress Bar
$tasks = @(
"Stopping and Removing HCI Node VM",
"Stopping and Removing Domain Controller VM",
"Removing NAT Configuration",
"Removing IP Addresses from Virtual Switch Interface",
"Removing Virtual Switch",
"Removing Firewall Rules",
"Removing Folder Structures"
)
$totalTasks = $tasks.Count
$currentTask = 0
$skipNetworking = $false
#endregion
#region Functions
# Function to Display Messages with Colors
function Write-Message {
param (
[string]$Message,
[ValidateSet("Info", "Success", "Warning", "Error")]
[string]$Type = "Info"
)
switch ($Type) {
"Info" { Write-Host $Message -ForegroundColor Cyan }
"Success" { Write-Host $Message -ForegroundColor Green }
"Warning" { Write-Host $Message -ForegroundColor Yellow }
"Error" { Write-Host $Message -ForegroundColor Red }
}
}
# Function to Remove VMs and Associated Resources
function Remove-VMResources {
param (
[string]$VMName,
[string]$DiskFolder
)
# Suppress non-critical outputs within the function
$ErrorActionPreference = 'Stop'
$WarningPreference = 'SilentlyContinue'
$VerbosePreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
# Stop and remove the VM if it exists
$vm = Get-VM -Name $VMName -ErrorAction SilentlyContinue
if ($null -ne $vm) {
if ($vm.State -in @('Running', 'Paused', 'Suspended')) {
try {
Stop-VM -Name $VMName -TurnOff -ErrorAction Stop | Out-Null
Write-Message "VM '$VMName' stopped." -Type "Success"
} catch {
Write-Message "Failed to stop VM '$VMName'. Error: $_" -Type "Error"
return
}
}
# Remove the VM
try {
Remove-VM -Name $VMName -Force -ErrorAction Stop | Out-Null
Write-Message "VM '$VMName' removed." -Type "Success"
} catch {
Write-Message "Failed to remove VM '$VMName'. Error: $_" -Type "Error"
return
}
# Remove VHD files
$vhdFiles = Get-ChildItem -Path $DiskFolder -Filter "$VMName*.vhdx" -Recurse -ErrorAction SilentlyContinue
foreach ($vhd in $vhdFiles) {
try {
Remove-Item -Path $vhd.FullName -Force -ErrorAction Stop | Out-Null
Write-Message "VHD file '$($vhd.FullName)' deleted." -Type "Success"
} catch {
Write-Message "Failed to delete VHD file '$($vhd.FullName)'. Error: $_" -Type "Error"
}
}
# Remove HgsGuardian if it exists
try {
Remove-HgsGuardian -Name $VMName -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null
Write-Message "HgsGuardian '$VMName' removed." -Type "Success"
} catch {
Write-Message "Failed to remove HgsGuardian '$VMName'. Error: $_" -Type "Error"
}
} else {
Write-Message "VM '$VMName' does not exist. Skipping removal." -Type "Warning"
}
# Reset preferences to default
$ErrorActionPreference = 'Continue'
$WarningPreference = 'Continue'
$VerbosePreference = 'Continue'
$ProgressPreference = 'Continue'
}
#endregion
#region Script Execution
foreach ($task in $tasks) {
$currentTask++
Write-Progress -Activity "Cleaning Up Configurations" -Status "$task..." -PercentComplete (($currentTask / $totalTasks) * 100)
switch ($task) {
"Stopping and Removing HCI Node VM" {
Remove-VMResources -VMName $HCIVMName -DiskFolder $HCIDiskFolder
}
"Stopping and Removing Domain Controller VM" {
Remove-VMResources -VMName $DCVMName -DiskFolder $HCIDiskFolder
}
"Removing NAT Configuration" {
# Check if the switch is still attached to any VM before touching networking
$attachedVMs = @(
Get-VM -ErrorAction SilentlyContinue | Where-Object {
(Get-VMNetworkAdapter -VMName $_.Name -ErrorAction SilentlyContinue).SwitchName -contains $vSwitchName
}
)
if ($attachedVMs.Count -gt 0) {
$vmNames = ($attachedVMs | Select-Object -ExpandProperty Name) -join ', '
Write-Message "Virtual switch '$vSwitchName' is still in use by: $vmNames." -Type "Warning"
Write-Message "NAT, IP addresses and virtual switch will not be removed. Stop or remove those VMs first." -Type "Warning"
$skipNetworking = $true
} else {
$skipNetworking = $false
Write-Message "Checking NAT configuration '$natName'..." -Type "Info"
$nat = Get-NetNat -Name $natName -ErrorAction SilentlyContinue
if ($null -eq $nat) {
Write-Message "NAT '$natName' does not exist. Skipping removal." -Type "Warning"
} else {
try {
Remove-NetNat -Name $natName -Confirm:$false -ErrorAction Stop | Out-Null
Write-Message "NAT '$natName' removed." -Type "Success"
} catch {
Write-Message "Failed to remove NAT '$natName'. Error: $_" -Type "Error"
}
}
}
}
"Removing IP Addresses from Virtual Switch Interface" {
if ($skipNetworking) {
Write-Message "Skipping IP address removal (switch still in use)." -Type "Warning"
} else {
Write-Message "Removing IP addresses from interface '$vSwitchNIC'..." -Type "Info"
try {
$ipAddresses = Get-NetIPAddress -InterfaceAlias $vSwitchNIC -ErrorAction Stop
foreach ($ip in $ipAddresses) {
try {
Remove-NetIPAddress -InterfaceAlias $vSwitchNIC -IPAddress $ip.IPAddress -Confirm:$false -ErrorAction Stop | Out-Null
Write-Message "IP address '$($ip.IPAddress)' removed from interface '$vSwitchNIC'." -Type "Success"
} catch {
Write-Message "Failed to remove IP address '$($ip.IPAddress)' from interface '$vSwitchNIC'. Error: $_" -Type "Error"
}
}
} catch [System.Management.Automation.ItemNotFoundException] {
Write-Message "No IP addresses found on interface '$vSwitchNIC'. Skipping removal." -Type "Warning"
} catch {
Write-Message "Failed to retrieve IP addresses from interface '$vSwitchNIC'. Error: $_" -Type "Error"
}
}
}
"Removing Virtual Switch" {
if ($skipNetworking) {
Write-Message "Skipping virtual switch removal (switch still in use)." -Type "Warning"
} else {
Write-Message "Checking virtual switch '$vSwitchName'..." -Type "Info"
$switch = Get-VMSwitch -Name $vSwitchName -ErrorAction SilentlyContinue
if ($null -eq $switch) {
Write-Message "Virtual switch '$vSwitchName' does not exist. Skipping removal." -Type "Warning"
} else {
try {
Remove-VMSwitch -Name $vSwitchName -Force -ErrorAction Stop | Out-Null
Write-Message "Virtual switch '$vSwitchName' removed." -Type "Success"
} catch {
Write-Message "Failed to remove virtual switch '$vSwitchName'. Error: $_" -Type "Error"
}
}
}
}
"Removing Firewall Rules" {
Write-Message "Removing ICMP firewall rules created by the lab setup..." -Type "Info"
try {
$ip, $cidr = $vNetIPNetwork -split '/'
$bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes()
$bytes[3] += 1
$gatewayIP = [System.Net.IPAddress]::new($bytes).ToString()
# Current rule name (without interface alias)
$ruleName = "Allow-ICMPv4-$gatewayIP"
# Legacy rule name (with interface alias, created before the fix)
$legacyRuleName = "Allow-ICMPv4-$vSwitchNIC-$gatewayIP"
foreach ($name in @($ruleName, $legacyRuleName)) {
$rule = Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue
if ($null -ne $rule) {
Remove-NetFirewallRule -DisplayName $name -ErrorAction Stop | Out-Null
Write-Message "Firewall rule '$name' removed." -Type "Success"
} else {
Write-Message "Firewall rule '$name' does not exist. Skipping." -Type "Warning"
}
}
} catch {
Write-Message "Failed to remove firewall rules. Error: $_" -Type "Error"
}
}
"Removing Folder Structures" {
Write-Message "Removing folder structures at '$HCIRootFolder'..." -Type "Info"
if (Test-Path -Path $HCIRootFolder) {
try {
Remove-Item -Path $HCIRootFolder -Recurse -Force -ErrorAction Stop | Out-Null
Write-Message "Folder '$HCIRootFolder' and all its contents have been deleted." -Type "Success"
} catch {
Write-Message "Failed to delete folder '$HCIRootFolder'. Error: $_" -Type "Error"
}
} else {
Write-Message "Folder '$HCIRootFolder' does not exist. Skipping removal." -Type "Warning"
}
}
}
}
# Complete the Progress Bar
Write-Progress -Activity "Cleaning Up Configurations" -Completed -Status "All tasks completed."
Write-Message "Cleanup completed successfully." -Type "Success"
#endregion
Comments