We can install a guest operating system in a VM and control guest operating system customisation for VMs by either running commands directly or, if deploying to vSphere-based cloud platforms, through customisation specifications.

In Aria Automation these commands are defined in a so-called cloudConfig resource property in the cloud template code which holds the commands that should be run. For customisation specifications on the other hand, a property in the cloud template code references a vSphere customisation specification by name.

In this post I’m going to show how to leverage Aria Automation cloudConfig to customise Ubuntu 22 and Windows Server 2022 VMs. Such customisation could include:

  • Set the hostname
  • Set the timezone
  • Configure the network adapter
  • Partition, format, mount hard disks
  • Run arbitrary commands (e.g. join a domain)

Aria Automation cloudConfig in a nutshell

The above mentioned guest customisation method was not designed to provide the full breadth of initialisation customisation that may be required. It is primarily used to configure networking aspects of a VM.

In order to fully customise the VM, we can leverage the Automation Assembler cloudConfig resource property in conjunction with Cloud-init.

Cloud-init is the de-facto industry standard method for cross-platform cloud instance initialisation. It is supported by all major public cloud providers, provisioning systems for private cloud infrastructure, and bare-metal installation. It enables the automated provisioning of an operating system image into a fully running state, complete with required access and applications. Typical tasks handled by cloud-init include networking and storage initialisation, optional package installation and configuration, user account creation and security key provisioning for remote access.

Cloud-init is a daemon which consists of a set of Python scripts and tools to initialize cloud instances of Linux machines.

For Windows there exists an equivalent with Cloudbase-init. It has a different syntax but does essentially the same as Cloud-init.

CloudConfig is VMware’s implementation of Cloud-init. The Automation Assembler cloudConfig resource property uses the declarative YAML syntax to instruct Cloud-init with the necessary tasks to customise the guest OS of the deployed instance.

With Automation Assembler cloudConfig the VM guest OS initialisation is roughly composed of the following steps:

  • The Automation Assembler cloudConfig property is picked up by the Aria Automation Provisioning & Lifecycle Service, which performs some pre-processing, e.g. expands input properties, resource properties, and property groups when defined.
  • The user data is sent to the so-called Photon endpoint adapters of the Provisioning Service.
  • These adapters (e.g vSphere or AWS) use the corresponding APIs to initiate the deployment of a VM from the OS disk image with the provided user data (Cloud-init/Cloudbase-init must be pre-installed in the image template and set to start automatically during the boot process).
  • Automation Assembler can leverage the so-called ISO transport method of the OVF datasource to push the Cloud-init user data to the Cloud-init service by mounting an ISO image wich contains this user data in an XML formatted file. The user data itself is stored as a Base64 encoded string in this file, as well as in the VM’s vApp properties.
    Note, with the VMware Guestinfo datasource there exists also another possibility to push user data to Cloud-init, but this is out of scope for this blog post.
  • The VM boots and uses the datasource to access the user data.

When deploying to vSphere, it is recommended to not combine embedded cloudConfig commands and customisation specification initialisation. They aren’t formally compatible and might produce inconsistent or unwanted results when used together.

Ubuntu Linux deployment and customisation on vSphere

In the first part of this session, we’re going to have a closer look an customising an Ubuntu Linux deployment.

Preparing the Ubuntu Linux VM vSphere template

To create a vSphere template that supports Cloud-init, we have to perform the following tasks:

  1. Deploy a basic Ubuntu 22 VM as the template (VMware tools must be installed)
  2. Make sure to set the CD-ROM on the VM template to passthrough mode
  3. Install Cloud-init on it using sudo apt install cloud-init
  4. Modify /etc/cloud/cloud.cfg as follows
    • Disable traditional VMware Linux guest customisation via customisation specification by setting disable_vmware_customization to true
    • Define the OVF transport as the only datasource
    • Disable Cloud-init network configuration (we’ll do this via Cloud-init’s runcmd module instead)
  5. End the configuration by executing the cloud-init clean command
  6. Shut down the VM and convert it into a template

Here is the relevant configuration in /etc/cloud/cloud.cfg:

disable_vmware_customization: true
datasource_list: [ OVF ]
datasource:
  OVF:
    allow_raw_data: true
vmware_cust_file_max_wait: 25

network:
  config: disabled

After the vSphere template has been created, we have to create the image mapping in Automation Assembler. For this session, we call it “Ubuntu22“.

Creating the Automation Assembler template for Ubuntu

We create a Cloud template which is capable of deploying Ubuntu VMs with the following guest OS customisations:

  • Set the hostname
  • Configure the network adapter
  • Partition, format, mount up to 4 additional hard disks
  • Set the root password as specified by the requesting user

