Overview
Hello fine people of the internet! After banging my head against the proverbial wall I've come to share the wisdom leaking out of my head. Wisdom is red right...?
If you're here I assume you, like me, are a Powershell fanboy not content to meekly run Powershell scripts from YAML. No? Perhaps you recognize Python's clunkiness for sysadmin work, and you WANT learn how to use Powershell in it's raw form to manage Linux machines. Maybe you'd like to use this method to write modules in another language entirely, that's entirely doable. Whatever the reason you're here to learn and learning is great.
Now sure, there are some resources out there detailing the use of Powershell with Ansible but those I've come across focus on Windows and, arguably, hide the simplicity of Ansible's inner workings.
Table of Contents
Cloud Init
To start, let's bootstrap Powershell on the server using cloud-init so it's immediately available to us.
During Azure/AWS/etc VM creation, you provide a yaml file like the one below. In it, we add the Microsoft repo and install Powershell. I'm explicitly providing the public key for the Microsoft repo but you can use a key id instead to query keyserver.ubuntu.com. To find the public key id of a gpg file run gpg --show-keys /path/to/foobar.gpg.
If your VM doesn't have an Ansible user already you may be interested in bootstrapping one using cloud-init.
cloud-init.yml
01 ## template: jinja
02 #cloud-config
03
04 package_reboot_if_required: true
05 package_update: true
06 package_upgrade: true
07 packages:
08 - apt-transport-https
09 - ca-certificates
10 - wget
11 - software-properties-common
12 - powershell
13
14 apt:
15 preserve_sources_list: true
16 sources_list: |
17 Types: deb
18 URIs: https://packages.microsoft.com/ubuntu/{{ distro_version }}/prod
19 Suites: $RELEASE
20 Components: main
21 Signed-By: $KEY_FILE
22 sources:
23 microsoft-prod:
24 source: 'deb [arch=amd64,armhf,arm64 signed-by=$KEY_FILE] https://packages.microsoft.com/ubuntu/{{ distro_version }}/prod $RELEASE main'
25 # Convert binary key to ASCII
26 # gpg --keyring /etc/apt/trusted.gpg.d/microsoft-prod.gpg --no-default-keyring --export -a > microsoft.asc
27 key: |
28 -----BEGIN PGP PUBLIC KEY BLOCK-----
29
30 mQENBFYxWIwBCADAKoZhZlJxGNGWzqV+1OG1xiQeoowKhssGAKvd+buXCGISZJwT
31 LXZqIcIiLP7pqdcZWtE9bSc7yBY2MalDp9Liu0KekywQ6VVX1T72NPf5Ev6x6DLV
32 7aVWsCzUAF+eb7DC9fPuFLEdxmOEYoPjzrQ7cCnSV4JQxAqhU4T6OjbvRazGl3ag
33 OeizPXmRljMtUUttHQZnRhtlzkmwIrUivbfFPD+fEoHJ1+uIdfOzZX8/oKHKLe2j
34 H632kvsNzJFlROVvGLYAk2WRcLu+RjjggixhwiB+Mu/A8Tf4V6b+YppS44q8EvVr
35 M+QvY7LNSOffSO6Slsy9oisGTdfE39nC7pVRABEBAAG0N01pY3Jvc29mdCAoUmVs
36 ZWFzZSBzaWduaW5nKSA8Z3Bnc2VjdXJpdHlAbWljcm9zb2Z0LmNvbT6JATUEEwEC
37 AB8FAlYxWIwCGwMGCwkIBwMCBBUCCAMDFgIBAh4BAheAAAoJEOs+lK2+EinPGpsH
38 /32vKy29Hg51H9dfFJMx0/a/F+5vKeCeVqimvyTM04C+XENNuSbYZ3eRPHGHFLqe
39 MNGxsfb7C7ZxEeW7J/vSzRgHxm7ZvESisUYRFq2sgkJ+HFERNrqfci45bdhmrUsy
40 7SWw9ybxdFOkuQoyKD3tBmiGfONQMlBaOMWdAsic965rvJsd5zYaZZFI1UwTkFXV
41 KJt3bp3Ngn1vEYXwijGTa+FXz6GLHueJwF0I7ug34DgUkAFvAs8Hacr2DRYxL5RJ
42 XdNgj4Jd2/g6T9InmWT0hASljur+dJnzNiNCkbn9KbX7J/qK1IbR8y560yRmFsU+
43 NdCFTW7wY0Fb1fWJ+/KTsC4=
44 =J6gs
45 -----END PGP PUBLIC KEY BLOCK-----
In the module below, we're ignoring any existing Ansible powershell helpers that may exist for simplicity's sake. To start, we read an Ansible provided parameter file from arg0 to produce a hashtable.
Important
Crucially, the string WANT_JSON is set somewhere in the module because it tells Ansible to pass us JSON instead of key/value pairs.
Since idempotency is a core principle of Ansible, it's important that our module abides by it. If you're not already familiar, Powershell supports idempotency via WhatIf mode which is controlled using the $WhatIfPreference variable and -WhatIf flag for advanced cmdlets (ones with [CmdletBinding()] above the param block). Below, we set $WhatIfPreference = $Ansible._ansible_check_mode to ensure our code executes in WhatIf mode when the -C or --check flag is provided. Builtin cmdlets like New-Item support WhatIf mode already, and inherit that setting from the caller, so nothing special needs done. Any code that doesn't support or properly implement WhatIf mode needs to be wrapped using ShouldProcess as in if ($PSCmdlet.ShouldProcess("Target", "Perform Task") { ...code... }.
If you need a fresher or refresher on ShouldProcess please consult this excellent guide.
Everything else in the module is standard powershell and ends with us serializing our response as JSON so Ansible can use it to check for changes and present them to the user.
Powershell Script
library/powershell_example.ps1
01 #!/usr/bin/env pwsh
02
03 <#
04 .SYNOPSIS
05 Places a file on the filesystem
06
07 .NOTES
08 Maybe Ansible.ModuleUtils.Legacy module is useful here?
09
10 .LINK
11 https://github.com/ansible/ansible/blob/1ad0c404ef05f6d6a03d59ad25b55860f15d1da0/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.Legacy.psm1#L140
12 #>
13
14 # (Crucial) Indicate that this is a non-native module
15 # https://docs.ansible.com/ansible/2.7/dev_guide/developing_program_flow_modules.html#non-native-want-json-modules
16 # WANT_JSON
17
18 $Ansible = Get-Content $Args[0] | ConvertFrom-Json
19 $WhatIfPreference = $Ansible._ansible_check_mode
20
21 # Result to return to Ansible at end of script
22 $Result = @{
23 path = ''
24 changed = $true
25 original_state = ''
26 state = ''
27 }
28
29 $Path = $Ansible.Path # /etc/testing/foo.txt
30 $Result.Path = $Path
31
32 if (-not (Test-Path $Path)) {
33 New-Item $Path -Force
34 $Result.original_state = 'Absent'
35 $Result.state = 'Present'
36 } else {
37 $Result.Changed = $false
38 $Result.original_state = 'Present'
39 $Result.state = 'Present'
40 }
41
42 return $Result | ConvertTo-Json -Depth 100 -Compress
Important
In our playbook file we set the TERM env to dumb to avoid an issue with Powershell escape codes mucking up the JSON response from our module which Ansible uses to determine whether it succeeded.
We provide the name of the module as a task and provide any properties our module expects. In this case, we just need the path to the file to create.
Playbook
playbooks/powershell_example.yml
01 - name: Test
02 hosts: all
03 become: true
04 environment:
05 # Avoid escape codes breaking output
06 # See https://github.com/ansible/ansible/issues/48881#issuecomment-440481672
07 - TERM: dumb
08 gather_facts: true
09 tasks:
10 - name: Test
11 powershell_example:
12 path: '/etc/testing/foo.txt'
Finally, we test our playbook against a machine, substituting 1.2.3.4 with an IP address or hostname.
01 # Dry run
02 ansible-playbook playbooks/powershell_example.yml -C -i ', 1.2.3.4' -v
03
04 # Real run
05 ansible-playbook playbooks/powershell_example.yml -i ', 1.2.3.4' -v