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_DC for the local infrastructure
  • AZSHCI_SUBSCRIPTION_ID, AZSHCI_TENANT_ID, AZSHCI_RESOURCE_GROUP, AZSHCI_LOCATION for the Arc registration
  • AZSHCI_DEFAULT_ADMIN_PASSWORD, AZSHCI_DC_LCM_PASSWORD, AZSHCI_NODE_SETUP_PASSWORD for 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 .env file for non-interactive Arc registration.

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, gateway 172.19.18.1).
    • Adds an inbound ICMPv4 firewall rule so VMs can ping the NAT gateway.
    • Creates the lab folder structure under E:\AzureLocalLab (subfolders VM\ and Disk\).
    • Automates VM creation with vTPM, nested virtualization, MAC spoofing, and boot-from-ISO configuration.
    • Default ISO paths: E:\ISO\AzureLocal24H2.iso (HCI node) and E:\ISO\WS2025.iso (DC). Adjust AZSHCI_ISO_PATH_HCI, AZSHCI_ISO_PATH_DC and AZSHCI_LAB_ROOT_FOLDER in scripts/01Lab/.env if your paths differ.

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-HciAdObjectsPreCreation to 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 to AZLN01.
    • 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 transient BootstrapOobeService connection errors.
    • Optional SPN authentication: if AZSHCI_SPN_APP_ID and AZSHCI_SPN_SECRET are set in scripts/01Lab/.env (output from 00_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.Compute and Az.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 CloudMetadataProvider set to AzSHCI.
    • 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.psm1 patch through Arc Run Command. The current script pins extension versions and a 10.2601.0.1162 package path. Review those targets against the installed release before using it; this is not a general repair for every Azure Local version.

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 AZLN01 and DC VMs and their associated VHD files.
    • Removes the HgsGuardian entries for both VMs.
    • Removes the NAT object and virtual switch (azurelocal).
    • Deletes the entire E:\AzureLocalLab folder tree.

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-lab is 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 DC VM.
  • 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 AZLN01 VM.
  • 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. Extension

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:

  1. 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 Administrator for the user performing the deployment.
  2. Initial Cluster Registration:

    • Use the Azure Portal for initial cluster registration: Azure Local Deployment via Portal

    • Extension Installation
      • Since April 2025, extensions must be installed manually during the cluster deployment. To do this, simply select the nodes and click the Install extensions button: Extension Installation
    • Network Configuration:
      • Apply the following network settings to the interfaces: Network Configuration

      • Personally, I use the following IP configurations: IP Configuration

    • Custom Location and User Configuration:
      • Use the domain, OU and LCM user prepared by 01_DC.ps1, with the credentials from your private .env file: AD Configuration
    • Security Options:
      • It’s crucial to disable BitLocker to prevent excessive storage consumption (totaling 2.1 TB), which could render your system inoperable if you lack sufficient capacity: 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.
  3. 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

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