The Cloud template looks as follows:

formatVersion: 1
inputs:
  osFlavor:
    type: string
    title: OS flavor
    oneOf:
      - title: Ubuntu 22.04 LTS
        const: Ubuntu22
    default: Ubuntu22
  vCPU:
    type: integer
    title: Number of CPUs
    enum:
      - 2
      - 4
      - 8
      - 16
    default: 2
  vRAM:
    type: integer
    title: Memory in MB
    enum:
      - 1024
      - 2048
      - 4096
      - 8192
      - 16384
      - 32768
    default: 2048
  additionalDisks:
    type: array
    title: Additional Disks
    minItems: 0
    maxItems: 4
    items:
      type: object
      properties:
        diskUnit:
          type: integer
          title: Disk Number
          minimum: 1
          maximum: 4
        mountPoint:
          type: string
          title: Mountpoint
          minLength: 2
          maxLength: 64
          pattern: /[a-z0-9A-Z-_]+
        diskSize:
          type: number
          title: Size (GB)
          minimum: 1
          maximum: 500
  rootPassword:
    type: string
    title: Root Password
    minLength: 8
    maxLength: 64
    encrypted: true
resources:
  Cloud_Net_1:
    type: Cloud.Network
    properties:
      networkType: existing
      constraints:
        - tag: net:dev
  Disks:
    type: Cloud.vSphere.Disk
    allocatePerInstance: true
    properties:
      capacityGb: ${input.additionalDisks[count.index].diskSize}
      SCSIController: SCSI_Controller_0
      unitNumber: ${input.additionalDisks[count.index].diskUnit}
      count: ${length(input.additionalDisks)}
  Cloud_VM_1:
    type: Cloud.vSphere.Machine
    properties:
      image: ${input.osFlavor}
      cpuCount: ${input.vCPU}
      totalMemoryMB: ${input.vRAM}
      folderName: vRA deployed VMs
      storage:
        constraints:
          - tag: storage:silver
      networks:
        - network: ${resource.Cloud_Net_1.id}
          assignment: static
      attachedDisks: ${map_to_object(resource.Disks[*].id, "source")}
      constraints:
        - tag: cz:vsphere
      customizeGuestOs: false
      cloudConfig: |
        #cloud-config

Note, that we’re referencing to a Cloud network “Cloud_Net_1” with the constraint “net:dev“, and to a VM with constraint “cz:vsphere” and storage constraint “storage:silver“.

It is important to set the customizeGuestOs property to false.

Now, on to the cloudConfig property itself.

A pipe character (|) must occur after our cloudConfig directive. All commands after the pipe character are sent to the Cloud-init service in the guest OS after the image is deployed. They are run only on first boot.

