Public/New-AzureArcDevice.ps1
function New-AzureArcDevice { [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] <# .SYNOPSIS Creates and configures Azure Arc-enabled devices with Group Policy deployment. .DESCRIPTION This function automates the process of setting up Azure Arc for device onboarding using Group Policy. It creates the necessary Azure resources, downloads required components, configures service principals, and deploys Group Policy objects for automated Azure Arc agent installation across multiple devices. The function performs the following operations: - Validates Azure authentication and prerequisites - Creates Azure resource groups and configures deployment parameters - Sets up remote file shares for Group Policy deployment - Downloads Azure Connected Machine Agent and Group Policy templates - Creates service principals with appropriate permissions - Deploys and links Group Policy objects to specified organizational units .PARAMETER SubscriptionId Optional Azure subscription ID to use. If not provided, user will be prompted to select. .PARAMETER ResourceGroupName Optional name for the Azure resource group. If not provided, user will be prompted. .PARAMETER Location Optional Azure region for resource deployment. If not provided, user will be prompted. .PARAMETER SharePath Optional path for the remote share used for Group Policy deployment. If not provided, the standardized AzureArc folder on desktop will be used. .PARAMETER Force Skip confirmation prompts and proceed with default values where applicable. .EXAMPLE New-AzureArcDevice Interactively creates Azure Arc device configuration with user prompts for all parameters. .EXAMPLE New-AzureArcDevice -SubscriptionId "12345678-1234-1234-1234-123456789012" -ResourceGroupName "rg-azurearc-prod" -Location "eastus" -Force Creates Azure Arc configuration with specified parameters and minimal prompting. .NOTES Author: Lessi Coulibaly Organization: Less-IT (AI and CyberSecurity) Website: https://github.com/coullessi/PowerShell Prerequisites: - Azure PowerShell modules (Az.Accounts, Az.Resources) - Valid Azure subscription with appropriate permissions - Active Directory environment with Group Policy management capabilities - Network access to Azure endpoints - Run Get-AzureArcPrerequisites first to ensure all requirements are met #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string]$SubscriptionId, [Parameter(Mandatory = $false)] [string]$ResourceGroupName, [Parameter(Mandatory = $false)] [string]$Location, [Parameter(Mandatory = $false)] [string]$SharePath, [Parameter(Mandatory = $false)] [switch]$Force ) # Remove quotes from parameters if provided if (-not [string]::IsNullOrWhiteSpace($SharePath)) { $SharePath = Remove-PathQuote -Path $SharePath } # Initialize standardized environment $environment = Initialize-StandardizedEnvironment -ScriptName "New-AzureArcDevice" -RequiredFileTypes @("DeviceLog", "OrgUnitList") # Check if user chose to quit if ($environment.UserQuit) { Write-Host "Returning to main menu..." return } # Check if initialization failed if (-not $environment.Success) { Write-Host "Failed to initialize environment. Exiting..." return } # Set up paths from standardized environment $workingFolder = $environment.FolderPath $logFile = $environment.FilePaths["DeviceLog"] $orgUnitsFile = $environment.FilePaths["OrgUnitList"] Clear-Host Write-Host "" Write-Host " ======================== AZURE ARC DEVICE DEPLOYMENT ======================== " -ForegroundColor Green Write-Host "" Write-Host "" Write-Host " Initializing Azure Arc Device Deployment..." Write-Host "" # Log session start ("=" * 100) | Out-File -FilePath $logFile "AZURE ARC DEVICE DEPLOYMENT SESSION" | Out-File -FilePath $logFile -Append ("=" * 100) | Out-File -FilePath $logFile -Append "Started: $(Get-Date)" | Out-File -FilePath $logFile -Append "Working Folder: $workingFolder" | Out-File -FilePath $logFile -Append "" | Out-File -FilePath $logFile -Append # Azure Authentication and Subscription Selection $authResult = Initialize-AzureAuthenticationAndSubscription -SubscriptionId $SubscriptionId if (-not $authResult.Success) { Write-Host " Azure authentication or subscription selection failed: $($authResult.Message)" "FAILED: Azure authentication - $($authResult.Message)" | Out-File -FilePath $logFile -Append return } Write-Host " Using Azure context: $($authResult.Context.Account.Id)" $subId = $authResult.SubscriptionId $TenantId = $authResult.Context.Tenant.Id # 1. Get resource group and location information Write-Host "`n Azure Resource Configuration" Write-Host "" # Prompt for resource group if ([string]::IsNullOrWhiteSpace($ResourceGroupName)) { $defaultResourceGroup = "rg-azurearc-$(Get-Date -Format 'yyyyMMdd')" $resourceGroup = Read-Host "`nProvide a resource group name for your Azure Arc deployment `n[default: $defaultResourceGroup]" # Use default if user pressed Enter without input if ([string]::IsNullOrWhiteSpace($resourceGroup)) { $resourceGroup = $defaultResourceGroup Write-Host " Using default resource group: $resourceGroup" } else { Write-Host " Resource group: $resourceGroup" } } else { $resourceGroup = $ResourceGroupName Write-Host " Using provided resource group: $resourceGroup" } # Prompt for location if ([string]::IsNullOrWhiteSpace($Location)) { $defaultLocation = "eastus" $location = Read-Host "`nProvide an Azure region for your deployment [default: $defaultLocation] (e.g., westus2, westeurope)" # Use default if user pressed Enter without input if ([string]::IsNullOrWhiteSpace($location)) { $location = $defaultLocation Write-Host " Using default location: $location" } else { $validLocations = @("eastus", "eastus2", "westus", "westus2", "westeurope", "northeurope", "southeastasia", "eastasia", "australiaeast", "uksouth", "canadacentral", "francecentral", "germanywestcentral", "japaneast", "koreacentral", "southafricanorth", "uaenorth", "brazilsouth", "southcentralus", "northcentralus", "centralus", "westcentralus", "westus3") while ($validLocations -notcontains $location.ToLower()) { Write-Host " Invalid location: '$location'" Write-Host " Common locations: eastus, westus2, westeurope, eastasia, australiaeast" $location = Read-Host "Provide a valid Azure region for your deployment [default: $defaultLocation]" # Use default if user pressed Enter without input if ([string]::IsNullOrWhiteSpace($location)) { $location = $defaultLocation break } } Write-Host " Location: $location" } } else { $location = $Location Write-Host " Using provided location: $location" } # 2. Create a resource group Write-Host "`n Creating Azure Resource Group" Write-Host "" # Check if resource group already exists $existingRG = Get-AzResourceGroup -Name $resourceGroup -ErrorAction SilentlyContinue if ($existingRG) { if ($existingRG.Location -eq $location) { # Same location - resource group already exists, just continue Write-Host " Resource group '$resourceGroup' already exists in '$location' - continuing..." } else { # Different location - not allowed by Azure, show clear error Write-Host " ERROR: Resource group '$resourceGroup' already exists in '$($existingRG.Location)'" Write-Host " Azure does not allow resource groups with the same name in different locations within the same subscription." Write-Host " Please choose a different resource group name or use the existing location '$($existingRG.Location)'." throw "Resource group name conflict: '$resourceGroup' already exists in '$($existingRG.Location)'" } } else { # Resource group doesn't exist - create it Write-Host "Creating resource group '$resourceGroup' in '$location'..." try { New-AzResourceGroup -Name $resourceGroup -Location $location | Out-Null Write-Host " Resource group created successfully!" } catch { Write-Host " Failed to create resource group: $($_.Exception.Message)" throw } } # 3. Configure remote share using standardized folder Write-Host "`n Remote Share Configuration" Write-Host "" try { $DomainName = (Get-ADDomain).DNSRoot } catch { Write-Host " Unable to retrieve Active Directory domain information. Ensure this is run on a domain-joined machine." "FAILED: Active Directory domain information retrieval" | Out-File -FilePath $logFile -Append throw } # Use the standardized working folder for the remote share $path = $workingFolder Write-Host " Using standardized working folder for remote share: $path" "Remote share location: $path" | Out-File -FilePath $logFile -Append # Extract share name from the last directory in the path $shareName = Split-Path -Leaf $path Write-Host " Share name will be: $shareName" # Check if share name already exists and points to a different path $existingShare = Get-SmbShare | Where-Object { $_.Name -eq $shareName } -ErrorAction SilentlyContinue $useExistingShare = $false if ($existingShare -and $existingShare.Path -ne $path) { Write-Host " Share '$shareName' already exists and points to: $($existingShare.Path)" Write-Host " You specified a different path: $path" # Create a unique share name by appending a timestamp $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" $originalShareName = $shareName $shareName = "$originalShareName`_$timestamp" Write-Host " Using unique share name: $shareName" } elseif ($existingShare -and $existingShare.Path -eq $path) { Write-Host " Share '$shareName' already exists and points to the correct path" $useExistingShare = $true } # Create directory and share if needed Write-Host "`nCreating remote share..." # Verify the directory exists (it should have been created by Test-ValidPath if needed) if (-not (Test-Path -PathType container $path)) { Write-Host " [FAIL] Directory does not exist and could not be created: $path" -ForegroundColor Green throw "Directory validation failed: $path" } else { Write-Host " Directory verified: $path" } # Create share if it doesn't exist or if we need a new unique name if (-not $useExistingShare) { $parameters = @{ Name = $shareName Path = "$($path)" FullAccess = "$env:USERDOMAIN\$env:USERNAME", "$DomainName\Domain Admins" ChangeAccess = "$DomainName\Domain Users", "$DomainName\Domain Computers", "$DomainName\Domain Controllers" } try { New-SmbShare @parameters -ErrorAction Stop | Out-Null Write-Host " SMB share '$shareName' created successfully!" } catch { if ($_.Exception.Message -match "already exists" -or $_.Exception.Message -match "already shared" -or $_.Exception.Message -match "name has already been shared") { Write-Host " SMB share '$shareName' already exists" } else { Write-Host " Failed to create SMB share: $($_.Exception.Message)" throw } } } # Set the remote share name for use in GPO deployment $RemoteShare = $shareName Write-Host " Remote share '$RemoteShare' is ready for deployment" Write-Host " All files will be stored in: $path" # 4. Download and prepare Azure Arc Components (enhanced functionality) Write-Host "`n Downloading Azure Arc Components" Write-Host "" # Download Azure Connected Machine Agent with enhanced error handling Write-Host " Downloading Azure Connected Machine Agent..." $agentPath = "$path\AzureConnectedMachineAgent.msi" try { # Check if agent already exists if (Test-Path $agentPath) { Write-Host " Agent installer already exists. Checking if update is needed..." $overwrite = Read-Host "Do you want to download a fresh copy? [Y/N] (default: N)" if ($overwrite.ToUpper() -eq 'Y') { Remove-Item $agentPath -Force Write-Host " Existing installer removed" } else { Write-Host " Using existing Azure Connected Machine Agent installer" } } if (-not (Test-Path $agentPath)) { $downloadUrl = "https://aka.ms/AzureConnectedMachineAgent" Write-Host " Downloading from: $downloadUrl" # Download with progress indication $webClient = New-Object System.Net.WebClient $webClient.DownloadFile($downloadUrl, $agentPath) $webClient.Dispose() # Verify download if (Test-Path $agentPath) { $fileSize = (Get-Item $agentPath).Length / 1MB Write-Host " Azure Connected Machine Agent downloaded successfully" Write-Host " File size: $([math]::Round($fileSize, 2)) MB" -ForegroundColor Green } else { throw "Download completed but file not found" } } } catch { Write-Host " Failed to download Azure Connected Machine Agent: $($_.Exception.Message)" throw } # Get the latest ArcEnabledServersGroupPolicy release Write-Host "Fetching latest ArcEnabledServersGroupPolicy release information..." try { # Get latest release information from GitHub API $releaseApiUrl = "https://api.github.com/repos/Azure/ArcEnabledServersGroupPolicy/releases/latest" $releaseInfo = Invoke-RestMethod -Uri $releaseApiUrl -Method Get # Find the zip file asset $zipAsset = $releaseInfo.assets | Where-Object { $_.name -like "*.zip" } | Select-Object -First 1 if (-not $zipAsset) { throw "No zip file found in the latest release" } $latestVersion = $releaseInfo.tag_name $downloadUrl = $zipAsset.browser_download_url $fileName = $zipAsset.name Write-Host " Latest version found: $latestVersion" Write-Host " File: $fileName" # Download the latest version Write-Host "Downloading $fileName..." $localFilePath = "$path\$fileName" Invoke-WebRequest -Uri $downloadUrl -OutFile $localFilePath Write-Host " ArcEnabledServersGroupPolicy downloaded successfully" # Extract the archive Write-Host "Extracting $fileName..." # Remove file extension to get folder name $extractFolderName = [System.IO.Path]::GetFileNameWithoutExtension($fileName) $extractPath = "$path\$extractFolderName" # Remove existing extraction folder if it exists if (Test-Path $extractPath) { Remove-Item $extractPath -Recurse -Force Write-Host " Removed existing extraction folder" } Expand-Archive -LiteralPath $localFilePath -DestinationPath $path -Force Write-Host " Archive extracted successfully" # Set location to the extracted folder Set-Location -Path $extractPath Write-Host " Working directory set to: $extractPath" } catch { Write-Host " Failed to download latest ArcEnabledServersGroupPolicy: $($_.Exception.Message)" Write-Host " Falling back to hardcoded version 1.0.5..." try { # Fallback to hardcoded version $fallbackUrl = "https://github.com/Azure/ArcEnabledServersGroupPolicy/releases/download/1.0.5/ArcEnabledServersGroupPolicy_v1.0.10.zip" $fallbackFile = "$path\ArcEnabledServersGroupPolicy_v1.0.10.zip" Invoke-WebRequest -Uri $fallbackUrl -OutFile $fallbackFile Write-Host " Fallback version downloaded successfully" Expand-Archive -LiteralPath $fallbackFile -DestinationPath $path -Force Set-Location -Path "$path\ArcEnabledServersGroupPolicy_v1.0.10" Write-Host " Fallback archive extracted successfully" } catch { Write-Host " Fallback download also failed: $($_.Exception.Message)" throw "Failed to download ArcEnabledServersGroupPolicy from both latest and fallback sources" } } # 5. Create a service principal for the Azure Connected Machine Agent (enhanced functionality) Write-Host "`n Creating Service Principal" Write-Host "" Write-Host "Creating Azure Arc service principal for onboarding..." $date = Get-Date $ArcServerOnboardingDetail = New-Item -ItemType File -Path "$path\ArcServerOnboarding.txt" "------------------------------------------------------------------------------" | Out-File -FilePath $ArcServerOnboardingDetail -Append "`nService principal creation date: $date`nSecret expiration date: $($date.AddDays(30))" | Out-File -FilePath $ArcServerOnboardingDetail -Append try { # Enhanced service principal creation with better configuration $displayName = "Azure Arc Deployment Account - ServerProtection" $expirationDate = $date.AddDays(30) # Longer expiration period $scope = "/subscriptions/$subId/resourceGroups/$resourceGroup" Write-Host " Service Principal Configuration:" Write-Host " Display Name: $displayName" Write-Host " Scope: $scope" Write-Host " Expiration: $($expirationDate.ToString('yyyy-MM-dd HH:mm:ss'))" # Create the service principal with enhanced error handling Write-Host " Creating service principal..." $ServicePrincipal = New-AzADServicePrincipal -DisplayName $displayName -EndDate $expirationDate if ($ServicePrincipal) { Write-Host " Service principal created successfully!" Write-Host " Waiting for Azure propagation..." Start-Sleep -Seconds 10 # Wait for Azure to propagate the service principal # Assign the role with retry logic $maxRetries = 3 $retryCount = 0 $roleAssigned = $false while (-not $roleAssigned -and $retryCount -lt $maxRetries) { try { $retryCount++ Write-Host " Attempting role assignment (attempt $retryCount of $maxRetries)..." New-AzRoleAssignment -ObjectId $ServicePrincipal.Id -RoleDefinitionName "Azure Connected Machine Onboarding" -Scope $scope -ErrorAction Stop $roleAssigned = $true Write-Host " Role assignment completed successfully!" } catch { Write-Host " Role assignment attempt $retryCount failed: $($_.Exception.Message)" if ($retryCount -lt $maxRetries) { Write-Host " Waiting 15 seconds before retry..." Start-Sleep -Seconds 15 } else { Write-Host " All role assignment attempts failed. Service principal created but role not assigned." Write-Host " You may need to manually assign the 'Azure Connected Machine Onboarding' role to the service principal." } } } # Save detailed service principal information $spDetails = @" Service Principal Details: Application ID: $($ServicePrincipal.AppId) Object ID: $($ServicePrincipal.Id) Display Name: $($ServicePrincipal.DisplayName) Subscription: $($authResult.SubscriptionName) Resource Group: $resourceGroup Scope: $scope Creation Date: $($date.ToString('yyyy-MM-dd HH:mm:ss')) Expiration Date: $($expirationDate.ToString('yyyy-MM-dd HH:mm:ss')) "@ $spDetails | Out-File -FilePath $ArcServerOnboardingDetail -Append $AppId = $ServicePrincipal.AppId $Secret = $ServicePrincipal.PasswordCredentials.SecretText Write-Host " Service principal details saved to: $($ArcServerOnboardingDetail.FullName)" Write-Host " Application ID: $AppId" Write-Host " Secret: [HIDDEN FOR SECURITY - Use from memory during deployment]" Write-Host " Secret expires: $($expirationDate.ToString('yyyy-MM-dd HH:mm:ss'))" Write-Host "`n IMPORTANT SECURITY NOTES:" -ForegroundColor Yellow Write-Host " Store the client secret securely - it cannot be retrieved again" Write-Host " The secret expires on $($expirationDate.ToString('yyyy-MM-dd'))" Write-Host " Limit access to these credentials to authorized personnel only" } else { Write-Host " Failed to create service principal" throw "Service principal creation failed" } } catch { Write-Host " Failed to create service principal: $($_.Exception.Message)" throw } # 6. Deploy the group policy object and link it to the selected organizational units try { $DC = Get-ADDomainController $DomainFQDN = $DC.Domain $ReportServerFQDN = $DC.HostName } catch { Write-Host " Unable to retrieve Active Directory domain controller information." throw } Write-Host "`n Deploying Group Policy Object" Write-Host "" # Check if DeployGPO.ps1 exists with enhanced validation $deployGPOFound = $false $deployGPOPath = ".\DeployGPO.ps1" # First check current directory if (Test-Path $deployGPOPath) { $deployGPOValidation = Test-ValidPath -Path $deployGPOPath -PathType File -RequireExists if ($deployGPOValidation.IsValid) { Write-Host " Found DeployGPO.ps1 in current directory" $deployGPOFound = $true } } # If not found, search in the path directory if (-not $deployGPOFound) { Write-Host " DeployGPO.ps1 not found in current directory: $(Get-Location)" Write-Host " Searching in extracted ArcEnabledServersGroupPolicy folder..." try { $foundPath = Get-ChildItem -Path $path -Recurse -Name "DeployGPO.ps1" -ErrorAction Stop | Select-Object -First 1 if ($foundPath) { $fullDeployGPOPath = Join-Path $path $foundPath $deployGPOValidation = Test-ValidPath -Path $fullDeployGPOPath -PathType File -RequireExists if ($deployGPOValidation.IsValid) { Write-Host " Found DeployGPO.ps1 at: $fullDeployGPOPath" $parentDir = Split-Path $fullDeployGPOPath -Parent # Validate we can access the parent directory $parentDirValidation = Test-ValidPath -Path $parentDir -PathType Directory -RequireExists if ($parentDirValidation.IsValid) { Set-Location $parentDir Write-Host " Changed working directory to: $parentDir" $deployGPOFound = $true } else { Write-Host " Cannot access directory containing DeployGPO.ps1: $($parentDirValidation.Error)" } } } } catch { Write-Host " Error searching for DeployGPO.ps1: $($_.Exception.Message)" } } # Final validation if (-not $deployGPOFound) { Write-Host " Could not locate DeployGPO.ps1 in any accessible directory" Write-Host " Please ensure the ArcEnabledServersGroupPolicy archive was properly extracted" throw "DeployGPO.ps1 script not found or not accessible" } # Get GPO count before deployment to identify the new GPO $gpoCountBefore = (Get-GPO -All -Domain $DomainFQDN).Count Write-Host "Current GPO count: $gpoCountBefore" try { .\DeployGPO.ps1 -DomainFQDN $DomainFQDN ` -ReportServerFQDN $ReportServerFQDN ` -ArcRemoteShare $RemoteShare ` -ServicePrincipalSecret $Secret ` -ServicePrincipalClientId $AppId ` -SubscriptionId $subId ` -ResourceGroup $resourceGroup ` -Location $location ` -TenantId $TenantId *>&1 | Out-Null Write-Host " Group Policy deployment completed" } catch { Write-Host " Failed to deploy Group Policy: $($_.Exception.Message)" throw } # Identify the newly created GPO Write-Host "Identifying the newly created Azure Arc GPO..." Start-Sleep -Seconds 2 # Give time for GPO creation to complete $gpoCountAfter = (Get-GPO -All -Domain $DomainFQDN).Count Write-Host "GPO count after deployment: $gpoCountAfter" # Try multiple methods to find the correct GPO $GPOName = $null # Method 1: Look for newly created GPO if count increased if ($gpoCountAfter -gt $gpoCountBefore) { $allGPOs = Get-GPO -All -Domain $DomainFQDN | Sort-Object CreationTime -Descending $newestGPO = $allGPOs | Where-Object { $_.DisplayName -like "*Azure*" -or $_.DisplayName -like "*Arc*" -or $_.DisplayName -like "*MSFT*" } | Select-Object -First 1 if ($newestGPO) { $GPOName = $newestGPO.DisplayName Write-Host " Found newly created GPO: $GPOName" } } # Method 2: Fallback to specific Azure Arc related patterns if (-not $GPOName) { Write-Host " Attempting to find GPO using pattern matching..." $arcGPOs = Get-GPO -All -Domain $DomainFQDN | Where-Object { $_.DisplayName -like "*Azure Arc*" -or $_.DisplayName -like "*ArcEnabled*" -or $_.DisplayName -like "*ConnectedMachine*" } if ($arcGPOs) { if ($arcGPOs -is [array]) { $GPOName = ($arcGPOs | Sort-Object CreationTime -Descending | Select-Object -First 1).DisplayName } else { $GPOName = $arcGPOs.DisplayName } Write-Host " Found Azure Arc GPO: $GPOName" } } # Method 3: Final fallback to MSFT pattern (original method) if (-not $GPOName) { Write-Host " Using fallback pattern matching for MSFT..." $msftGPO = Get-GPO -All -Domain $DomainFQDN | Where-Object { $_.DisplayName -Like "*MSFT*" } | Sort-Object CreationTime -Descending | Select-Object -First 1 if ($msftGPO) { $GPOName = $msftGPO.DisplayName Write-Host " Found MSFT GPO: $GPOName" Write-Host " Note: This may not be the correct Azure Arc GPO if multiple MSFT GPOs exist" } } # Validate we found a GPO if (-not $GPOName) { Write-Host " Could not identify the Azure Arc GPO. Please check the DeployGPO.ps1 output." Write-Host " Available GPOs in domain:" -ForegroundColor Yellow Get-GPO -All -Domain $DomainFQDN | Sort-Object DisplayName | ForEach-Object { Write-Host " $($_.DisplayName)" } return } Write-Host " Using GPO for OU linking: $GPOName" # Prompt user for OU configuration Write-Host "`n Organizational Units Configuration" Write-Host "" Write-Host " You can create a file containing the list of Organizational Units (OUs)" Write-Host " for future GPO linking. This file will be stored in the working folder." # Use the standardized OU file from environment initialization $ouFileFullPath = $orgUnitsFile $ouFileName = [System.IO.Path]::GetFileName($ouFileFullPath) Write-Host "`nChoose OU configuration method:" -ForegroundColor Yellow Write-Host " 1. Create/edit OU file: $ouFileName" Write-Host " 2. Skip OU file creation (link GPO manually later)" if (-not $Force) { $defaultChoice = "1" $configChoice = Read-Host "Select option [1/2] (default: 1)" # Use default if user pressed Enter without input if ([string]::IsNullOrWhiteSpace($configChoice)) { $configChoice = $defaultChoice Write-Host " Using default choice: Create/edit OU file" } } else { $configChoice = "1" } $createOUFile = $true switch ($configChoice) { "2" { $createOUFile = $false Write-Host " Skipping OU file creation. You can link the GPO manually later." "OU file creation skipped by user choice" | Out-File -FilePath $logFile -Append } default { Write-Host " Using default OU file: $defaultOUFile" } } if ($createOUFile) { # Get domain information for better examples try { $domainInfo = Get-ADDomain $domainDN = $domainInfo.DistinguishedName $domainNetBIOS = $domainInfo.NetBIOSName Write-Host " Domain: $($domainInfo.DNSRoot) ($domainNetBIOS)" } catch { Write-Host " Could not retrieve domain information" $domainDN = "DC=domain,DC=com" } # Check if file already exists if (Test-Path $ouFileFullPath) { Write-Host " OU file '$(Split-Path $ouFileFullPath -Leaf)' already exists." Write-Host " File location: $ouFileFullPath" if (-not $Force) { $editChoice = Read-Host "Do you want to (E)dit the existing file or (C)reate a new one? [E/C] (default: E)" if ([string]::IsNullOrWhiteSpace($editChoice)) { $editChoice = "E" } if ($editChoice.ToUpper() -eq "C") { Write-Host " Creating new OU file (overwriting existing)..." $createNewFile = $true } else { Write-Host " Will edit existing file" $createNewFile = $false } } else { $createNewFile = $false } } else { Write-Host " OU file '$(Split-Path $ouFileFullPath -Leaf)' does not exist." Write-Host " Creating new OU file..." $createNewFile = $true } # Create new file if needed if ($createNewFile) { # Get available OUs for better examples try { $availableOUs = Get-ADOrganizationalUnit -Filter * | Select-Object -First 10 Name | Sort-Object Name $ouExamples = @() if ($availableOUs.Count -gt 0) { $ouExamples = $availableOUs | Select-Object -First 5 | ForEach-Object { "# $($_.Name)" } } else { $ouExamples = @("# Servers", "# Workstations", "# Production") } } catch { $ouExamples = @("# Servers", "# Workstations", "# Production") } # Create comprehensive OU file template $ouFileContent = @( "# Azure Arc Organizational Units Configuration", "# Add one OU name per line (simple names, not full distinguished names)", "# Lines starting with # are comments and will be ignored", "# ", "# INSTRUCTIONS:", "# 1. Add the names of OUs where you want to apply the Azure Arc GPO", "# 2. Use simple OU names (e.g., 'Servers', 'Workstations')", "# 3. Use special keyword 'DOMAIN' to apply to the entire domain", "# 4. Save this file and close Notepad to continue", "# ", "# IMPORTANT: GPOs can only be linked to Organizational Units (OUs) and domains,", "# NOT to built-in containers like Computers, Users, etc.", "# ", "# Available OUs in your domain:" ) + $ouExamples + @( "# ", "# Examples of VALID entries:", "# DOMAIN", "# Servers", "# Workstations", "# Production Servers", "# ", "# Examples of INVALID entries (containers - will be ignored):", "# Computers", "# Users", "# Builtin", "# ", "# Default example (remove # to use):", "# DOMAIN", "", "# Add your OU names below (one per line):", "" ) $ouFileContent | Out-File -FilePath $ouFileFullPath -Encoding UTF8 # Validate the file was created successfully $ouFileValidation = Test-ValidPath -Path $ouFileFullPath -PathType File -RequireExists if (-not $ouFileValidation.IsValid) { Write-Host " Failed to create OU file: $($ouFileValidation.Error)" throw "Failed to create OU configuration file" } Write-Host " Created OU file template: $(Split-Path $ouFileFullPath -Leaf)" } # Always open file for editing (mandatory user interaction) Write-Host "`n Opening OU file for editing..." Write-Host " File location: $ouFileFullPath" Write-Host "" Write-Host " INSTRUCTIONS:" -ForegroundColor Yellow Write-Host " 1. Add the names of OUs where you want to link the Azure Arc GPO" Write-Host " 2. Use simple OU names (e.g., 'Servers', 'Workstations')" Write-Host " 3. Use 'DOMAIN' to apply to the entire domain" Write-Host " 4. Save the file and close Notepad to continue" Write-Host "" try { Write-Host " Opening Notepad..." Start-Process notepad.exe -ArgumentList $ouFileFullPath -Wait Write-Host " Notepad closed. Continuing with deployment..." } catch { Write-Host " Could not open Notepad automatically." Write-Host " Please edit the file manually: $ouFileFullPath" if (-not $Force) { Read-Host "Press Enter when you have finished editing the file" } } # Verify file exists and validate accessibility $ouFileValidation = Test-ValidPath -Path $ouFileFullPath -PathType File -RequireExists if ($ouFileValidation.IsValid) { Write-Host " OU file ready: $(Split-Path $ouFileFullPath -Leaf)" # Show file contents for confirmation and prepare for linking try { $ouNames = Get-Content $ouFileFullPath -ErrorAction Stop | Where-Object { $_.Trim() -ne "" -and -not $_.Trim().StartsWith("#") } | ForEach-Object { # Remove quotes and trim all whitespace (including spaces, tabs, etc.) $cleaned = Remove-PathQuote -Path $_.Trim() $cleaned.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } if ($ouNames.Count -gt 0) { Write-Host " OU file contains $($ouNames.Count) target(s):" $ouNames | ForEach-Object { Write-Host " '$_'" } # Ask user if they want to link the GPO now if (-not $Force) { $linkChoice = Read-Host "`nDo you want to link the GPO '$GPOName' to these OUs now? [Y/N] (default: Y)" if ([string]::IsNullOrWhiteSpace($linkChoice)) { $linkChoice = "Y" } } else { $linkChoice = "Y" } if ($linkChoice.ToUpper() -eq "Y") { Write-Host "`n Linking GPO to Organizational Units" Write-Host "" # Get domain information for better OU resolution try { $domainInfo = Get-ADDomain $domainDN = $domainInfo.DistinguishedName Write-Host " Domain DN: $domainDN" } catch { Write-Host " Could not retrieve domain information" $domainDN = $null } $validTargets = @() $skippedTargets = @() foreach ($ouName in $ouNames) { # Ensure no leading/trailing spaces $cleanOUName = $ouName.Trim() Write-Host " Processing OU: '$cleanOUName'" try { # Handle special keyword 'DOMAIN' for domain root if ($cleanOUName.ToUpper() -eq "DOMAIN") { if ($domainDN) { $validTargets += $domainDN Write-Host " Resolved to domain root: $domainDN" } else { Write-Host " ERROR: Could not determine domain DN" $skippedTargets += @{Name = $cleanOUName; Reason = "Could not determine domain DN"} } } # Skip known container names that can't have GPOs linked elseif (@("Computers", "Users", "Builtin") -contains $cleanOUName) { Write-Host " SKIPPED: '$cleanOUName' is a container, not an OU" Write-Host " REASON: GPOs cannot be linked to built-in containers" $skippedTargets += @{Name = $cleanOUName; Reason = "Built-in container - GPOs cannot be linked to containers"} } # Look up actual OU by name else { Write-Host " Searching for OU name: '$cleanOUName'" # Search for OU by name across the entire domain $ou = Get-ADOrganizationalUnit -Filter "Name -eq '$cleanOUName'" -ErrorAction SilentlyContinue if ($ou) { if ($ou -is [array]) { # Multiple OUs with same name found Write-Host " WARNING: Multiple OUs found with name '$cleanOUName':" $ou | ForEach-Object { Write-Host " - $($_.DistinguishedName)" } $selectedOU = $ou[0].DistinguishedName # Use first one Write-Host " Using first match: $selectedOU" $validTargets += $selectedOU } else { Write-Host " Found OU: $($ou.DistinguishedName)" $validTargets += $ou.DistinguishedName } } else { Write-Host " ERROR: OU '$cleanOUName' not found in domain" $skippedTargets += @{Name = $cleanOUName; Reason = "OU not found in domain"} # Show available OUs as suggestion try { $availableOUs = Get-ADOrganizationalUnit -Filter * | Select-Object -First 10 Name | Sort-Object Name if ($availableOUs.Count -gt 0) { Write-Host " Available OUs (first 10):" $availableOUs | ForEach-Object { Write-Host " $($_.Name)" } } } catch { Write-Host " Could not retrieve available OUs" } } } } catch { Write-Host " ERROR: Failed to process OU '$cleanOUName': $($_.Exception.Message)" $skippedTargets += @{Name = $cleanOUName; Reason = "Processing error: $($_.Exception.Message)"} } } # Show summary of targets if ($validTargets.Count -gt 0) { Write-Host "`n Valid targets for GPO linking ($($validTargets.Count)):" $validTargets | ForEach-Object { Write-Host " $_" } } if ($skippedTargets.Count -gt 0) { Write-Host "`n Skipped targets ($($skippedTargets.Count)):" $skippedTargets | ForEach-Object { Write-Host " $($_.Name): $($_.Reason)" } } # Proceed with linking if we have valid targets if ($validTargets.Count -gt 0) { Write-Host "`n Proceeding with GPO linking..." $successCount = 0 $failureCount = 0 foreach ($target in $validTargets) { try { Write-Host " Linking GPO '$GPOName' to: $target" New-GPLink -Name $GPOName -Target $target -ErrorAction Stop | Out-Null Write-Host " SUCCESS: GPO linked successfully" $successCount++ } catch { if ($_.Exception.Message -match "already linked") { Write-Host " INFO: GPO already linked to this target" $successCount++ # Count as success since it's already linked } else { Write-Host " ERROR: Failed to link GPO" Write-Host " REASON: $($_.Exception.Message)" $failureCount++ } } } # Show final linking summary Write-Host "`n GPO Linking Summary:" -ForegroundColor Yellow Write-Host " Successful links: $successCount" Write-Host " Failed links: $failureCount" -ForegroundColor $(if ($failureCount -gt 0) { "Red" } else { "Gray" }) Write-Host " Skipped targets: $($skippedTargets.Count)" -ForegroundColor $(if ($skippedTargets.Count -gt 0) { "Yellow" } else { "Gray" }) } else { Write-Host "`n No valid targets found for GPO linking." Write-Host " Please review the OU names in the file and ensure they exist in your domain." } } else { Write-Host " GPO linking skipped by user choice." } } else { Write-Host " OU file is empty (no targets specified)" Write-Host " You can manually link the GPO '$GPOName' later using Group Policy Management Console" } } catch { Write-Host " Could not read OU file contents: $($_.Exception.Message)" Write-Host " File may be corrupted or inaccessible" } } else { Write-Host " Error accessing OU file: $($ouFileValidation.Error)" Write-Host " OU file validation failed - GPO linking cannot proceed automatically" Write-Host " You can manually link the GPO '$GPOName' later using Group Policy Management Console" } } Write-Host "`n Azure Arc deployment completed successfully!" Write-Host " Service principal details saved to: $($ArcServerOnboardingDetail.FullName)" Write-Host " Note: Service principal secret was not saved to file for security reasons." Write-Host " Remote share created: \\$env:COMPUTERNAME\$RemoteShare" Write-Host " GPO '$GPOName' created and ready" if ($createOUFile -and (Test-Path $ouFileFullPath)) { Write-Host " OU configuration file: $(Split-Path $ouFileFullPath -Leaf)" Write-Host " File location: $ouFileFullPath" } Write-Host Write-Host " Next Steps:" -ForegroundColor Yellow Write-Host " 1. Verify GPO settings and linking in Group Policy Management Console" if ($createOUFile) { Write-Host " - Check if GPO '$GPOName' is properly linked to your desired OUs" } Write-Host " 2. Run 'gpupdate /force' on target devices or wait for automatic refresh" Write-Host " 3. Monitor Azure Arc onboarding in Azure portal" Write-Host " 4. Check device compliance in Microsoft Defender for Cloud" Write-Host } |