param( [Parameter(Mandatory=$true)] [string]$ConfigFile ) $ErrorActionPreference = "Stop" # ============================================================================ # FUNCTIONS # ============================================================================ function Convert-ToHashtable { param($Object) if ($null -eq $Object) { return $null } if ($Object -is [hashtable]) { return $Object } if ($Object -is [System.Collections.IEnumerable] -and $Object -isnot [string]) { return @(foreach ($item in $Object) { Convert-ToHashtable $item }) } if ($Object -is [PSCustomObject]) { $hash = @{} foreach ($prop in $Object.PSObject.Properties) { $hash[$prop.Name] = Convert-ToHashtable $prop.Value } return $hash } return $Object } function Build-CloudInit { param($VM) # User-data with datasource configuration $userData = "#cloud-config`n" $userData += "hostname: $($VM.hostname)`n" $userData += "fqdn: $($VM.fqdn)`n" $userData += "manage_etc_hosts: true`n" # CRITICAL: Ensure VMware datasource is used $userData += "`ndatasource:`n" $userData += " VMware:`n" $userData += " allow_raw_data: true`n" $userData += "`ndatasource_list: [ VMware, OVF, None ]`n`n" # User account for admin access $userData += "users:`n" $userData += " - name: $($VM.adminUser)`n" $userData += " sudo: ALL=(ALL) NOPASSWD:ALL`n" $userData += " groups: sudo`n" $userData += " shell: /bin/bash`n" $userData += " lock_passwd: false`n" $userData += " passwd: $($VM.adminPasswordHash)`n" # SSH keys if ($VM.adminsshKeys) { $userData += " ssh_authorized_keys:`n" foreach ($key in $VM.adminsshKeys) { $userData += " - $key`n" } } # User account for Ansible (separate from admin user) $userData += " - name: $($VM.ansibleUser)`n" $userData += " sudo: ALL=(ALL) NOPASSWD:ALL`n" $userData += " groups: sudo`n" $userData += " shell: /bin/bash`n" $userData += " lock_passwd: false`n" $userData += " passwd: $($VM.ansiblePasswordHash)`n" # SSH keys if ($VM.ansibleSshKeys) { $userData += " ssh_authorized_keys:`n" foreach ($key in $VM.ansibleSshKeys) { $userData += " - $key`n" } } # Packages $userData += "`npackage_update: true`n" $userData += "package_upgrade: true`n" if ($VM.packages) { $userData += "`npackages:`n" foreach ($pkg in $VM.packages) { $userData += " - $pkg`n" } } # Commands $commands = @() # LVM setup if ($VM.additionalDisks) { foreach ($disk in $VM.additionalDisks) { if ($disk.useLVM) { $vg = $disk.volumeGroup $dev = $disk.devicePath $commands += "while [ ! -e $dev ]; do sleep 1; done" $commands += "pvcreate $dev" $commands += "vgcreate $vg $dev" foreach ($lv in $disk.logicalVolumes) { $lvpath = "/dev/$vg/$($lv.name)" $commands += "lvcreate -L $($lv.size) -n $($lv.name) $vg" $commands += "mkfs.$($lv.filesystem) $lvpath" $commands += "mkdir -p $($lv.mountPoint)" $commands += "echo '$lvpath $($lv.mountPoint) $($lv.filesystem) defaults 0 0' >> /etc/fstab" $commands += "mount $($lv.mountPoint)" } } } } # CIS hardening if ($VM.cisLevel1) { $cisPath = Join-Path $PSScriptRoot "cis-level1-hardening.sh" if (Test-Path $cisPath) { $cisContent = Get-Content $cisPath -Raw $cisB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($cisContent)) $userData += "`nwrite_files:`n" $userData += " - encoding: b64`n" $userData += " content: $cisB64`n" $userData += " path: /tmp/cis.sh`n" $userData += " permissions: '0755'`n" $commands += "/tmp/cis.sh" } } # User commands if ($VM.runCommands) { $commands += $VM.runCommands } # Add all commands if ($commands.Count -gt 0) { $userData += "`nruncmd:`n" foreach ($cmd in $commands) { $userData += " - $cmd`n" } } # Metadata with network configuration embedded $metadata = "instance-id: $($VM.name)`n" $metadata += "local-hostname: $($VM.hostname)`n" $metadata += "network:`n" $metadata += " version: 2`n" $metadata += " ethernets:`n" $metadata += " id0:`n" $metadata += " match:`n" $metadata += " name: en*`n" if ($VM.networkType -eq "static") { $metadata += " addresses: [$($VM.ipAddress)/$($VM.subnetMask)]`n" $metadata += " routes:`n" $metadata += " - to: default`n" $metadata += " via: $($VM.gateway)`n" $metadata += " nameservers:`n" $metadata += " addresses: [$($VM.dnsServers -join ', ')]`n" } else { $metadata += " dhcp4: true`n" } # Separate network config for guestinfo.network-config (keep both for compatibility) $network = "version: 2`n" $network += "ethernets:`n" $network += " id0:`n" $network += " match:`n" $network += " name: en*`n" if ($VM.networkType -eq "static") { $network += " addresses: [$($VM.ipAddress)/$($VM.subnetMask)]`n" $network += " routes:`n" $network += " - to: default`n" $network += " via: $($VM.gateway)`n" $network += " nameservers:`n" $network += " addresses: [$($VM.dnsServers -join ', ')]`n" } else { $network += " dhcp4: true`n" } return @{ UserData = $userData Metadata = $metadata Network = $network } } function Deploy-SingleVM { param($VM, $Global) Write-Host "`n========================================" -ForegroundColor Cyan Write-Host "Deploying: $($VM.name)" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan try { # Check existing if (Get-VM -Name $VM.name -ErrorAction SilentlyContinue) { Write-Host "ERROR: VM already exists" -ForegroundColor Red return $false } # Get resources Write-Host "Getting vCenter resources..." -ForegroundColor Gray $cluster = Get-Cluster -Name $Global.cluster $datastore = Get-Datastore -Name $VM.datastore $contentLib = Get-ContentLibrary -Name $Global.contentLibrary $template = Get-ContentLibraryItem -ContentLibrary $contentLib -Name $Global.ovaTemplate $resPool = Get-ResourcePool -Location $cluster -Name "Resources" $folder = Get-Folder -Name $VM.folder -ErrorAction SilentlyContinue # Deploy VM Write-Host "Deploying from Content Library..." -ForegroundColor Yellow $newVM = New-VM -Name $VM.name ` -ContentLibraryItem $template ` -ResourcePool $resPool ` -Datastore $datastore ` -Location $folder Write-Host "✓ VM created" -ForegroundColor Green # Configure hardware Write-Host "Configuring hardware..." -ForegroundColor Yellow Set-VM -VM $newVM -NumCpu $VM.cpuCount -MemoryGB $VM.memoryGB -Confirm:$false | Out-Null # Resize disk if ($VM.osDiskGB) { $disk = Get-HardDisk -VM $newVM | Select-Object -First 1 if ($disk.CapacityGB -lt $VM.osDiskGB) { Set-HardDisk -HardDisk $disk -CapacityGB $VM.osDiskGB -Confirm:$false | Out-Null } } # Network $nic = Get-NetworkAdapter -VM $newVM Set-NetworkAdapter -NetworkAdapter $nic -NetworkName $VM.portGroup -StartConnected:$true -Confirm:$false | Out-Null # Additional disks if ($VM.additionalDisks) { foreach ($disk in $VM.additionalDisks) { New-HardDisk -VM $newVM -CapacityGB $disk.sizeGB -StorageFormat Thin -Datastore $datastore | Out-Null } } Write-Host "✓ Hardware configured" -ForegroundColor Green # Cloud-init Write-Host "Setting up cloud-init..." -ForegroundColor Yellow $ci = Build-CloudInit -VM $VM # CRITICAL: Always set network-config (for both DHCP and static) # This overrides the template's default network configuration $settings = @{ "guestinfo.userdata" = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($ci.UserData)) "guestinfo.userdata.encoding" = "base64" "guestinfo.metadata" = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($ci.Metadata)) "guestinfo.metadata.encoding" = "base64" "guestinfo.network-config" = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($ci.Network)) "guestinfo.network-config.encoding" = "base64" "disk.EnableUUID" = "TRUE" } foreach ($key in $settings.Keys) { $adv = Get-AdvancedSetting -Entity $newVM -Name $key -ErrorAction SilentlyContinue if ($adv) { Set-AdvancedSetting -AdvancedSetting $adv -Value $settings[$key] -Confirm:$false | Out-Null } else { New-AdvancedSetting -Entity $newVM -Name $key -Value $settings[$key] -Confirm:$false | Out-Null } } # CRITICAL: Enable VMware customization Write-Host "Ebling VMware customization..." -ForegroundColor Gray $spec = New-Object VMware.Vim.VirtualMachineConfigSpec $spec.Tools = New-Object VMware.Vim.ToolsConfigInfo $spec.Tools.SyncTimeWithHost = $false # Create custom attributes for cloud-init $extraConfig = @() $opt = New-Object VMware.Vim.OptionValue $opt.Key = "guestinfo.disable_vmware_customization" $opt.Value = "false" $extraConfig += $opt $spec.ExtraConfig = $extraConfig $newVM.ExtensionData.ReconfigVM($spec) Write-Host "✓ Cloud-init configured" -ForegroundColor Green Write-Host " Network: $($VM.networkType)" -ForegroundColor Gray if ($VM.networkType -eq "static") { Write-Host " IP: $($VM.ipAddress)/$($VM.subnetMask)" -ForegroundColor Gray Write-Host " Gateway: $($VM.gateway)" -ForegroundColor Gray Write-Host " DNS: $($VM.dnsServers -join ', ')" -ForegroundColor Gray } # Power on Write-Host "Powering on VM..." -ForegroundColor Yellow Start-VM -VM $newVM -Confirm:$false | Out-Null Write-Host "`n✓✓✓ SUCCESS ✓✓✓" -ForegroundColor Green Write-Host "VM: $($VM.name)" -ForegroundColor White if ($VM.networkType -eq "static") { Write-Host "IP: $($VM.ipAddress)" -ForegroundColor White } else { Write-Host "Network: DHCP" -ForegroundColor White } Write-Host "Cloud-init will complete in 2-5 minutes" -ForegroundColor Yellow return $true } catch { Write-Host "`n✗✗✗ FAILED ✗✗✗" -ForegroundColor Red Write-Host "Error: $_" -ForegroundColor Red return $false } } # ============================================================================ # MAIN # ============================================================================ Write-Host "`n╔══════════════════════════════════════╗" -ForegroundColor Cyan Write-Host "║ Ubuntu VM Deployment Script ║" -ForegroundColor Cyan Write-Host "╚══════════════════════════════════════╝" -ForegroundColor Cyan # Load config Write-Host "`nLoading configuration..." -ForegroundColor Gray if (!(Test-Path $ConfigFile)) { Write-Host "ERROR: Config file not found: $ConfigFile" -ForegroundColor Red exit 1 } $json = Get-Content $ConfigFile -Raw | ConvertFrom-Json $config = Convert-ToHashtable $json # Validate if (!$config.global.contentLibrary) { Write-Host "ERROR: Missing contentLibrary in config" -ForegroundColor Red Write-Host "Add: `"contentLibrary`": `"YourLibraryName`"" -ForegroundColor Yellow exit 1 } if (!$config.global.ovaTemplate) { Write-Host "ERROR: Missing ovaTemplate in config" -ForegroundColor Red Write-Host "Add: `"ovaTemplate`": `"noble-server-cloudimg-amd64`"" -ForegroundColor Yellow exit 1 } Write-Host "✓ Config loaded" -ForegroundColor Green Write-Host " Content Library: $($config.global.contentLibrary)" -ForegroundColor White Write-Host " Template: $($config.global.ovaTemplate)" -ForegroundColor White Write-Host " VMs: $($config.vms.Count)" -ForegroundColor White # Load PowerCLI if (!(Get-Module VMware.VimAutomation.Core -ErrorAction SilentlyContinue)) { Write-Host "`nLoading PowerCLI..." -ForegroundColor Gray Import-Module VMware.VimAutomation.Core -ErrorAction Stop } # Connect Write-Host "`nConnecting to vCenter..." -ForegroundColor Gray $pass = ConvertTo-SecureString $config.vcenter.password -AsPlainText -Force $cred = New-Object PSCredential($config.vcenter.username, $pass) Connect-VIServer -Server $config.vcenter.server -Credential $cred | Out-Null Write-Host "✓ Connected to $($config.vcenter.server)" -ForegroundColor Green # Deploy $success = 0 $failed = 0 foreach ($vm in $config.vms) { if (Deploy-SingleVM -VM $vm -Global $config.global) { $success++ } else { $failed++ } if ($config.global.delayBetweenDeployments -gt 0) { Write-Host "`nWaiting $($config.global.delayBetweenDeployments)s..." -ForegroundColor Gray Start-Sleep -Seconds $config.global.delayBetweenDeployments } } # Summary Write-Host "`n`n╔══════════════════════════════════════╗" -ForegroundColor Cyan Write-Host "║ SUMMARY ║" -ForegroundColor Cyan Write-Host "╚══════════════════════════════════════╝" -ForegroundColor Cyan Write-Host "Total: $($config.vms.Count)" -ForegroundColor White Write-Host "Success: $success" -ForegroundColor Green Write-Host "Failed: $failed" -ForegroundColor $(if($failed -gt 0){"Red"}else{"Green"}) Disconnect-VIServer -Confirm:$false Write-Host "`n✓ Done!`n" -ForegroundColor Green