The cloudConfig user data section will look as follows:

        #cloud-config
        write_files:
          - path: /etc/netplan/99-installer-config.yaml
            content: |
              network:
                version: 2
                renderer: networkd
                ethernets:
                  ens192:
                    addresses:
                      - ${self.networks[0].address}/${resource.Cloud_Net_1.prefixLength}
                    gateway4: ${resource.Cloud_Net_1.gateway}
                    nameservers:
                      search: ${resource.Cloud_Net_1.dnsSearchDomains}
                      addresses: ${resource.Cloud_Net_1.dns}
        ssh_pwauth: true
        disable_root: false
        chpasswd:
          list: |
            root:${input.rootPassword}
            ubuntu:${input.rootPassword}
          expire: false
        bootcmd:
          - fdisk -l /dev/sdb; [ $? -eq 0 ] && printf "o\nn\np\n1\n\n\nw\n" | fdisk /dev/sdb
          - fdisk -l /dev/sdc; [ $? -eq 0 ] && printf "o\nn\np\n1\n\n\nw\n" | fdisk /dev/sdc
          - fdisk -l /dev/sdd; [ $? -eq 0 ] && printf "o\nn\np\n1\n\n\nw\n" | fdisk /dev/sdd
          - fdisk -l /dev/sde; [ $? -eq 0 ] && printf "o\nn\np\n1\n\n\nw\n" | fdisk /dev/sde
        fs_setup:
          - label: ${to_upper(replace(input.additionalDisks[0].mountPoint,"/",""))}
            device: /dev/sdb
            partition: 1
            filesystem: xfs
            overwrite: true
          - label: ${to_upper(replace(input.additionalDisks[1].mountPoint,"/",""))}
            device: /dev/sdc
            partition: 1
            filesystem: xfs
            overwrite: true
          - label: ${to_upper(replace(input.additionalDisks[2].mountPoint,"/",""))}
            device: /dev/sdd
            partition: 1
            filesystem: xfs
            overwrite: true
          - label: ${to_upper(replace(input.additionalDisks[3].mountPoint,"/",""))}
            device: /dev/sde
            partition: 1
            filesystem: xfs
            overwrite: true
        runcmd:
          - mountpointb="${input.additionalDisks[0].mountPoint}"; if [ $mountpointb != "null" ] && [ -z "$(grep -w $mountpointb /etc/fstab)" ] ; then echo "/dev/sdb1   $mountpointb   xfs  defaults   0   2" >> /etc/fstab ; fi
          - mountpointc="${input.additionalDisks[1].mountPoint}"; if [ $mountpointc != "null" ] && [ -z "$(grep -w $mountpointc /etc/fstab)" ] ; then echo "/dev/sdc1   $mountpointc   xfs  defaults   0   2" >> /etc/fstab ; fi
          - mountpointd="${input.additionalDisks[2].mountPoint}"; if [ $mountpointd != "null" ] && [ -z "$(grep -w $mountpointd /etc/fstab)" ] ; then echo "/dev/sdd1   $mountpointd   xfs  defaults   0   2" >> /etc/fstab ; fi
          - mountpointe="${input.additionalDisks[3].mountPoint}"; if [ $mountpointe != "null" ] && [ -z "$(grep -w $mountpointe /etc/fstab)" ] ; then echo "/dev/sde1   $mountpointe   xfs  defaults   0   2" >> /etc/fstab ; fi
          - mountpointb="${input.additionalDisks[0].mountPoint}"; if [ $mountpointb != "null" ] && [ ! -d $mountpointb ] ; then  mkdir -p $mountpointb ; fi
          - mountpointc="${input.additionalDisks[1].mountPoint}"; if [ $mountpointc != "null" ] && [ ! -d $mountpointc ] ; then  mkdir -p $mountpointc ; fi
          - mountpointd="${input.additionalDisks[2].mountPoint}"; if [ $mountpointd != "null" ] && [ ! -d $mountpointd ] ; then  mkdir -p $mountpointd ; fi
          - mountpointe="${input.additionalDisks[3].mountPoint}"; if [ $mountpointe != "null" ] && [ ! -d $mountpointe ] ; then  mkdir -p $mountpointe ; fi
          - mount -a
          - netplan apply
          - hostnamectl set-hostname --static ${self.resourceName}
          - eject /dev/cdrom
          - touch /etc/cloud/cloud-init.disabled

In the cloudConfig section we’re setting the root account password as defined by the user. Using the write_files module, we create a netplan configuration file called 99-installer-config.yaml, which will be used at a later stage. In the bootcmd module, we create the relevant partition tables depending on the user’s input of selected hard disks. The fs_setup module formats these partitions with the XFS filesystem. Afterwards we run commands in the runcmd module. Here we permanently mount the filesystems, configure networking and set the hostname. Finally we create a file called /etc/cloud/cloud-init.disable, which prevents the Cloud-init service from being start after a reboot of the system.

Windows Server deployment and customisation on vSphere

In the second part of this session, we’re going to have a closer look an customizing a Windows Server 2022 deployment.

Preparing the Windows Server VM vSphere template

The Windows VM image preparation process is similar to the Linux preparation process.

To create a vSphere template that supports Cloudbase-init, we have to

  1. Deploy a basic Windows Server 2022 VM as the template (VMware tools must be installed)
  2. Make sure to set the CD-ROM on the VM template to passthrough mode
  3. Download the Cloudbase-init installer from https://cloudbase.it/downloads/CloudbaseInitSetup_Stable_x64.msi
  4. Execute the Cloudbase-init GUI installer and run through the wizard
    • We’re selecting the following configuration options
      • Username: Administrator
      • Select the checkbox “Use metadata password
      • User’s local groups: Administrators
      • Select the checkbox “Run Cloudbase-init service as LocalSystem
    • Do not close the final page of the setup wizard! With the Completed page of the setup wizard still open, use Windows Explorer to navigate to the Cloudbase-Init installation path, and edit the following files in a text editor:
      • conf\cloudbase-init-unattend.conf
        Here we set in line 19 metadata_services to cloudbaseinit.metadata.services.ovfservice.OvfService which enables the relevant metadata service.
      • conf\cloudbase-init.conf
        Here we set in line 20 metadata_services to cloudbaseinit.metadata.services.ovfservice.OvfService which enables the relevant metadata service.
    • On the Completed page of the setup wizard, select the options to run Sysprep and to shut down after Sysprep, then click Finish
  5. Shut down the VM and convert it into a template

Here is the content of the cloudbase-init-unattend.conf:

[DEFAULT]
username=Administrator
groups=Administrators
inject_user_password=true
config_drive_raw_hhd=true
config_drive_cdrom=true
config_drive_vfat=true
bsdtar_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\bin\bsdtar.exe
mtools_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\bin\
verbose=true
debug=true
logdir=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\log\
logfile=cloudbase-init-unattend.log
default_log_levels=comtypes=INFO,suds=INFO,iso8601=WARN,requests=WARN
logging_serial_port_settings=
mtu_use_dhcp_config=true
ntp_use_dhcp_config=true
local_scripts_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\LocalScripts\
metadata_services=cloudbaseinit.metadata.services.ovfservice.OvfService
plugins=cloudbaseinit.plugins.common.mtu.MTUPlugin,cloudbaseinit.plugins.common.sethostname.SetHostNamePlugin,cloudbaseinit.plugins.windows.extendvolumes.ExtendVolumesPlugin
allow_reboot=false
stop_service_on_exit=false
check_latest_version=false

Here is the content of the cloudbase-init.conf:

[DEFAULT]
username=Administrator
groups=Administrators
inject_user_password=true
first_logon_behaviour=no
config_drive_raw_hhd=true
config_drive_cdrom=true
config_drive_vfat=true
bsdtar_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\bin\bsdtar.exe
mtools_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\bin\
verbose=true
debug=true
logdir=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\log\
logfile=cloudbase-init.log
default_log_levels=comtypes=INFO,suds=INFO,iso8601=WARN,requests=WARN
logging_serial_port_settings=
mtu_use_dhcp_config=true
ntp_use_dhcp_config=true
local_scripts_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\LocalScripts\
metadata_services=cloudbaseinit.metadata.services.ovfservice.OvfService
plugins=cloudbaseinit.plugins.common.userdata.UserDataPlugin

After the vSphere template has been created, we have to create the image mapping in Automation Assembler. For this session, we call it “WinSrv2022“.

Creating the Automation Assembler template for Windows Server

We create a Cloud template which is capable of deploying Windows Server 2022 VMs with the following guest OS customisations:

  • Set the hostname
  • Configure the network adapter
  • Partition, format, mount up to 4 additional hard disks
  • Set the root password as specified by the requesting user

The main part of the cloud template looks quite similar to our Linux cloud template:

formatVersion: 1
inputs:
  osFlavor:
    type: string
    title: OS flavor
    oneOf:
      - title: Windows Server 2022
        const: WinSrv2022
    default: WinSrv2022
  vCPU:
    type: integer
    title: Number of CPUs
    enum:
      - 2
      - 4
      - 8
      - 16
    default: 2
  vRAM:
    type: integer
    title: Memory in MB
    enum:
      - 1024
      - 2048
      - 4096
      - 8192
      - 16384
      - 32768
    default: 4096
  additionalDisks:
    type: array
    title: Additional Disks
    minItems: 0
    maxItems: 4
    items:
      type: object
      properties:
        diskUnit:
          type: integer
          title: Disk Number
          minimum: 1
          maximum: 4
        diskSize:
          type: number
          title: Size (GB)
          minimum: 1
          maximum: 500
  rootPassword:
    type: string
    title: Administrator Password
    minLength: 8
    maxLength: 64
    encrypted: true
resources:
  Cloud_Net_1:
    type: Cloud.Network
    properties:
      networkType: existing
      constraints:
        - tag: net:dev
  Disks:
    type: Cloud.vSphere.Disk
    allocatePerInstance: true
    properties:
      capacityGb: ${input.additionalDisks[count.index].diskSize}
      SCSIController: SCSI_Controller_0
      unitNumber: ${input.additionalDisks[count.index].diskUnit}
      count: ${length(input.additionalDisks)}
  Cloud_VM_1:
    type: Cloud.vSphere.Machine
    properties:
      image: ${input.osFlavor}
      cpuCount: ${input.vCPU}
      totalMemoryMB: ${input.vRAM}
      folderName: vRA deployed VMs
      storage:
        constraints:
          - tag: storage:silver
      networks:
        - network: ${resource.Cloud_Net_1.id}
          assignment: static
      attachedDisks: ${map_to_object(resource.Disks[*].id, "source")}
      constraints:
        - tag: cz:vsphere
      customizeGuestOs: false
      remoteAccess:
        authentication: usernamePassword
        username: Administrator
        password: ${input.rootPassword}
      cloudConfig: |

Again, we’re referencing to a Cloud network “Cloud_Net_1” with the constraint “net:dev“, and to a VM with constraint “cz:vsphere” and storage constraint “storage:silver“.

It is again important to set the customizeGuestOs property to false.

Now to the cloudConfig property itself.

Again the pipe character (|) must occur after the cloudConfig directive. All commands after the pipe character are sent to the Cloudbase-init service in the guest OS after the image is deployed. They are run only on first boot.

The cloudConfig user data section will look as follows:

      cloudConfig: |
        Content-Type: multipart/mixed; boundary="==NewPart=="
        MIME-Version: 1.0

        --==NewPart==
        Content-Type: text/cloud-config; charset="us-ascii"
        MIME-Version: 1.0
        Content-Transfer-Encoding: 7bit
        Content-Disposition: attachment; filename="cloud-config"

        set_hostname: ${self.resourceName}
         
        --==NewPart==
        Content-Type: text/x-shellscript; charset="us-ascii"
        MIME-Version: 1.0
        Content-Transfer-Encoding: 7bit
        Content-Disposition: attachment; filename="vm-init.ps1"

        #ps1_sysnative
        $adapter = Get-NetAdapter
        $adapter | Remove-NetIpAddress -Confirm:$false
        $adapter | Remove-NetRoute -Confirm:$false
        $adapter | New-NetIpAddress -IpAddress ${self.networks[0].address} -PrefixLength ${resource.Cloud_Net_1.prefixLength} -DefaultGateway ${resource.Cloud_Net_1.gateway}
        $adapter | Set-DnsClientServerAddress -ServerAddresses ${replace(replace(to_string(resource.Cloud_Net_1.dns[0]),"]",")"),"[","(")},${replace(replace(to_string(resource.Cloud_Net_1.dns[1]),"]",")"),"[","(")}
        $arrNewDisks = Get-Disk | Where-Object{$_.OperationalStatus -eq "Offline" -and $_.PartitionStyle -eq "RAW"}
        Foreach($objDisk In $arrNewDisks){
          $thisDiskNo = $objDisk.Number
          $objPartition = Get-Disk -Number $thisDiskNo | Initialize-Disk -PartitionStyle "GPT" -PassThru | New-Partition -AssignDriveLetter -UseMaximumSize
          Format-Volume -DriveLetter $objPartition.DriveLetter -FileSystem "NTFS" -NewFileSystemLabel "Disk $thisDiskNo" -Confirm:$false
        }
        $drives = Get-WmiObject Win32_Volume -Filter "DriveType=5"
        $drives | ForEach-Object { (New-Object -ComObject Shell.Application).Namespace(17).ParseName($_.Name).InvokeVerb("Eject") } -ErrorAction SilentlyContinue

The cloudConfig uses MIME multi-part user data (see https://cloudbase-init.readthedocs.io/en/latest/userdata.html#multi-part-content).

A multipart/mixed MIME message is composed of a mix of different data types. Each body part is delineated by a boundary. The boundary parameter is a text string used to delineate one part of the message body from another, in our example “--==NewPart==“.

With the MIME multi-part format, the user data content will be handled based on the content type. The content type “text/cloud-config” will run cloud-config, whereas the content type “text/x-shellscript” executes any script, including Batch and PowerShell scripts. In the example cloud template, we’re executing a script called vm-init.ps1, which will contain the code below (until the next boundary). Using the sysnative parameter ps1_sysnative Cloudbase-init will execute the script using the system native Powershell executable.

We’re then setting configuring the network adapter. Afterwards we’re partitioning and formatting the user defined hard disks and assigning a drive letter to them. Finally we’re ejecting the transport ISO image from the CD drive.

Conclusion

Aria Automation Assembler cloudConfig provides a convenient, standardized way to customise the guest OS of VMs.

One thing to note with the used OVF datasource ISO transport is, that although we’re ejecting the CD at the end of our user data, the transport ISO will be automatically connected to the VM by Aria Automation again, every time the VM has been shut down and then started again.

Another thing to consider is the fact, that the cloudConfig user data is saved more or less in clear text in the VM’s vApp properties (as a Base64 representation), which, depending on your security requirements, might make it not suitable to pass sensitive data (like the root password in the example cloud templates in this blog post). Also, Cloud-init and Cloudbase-init save those sensitive data in clear text in their log files (Cloud-init saves this also in its instance data). To work around this, I recommend to not pass sensitive data via cloudConfig, but use the extensibility capabilities of Aria Automation instead (e.g. custom ABX actions or Orchestrator workflows).

You can find the Cloud template YAML files discussed in this post on my Github vRA repository.