In Part 1 and Part 2 of this series we used Ubuntu as the guest OS of our target instances. In Part 3 we will show how to use VMware’s Photon OS as our guest OS.

The main reason to use Photon OS is that it is open-sourced, it has a small footprint and it is optimised for VMware vSphere.
Some Background
Does this mean that Photon OS is the ideal guest OS for our ‘cattle‘ stations where we want to run our PowerShell scripts?
Not really, the major flaw with Photon OS, imho, is that the available packages are limited and rather infrequently maintained.
In fact, until recently the available PowerShell package was an outdated, not supported anymore version. But the worst part of the deal, it didn’t even allow to perform an Install-Module. Which, again imho, is a basic concept in PowerShell. And which didn’t allow me to install, for example, VMware PowerCLI on a Photon station.
But then recently this happened!

The available PowerShell v6 TDNF package was upgraded to a currently supported version, and more importantly, the Import-Module cmdlet worked!
Allowing us to install VMware PowerCLI on a Photon instance.
Is this the end of all the PowerShell woes on Photon OS?
Not really! The PowerShell version in this TDNF package will also run out of support in a not so far future and a PowerShell TDNF package upgrade is (still) not automated and somewhat of a black art.
But for now, let’s be happy with what we have and ‘cloud-init’ our Photon ‘cattle’ station!
Issues and some Solutions
No UserData Property
While the Ubuntu cloud image, as we saw in Part 1 of the series, has the option to pass the user-data to cloud-init via an OVF property, this feature is unfortunately not available in the Photon OVA.
There are some solutions available.
- Roll your own OVA image
- Use the NoCloud option, which means creating an ISO containing the user-data and attaching this ISO to the instance before cloud-init runs.
Since I prefer to use the ‘standard’ OVA, I opt for creating such a seed ISO.
The Root Password
This is somewhat of a chicken-and-the-egg dilemma.
In the Photon OVA image, the root account is set to force a password change on first use. But unfortunately, this also disallows setting the root password via the user-data. See Issue#931 for more details.
Again, there are two options available.
- Roll your own OVA image
- Use a two-stage process, wherein the first stage we use William Lam‘s Set-VMKeystrokes function. Then in the second stage, we attach the seed ISO and start the instance. And thus kicking off the cloud-init process.
And again, since I prefer to use the ‘standard’ OVA, I opt for the Set-VMKeystrokes solution.
Install-CloudInitVM Revisited
function Install-CloudInitVM
{
<#
.SYNOPSIS
Deploy a VM from an OVA file and use cloud-init for the configuration
.DESCRIPTION
This function will deploy an OVA file.
The function transfer the user-data to the cloud-init process on the VM with
one of the OVF properties.
.NOTES
Author: Luc Dekens
Version:
1.0 05/12/19 Initial release
1.1 07/12/19 Added Photon deployment
Added seed ISO option
.PARAMETER OvaFile
Specifies the path to the OVA file
.PARAMETER VmName
The displayname of the VM
.PARAMETER ClusterName
The cluster onto which the VM shall be deployed
.PARAMETER DsName
The datastore on which the VM shall be deployed
.PARAMETER PgName
The portgroupname to which the VM shall be connected
.PARAMETER CloudConfig
The path to the YAML file containing the user-data
.PARAMETER CloudConfigISO
The ISO seed file, containing the meta-data and user-data
.PARAMETER Credential
The credentials for a user in the VM's guest OS
.PARAMETER Photon
Switch to indicate a Photon is deployed. This will add an
additional step to reset the root password before starting
the actual cloud-init based deployment
.EXAMPLE
$sCloudInitVM = @{
OvaFile = '.\bionic-server-cloudimg-amd64.ova'
VmName = $vmName
ClusterName = $clusterName
DsName = $dsName
PgName = $pgName
CloudConfig = '.\user-data.yaml'
Credential = $cred
}
Install-CloudInitVM @sCloudInitVM
.EXAMPLE
$sCloudInitVM = @{
OvaFile = '.\photon-hw13_uefi-3.0-9355405.ova'
VmName = $vmName
ClusterName = $clusterName
DsName = $dsName
PgName = $pgName
CloudConfigISO = '.\seed.iso'
Credential = $cred
Photon = $true
}
Install-CloudInitVM @sCloudInitVM
#>
[cmdletbinding()]
param(
[string]$OvaFile,
[string]$VmName,
[string]$ClusterName,
[string]$DsName,
[string]$PgName,
[Parameter(ParameterSetName = 'YAML')]
[string]$CloudConfig,
[Parameter(ParameterSetName = 'SeedISO')]
[string]$CloudConfigISO,
[PSCredential[]]$Credential,
[Switch]$Photon
)
$waitJob = (Get-Command -Name .\Wait-Job.ps1 ).ScriptBlock
if ($PSCmdlet.ParameterSetName -eq 'YAML')
{
$userData = Get-Content -Path $CloudConfig -Raw
}
Write-Verbose "$(Get-Date -Format 'HH:mm:ss.fff') - Starting deployment of $vmName"
if ($Photon)
{
Write-Verbose "$(Get-Date -Format 'HH:mm:ss.fff') - This is a Photon deployment"
}
$start = Get-Date
$vm = Get-VM -Name $vmName -ErrorAction SilentlyContinue -Verbose:$false
if ($vm)
{
Write-Verbose "$(Get-Date -Format 'HH:mm:ss.fff') - Cleaning up"
if ($vm.PowerState -eq 'PoweredOn')
{
Stop-VM -VM $vm -Confirm:$false -Verbose:$false | Out-Null
}
Remove-VM -VM $vm -DeletePermanently -Confirm:$false -Verbose:$false
}
$ovfProp = Get-OvfConfiguration -Ovf $ovaFile -Verbose:$false
if (-not $Photon)
{
$ovfProp.Common.user_data.Value = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($userData))
$ovfProp.NetworkMapping.VM_Network.Value = $pgName
}
else
{
$ovfProp.NetworkMapping.None.Value = $pgName
}
$sApp = @{
Source = $ovaFile
Name = $vmName
Datastore = Get-Datastore -Name $dsName -Verbose:$false
DiskStorageFormat = 'Thin'
VMHost = Get-Cluster -Name $clusterName -Verbose:$false | Get-VMHost -Verbose:$false | Get-Random
OvfConfiguration = $ovfProp
Verbose = $false
}
Write-Verbose "$(Get-Date -Format 'HH:mm:ss.fff') - Importing OVA"
$vm = Import-VApp @sApp
Write-Verbose "$(Get-Date -Format 'HH:mm:ss.fff') - Starting the VM"
Start-VM -VM $vm -Confirm:$false -RunAsync -Verbose:$false | Out-Null
if ($Photon)
{
while (-not $vm.ExtensionData.Guest.GuestOperationsReady)
{
Start-Sleep 2
$vm.ExtensionData.UpdateViewData('Guest')
}
Write-Verbose "$(Get-Date -Format 'HH:mm:ss.fff') - Resetting password"
$PswdIsReset = $false
while (-not $PswdIsReset)
{
Set-VMKeystrokes -VMName $VM.Name -StringInput 'root' -ReturnCarriage $true | Out-Null
Start-Sleep 1
Set-VMKeystrokes -VMName $VM.Name -StringInput 'changeme' -ReturnCarriage $true | Out-Null
Start-Sleep 1
Set-VMKeystrokes -VMName $VM.Name -StringInput 'changeme' -ReturnCarriage $true | Out-Null
Start-Sleep 1
Set-VMKeystrokes -VMName $VM.Name -StringInput 'Welcome2019!' -ReturnCarriage $true | Out-Null
Start-Sleep 1
Set-VMKeystrokes -VMName $VM.Name -StringInput 'Welcome2019!' -ReturnCarriage $true | Out-Null
Start-Sleep 1
Set-VMKeystrokes -VMName $VM.Name -StringInput "exit" -ReturnCarriage $true | Out-Null
Write-Verbose "$(Get-Date -Format 'HH:mm:ss.fff') - Testing new password"
$sInvoke = @{
VM = $vm
ScriptType = 'bash'
ScriptText = 'whoami'
GuestUser = $Credential.GetNetworkCredential().UserName
GuestPassword = $Credential.GetNetworkCredential().Password
Verbose = $false
}
try
{
Invoke-VMScript @sInvoke -ErrorAction Stop | Out-Null
$PswdIsReset = $true
}
catch [VMware.VimAutomation.ViCore.Types.V1.ErrorHandling.InvalidGuestLogin]
{
Start-Sleep -Seconds 1
}
catch
{
Write-Error "Exception $($error[0].Exception.GetType().FullName)"
return
}
}
Write-Verbose "$(Get-Date -Format 'HH:mm:ss.fff') - Shutdown VM"
Shutdown-VMGuest -VM $vm -Confirm:$false -Verbose:$false | Out-Null
while ($vm.ExtensionData.Runtime.PowerState -ne 'poweredOff')
{
Start-Sleep 2
$vm.ExtensionData.UpdateViewData('Runtime.PowerState')
}
Write-Verbose "$(Get-Date -Format 'HH:mm:ss.fff') - Attaching seed ISO"
$isoName = Split-Path -Path $CloudConfigISO -Leaf
$isoFile = "DS:\$($vm.ExtensionData.Summary.Config.VmPathName.Split(' ')[1].Split('/')[0])\$($isoName)"
Get-PSDrive -Name DS -ErrorAction SilentlyContinue -Verbose:$false | Remove-PSDrive -Confirm:$false -Verbose:$false
New-PSDrive -Location (Get-Datastore -VM $vm) -Name DS -PSProvider VimDatastore -Root '\' -Verbose:$false | Out-Null
Get-Item -Path $CloudConfigISO |
Copy-DatastoreItem -Destination $isoFile -Force -Confirm:$false -Verbose:$false
$isoDSPath = $vm.ExtensionData.Summary.Config.VmPathName.Replace("$vmName.vmx", $isoName)
Get-CDDrive -VM $vm -Verbose:$false | Set-CDDrive -IsoPath $isoDSPath -StartConnected:$true -Confirm:$false -Verbose:$false | Out-Null
Write-Verbose "$(Get-Date -Format 'HH:mm:ss.fff') - Starting the VM"
Start-VM -VM $vm -Confirm:$false -Verbose:$false | Out-Null
}
Write-Verbose "$(Get-Date -Format 'HH:mm:ss.fff') - Waiting for cloud-init to finish"
$User = $Credential.GetNetworkCredential().UserName
$Password = $Credential.GetNetworkCredential().Password
$sJob = @{
Name = 'WaitForCloudInit'
ScriptBlock = $waitJob
ArgumentList = $vm.Name, $User, $Password, $global:DefaultVIServer.Name, $global:DefaultVIServer.SessionId
}
Start-Job @sJob | Receive-Job -Wait
if ($Photon)
{
Write-Verbose "$(Get-Date -Format 'HH:mm:ss.fff') - Restart OS"
$vmGuest = Stop-VMGuest -VM $vm -Confirm:$false -Verbose:$false
while ($vmGuest.VM.ExtensionData.Runtime.PowerState -ne 'poweredOff')
{
Start-Sleep 2
$vmGuest.VM.ExtensionData.UpdateViewData('Runtime.PowerState')
}
$vm = Start-VM -VM $vmName -Confirm:$false -Verbose:$false
while ($vm.PowerState -ne 'PoweredOn')
{
Start-Sleep 2
$vm = Get-VM -Name $VmName -Verbose:$false
}
while (-not $vm.ExtensionData.Guest.GuestOperationsReady)
{
Write-Verbose "$(Get-Date -Format 'HH:mm:ss.fff') - Waiting for GuestOperations ready"
Start-Sleep 2
$vm.ExtensionData.UpdateViewData('Guest')
}
Write-Verbose "$(Get-Date -Format 'HH:mm:ss.fff') - Eject CDROM"
$sInvoke = @{
VM = $vm
ScriptType = 'bash'
ScriptText = 'eject cdrom'
GuestUser = $Credential.GetNetworkCredential().UserName
GuestPassword = $Credential.GetNetworkCredential().Password
Verbose = $false
}
Invoke-VMScript @sInvoke | Out-Null
Get-CDDrive -VM $vm -Verbose:$false | Set-CDDrive -NoMedia -Confirm:$false -Verbose:$false | Out-Null
}
Write-Verbose "$(Get-Date -Format 'HH:mm:ss.fff') - Deployment complete"
Write-Verbose "`nDeployment took $([math]::Round((New-TimeSpan -Start $start -End (Get-Date)).TotalSeconds,0)) seconds"
}
Annotations
The v1.0 version of this function has already been annotated in Part 1 of this series. Those annotations will not be repeated here.
Line 68-71: To distinguish between the way the user-data is passed, the function now has two mutually exclusive parameters, CloudConfig and CloudConfigIso.
Line 73: Since Photon requires some special actions, the function now has a switch, obviously named Photon, that specifies if the cloud-init deployment is intended for a Photon instance or not.
Line 77-80: When the user-data is provided as a YAML file, the function reads the file.
Line 83-86: A small reminder in the verbose output that the function will handle this as a Photon deployment.
Line 102-110: The Ubuntu and the Photon OVA files have different OVF properties. This If-Then-Else handles the differences.
Line 126-199: This part of the function handles most of the peculiarities of a Photon deployment.
Line 136-175: This part handles the reset of the root password. As mentioned earlier, it uses William’s Set-VMKeystrokes function. I experienced intermittent race conditions in sending the keystrokes. Hence the loop with the retries. As a test, to check if the root password was reset, the command ‘whoami‘ is executed trough Invoke-VMScript on the instance.
Line 179-184: Before attaching the seed ISO, the Photon OS is shutdown gracefully. In a While-loop the script test until the instance is actually powered off.
Line 188-195: The seed ISO is copied to the VM’s folder, and then attached.
Line 198: The VM is started with the seed ISO attached. As a result, cloud-init will now use the YAML files it finds on the CD drive as datasource.
Line 216-221: Since the cloud-init implementation on Photon is not configured to use the power-state module, the test we used on the Ubuntu instance, can not be used for Photon. As a bypass, the function stops/starts the guest OS on the instance.
Line 235-244: The function uses the stop/start sequence to disconnect the seed ISO. To avoid having a question on the VM, the function first runs the ‘eject cdrom‘ command inside the instance.
The Seed ISO
As mentioned earlier, I prefer to use a seed ISO file to solve the issue of not having an OVF property for the user-data.
To create this seed ISO file, we need a Linux station that has the ‘genisoimage‘ command available. In the following sample script, I use an Ubuntu station just for that purpose.
$workVM = 'UbuntuPS'
$isoName = 'genisoimage-ps.iso'
$isoVolId = 'cidata'
$isoFiles = @{
'meta-data' = 'meta-data.yaml'
'user-data' = 'user-data.yaml'
}
$code1 = @'
sudo apt-get install genisoimage -y > null
genisoimage -output $isoName -volid $isoVolId -joliet -rock $($isoFiles.Keys -join ' ')
'@
$workCred = Get-VICredentialStoreItem -Host $workVM
$isoFiles.GetEnumerator() | ForEach-Object -Process {
Copy-Item -Path $_.Value -Destination $_.Name
}
$sInvoke = @{
VM = $workVM
ScriptType = 'Bash'
ScriptText = $ExecutionContext.InvokeCommand.ExpandString($code1)
GuestUser = $workCred.User
GuestPassword = ConvertTo-SecureString -String $workCred.Password -AsPlainText -Force
GuestOSType = 'Linux'
Verbose = $true
NoIPinCert = $true
InFile = $isoFiles.Keys
OutFile = $isoName
}
Invoke-VMScriptPlus @sInvoke
Remove-Item -Path @($isoFiles.Keys) -Confirm:$false
Move-Item -Path $isoName -Destination "..\$isoName" -Force -Confirm:$false
Annotations
Line 4-7, 16-18: The script converts (copies) the provided YAML files to standard named YAML files. This allows the script to be used with different versions of the data and only needs a change in the $isoFiles hash table.
Line 32: This script uses the latest version of my Invoke-VMScriptPlus function. The reason is twofold: the bash script is multi-line and the script used the InFile/OutFile parameters to copy the YAML files to the Linux box and the ISO file back to the calling station.
User-Data
The user-data that is used for a Photon based instance is, for now, rather simple and straightforward.
Note the installation of the tdnf PowerShell package (as mentioned earlier in this post) in the runcmd section.
#cloud-config
fqdn: PhotonPS.local.lab
timezone: Europe/Brussels
hostname: PhotonPS
write_files:
- path: /etc/systemd/network/10-static.network
permissions: 0644
content: |
[Match]
Name=eth0
[Network]
Address=192.168.10.81/24
Gateway=192.168.10.1
DNS=192.168.10.2 192.168.10.3
DHCP=no
Domains=local.lab
NTP=192.168.10.2 192.168.10.3
LinkLocalAddressing=no
LLDP=true
- path: /etc/systemd/network/99-dhcp-en.network
permissions: 0644
content: |
[Match]
Name=e*
[Network]
DHCP=no
- path: /etc/gnutls/default-priorities
content: |
SYSTEM=NONE:!VERS-SSL3.0:!VERS-TLS1.0:+VERS-TLS1.1:+VERS-TLS1.2:+AES-128-CBC:+RSA:+SHA1:+COMP-NULL
runcmd:
- tdnf distro-sync
- tdnf update -y
- tdnf install bindutils -y
- tdnf install powershell -y
- tdnf install gnuTLS -y
- touch /etc/gnutls/default-priorities
package_upgrade: true
package_reboot_if_required: true
Meta-data
If you plan on deploying multiple instance, just make sure that the instance-id has a unique value.
instance-id: iid-local01 local-hostname: photonps
Sample Run
For a Photon instance the function is called like this.
$vmName = 'PhotonPS'
# Get credential for logging on to the guest OS
$viCred = Get-VICredentialStoreItem -Host $vmName
$secPassword = ConvertTo-SecureString -String $viCred.Password -AsPlainText -Force
$cred = [Management.Automation.PSCredential]::new($viCred.User, $secPassword)
$sCloudInitVM = @{
OvaFile = 'D:\Repository\VMware\Photon\photon-hw13_uefi-3.0-9355405.ova'
VmName = $vmName
ClusterName = 'cluster'
DsName = 'vsanDatastore'
PgName = 'vdPg1'
CloudConfigISO = '..\genisoimage-ps.iso'
Photon = $true
Credential = $cred
Verbose = $true
}
Install-CloudInitVM @sCloudInitVM
The result of this, since we used the Verbose switch, looks like this.

Again, don’t give too much importance to the total execution time. This was a run in my lab environment, which has limited resources.
On a side-note, notice how the PowerCLI cmdlets used in the function all show this, imho, annoying ‘Finished execution‘ message.
I would love to be able to suppress these verbose messages from the PowerCLI cmdlets. That is why I launched PowerCLI Idea #225 “Get rid of the over-eager verbosity on PowerCLI cmdlets“.
Enjoy!