What is the best practice to run a user-provided .ps1 script as "cloud init script" on a Windows VM module?

I have a virtual machine module that I want to add the option to run a ps1 on startup, just as I would use a bash script as a cloud-init script. I have seen several different ways to do this, but they all have different requirements and do not work for our needs:

  1. Use unattend xml configs to run
  2. Run in-line code within azurerm_virtual_machine_extension
  3. Run ps1 from public repo within azurerm_virtual_machine_extension
  4. Pass in script to azurerm_virtual_machine_extension with filebase64 or base64encode

Now here are my problems with the above solutions:

  1. I want the users of my vm module to only have to provide a valid ps1 file - not 2 additional xml configs which also require username / password for autologon.
  2. I want to pass in a script file, not a line of code. The first script we’re being asked to be able to use is about 50 lines long.
  3. The script(s) to run are not available in public repos, and we do not want to add functionality for the VM to authenticate to the private repo for this purpose.
  4. I can pass in the file to the machine using custom_data = base64encode("${file("./${var.cloudinit_file}")}"), however this results in malformed base64 file.

Here’s the error:

Exception calling "FromBase64String" with "1" argument(s): "The input is not a valid Base-    64 string as it contains a
non-base 64 character, more than two padding characters, or an illegal character among the padding characters. "
At line:1 char:1

Here is the VM portion of my module (not including all the networking and other backend parts as they are not the problem:

resource "azurerm_windows_virtual_machine" "vm" {
  count = var.vm_offer == "WindowsServer" ? 1 : 0

  name                     = "${var.sz_application}-${var.sz_environment}-${local.region-code}"
  resource_group_name      = data.azurerm_resource_group.iac-rg.name
  location                 = var.location
  size                     = var.vm_size
  admin_username           = azurerm_key_vault_secret.username.value
  admin_password           = azurerm_key_vault_secret.password.value
  provision_vm_agent       = "true"
  enable_automatic_updates = "true"
  network_interface_ids = [
    azurerm_network_interface.vm.id,
  ]

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = var.vm_storage_tier
  }

  custom_data = base64encode("${file("./${var.cloudinit_file}")}")

  source_image_reference {
    publisher = var.vm_publisher
    offer     = var.vm_offer
    sku       = var.vm_sku
    version   = var.vm_image_version
  }

  tags = local.tags
}

Other things I have tried:

  1. using data.template_file.cloudinit.rendered
  2. using data.template_cloudinit_config.cloudinit.rendered
  3. using azurerm_virtual_machine_extension with this monstrousity: "commandToExecute": "powershell -command -Command [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${base64encode(data.template_file.cloudinit.rendered)}'))\""

edit: using custom_data = filebase64("./${var.cloudinit_file}") seems to yield a better result … now deploying VM and I’ll check if the c:\AzureData\CustomData.bin is a useable file or not

edit 2: It is still un-decodable… at least using this:

PS C:\AzureData> $data = get-content .\CustomData.bin
PS C:\AzureData>     [System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String($data)) > out.ps1
Exception calling "FromBase64String" with "1" argument(s): "The input is not a valid Base-64 string as it contains a
non-base 64 character, more than two padding characters, or an illegal character among the padding characters. "
At line:1 char:1
+ [System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase6     ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [],     MethodInvocationException
    + FullyQualifiedErrorId : FormatException

So… still stuck

edit 3: Success! But still have a question…

This worked:

in my azurerm_windows_virtual_machine resource, I pass in my custom_data using custom_data = filebase64("./${var.cloudinit_file}")

This copies the file onto the machine as the file C:\AzureData\CustomData.bin

Then, I use the following azurerm_virtual_machine_extension extension:

resource "azurerm_virtual_machine_extension" "cloudinit" {
  name                 = "cloudinit"
  virtual_machine_id = azurerm_windows_virtual_machine.vm[0].id
  publisher            = "Microsoft.Compute"
  type                 = "CustomScriptExtension"
  type_handler_version = "1.10"
  settings = <<SETTINGS
    {
        "commandToExecute": "powershell -ExecutionPolicy unrestricted -NoProfile -NonInteractive -command \"cp c:/azuredata/customdata.bin c:/azuredata/install.ps1; c:/azuredata/install.ps1\""
    }
    SETTINGS
}

My last question is: Is there a better way for the commandToExecute to directly call the CustomData.bin as a .ps1 script without the need to copy the .bin as a .ps1 file?

Edit: Nice! I see your success story edit, i’ll try that!

This guy has a nice write up @cd83. It seems to be the ‘accepted’ answer but its not my favorite. It involves adding the file to azure blob storage and accessing with parameters passed via ‘protected_settings’. I too am looking for a way to just run a local ps1 file on the remote server after spin up without having to use a middleman. It’s easy to do with bash on linux but for some reason they made it more difficult for windows. Also long time no see :upside_down_face:

https://jackstromberg.com/2018/11/using-terraform-with-azure-vm-extensions/

Hey! I specifically wanted to avoid any sort of middle-man system where I have to download the file from anywhere else and wanted to place it directly on the system. Glad to hear it worked :slight_smile: