Ansible – Windows Application Deployment Template

Posted by Lucas Jackson on Friday, May 8, 2020

Ansible

Ansible is a very good configuration management tool and is arguably the most popular today. It is simple to use, powerful and it’s agentless.

Ansible can do a boatload of things, one of which is installing software onto a machine. Since Windows natively doesn’t have a package manager, many people opt to use Chocolatey with Ansible to simplify management of software installations. There is an Ansible module available here for Chocolatey for those whom are interested.

In this post, I will take a different approach to package management by creating a template that can be re-used with some slight tweaks and adjustments. It can be used to install, upgrade and uninstall applications on Windows machines using Ansible.

This template gives you the ability to manage packages without a 3rd party intermediary. With a little bit of patience and testing, you can create a package fairly quickly.

Approach

My preferred approach when using Ansible with software packages is to use Ansible Roles.

As written in the Ansible documentation: “Roles are ways of automatically loading certain vars_files, tasks, and handlers based on a known file structure”.

Example Project File Structure

install-windows-apps.yml
demoapp/
  defaults/
    main.yml
  tasks/
    main.yml
  handlers/
    main.yml
shared_files/
  Get-ApplicationProductId.ps1

install-windows-apps.yml

Run this playbook to install or uninstall the demoapp role. Change demoapp_package_behavior to ‘uninstall’ if you want to uninstall the application.

By default the template is setup to install or upgrade automatically. An upgrade will be performed if the version installed on the system is less than the role’s version.

---
- hosts: windows
  tasks:
    - include_role:
        name: demoapp
      vars:
        demoapp_package_behavior: 'install'

demoapp/defaults/main.yml

This file is for default variables that will be leveraged by tasks and handlers.

---
demoapp_package_behavior: install
demoapp_package_display_name_uninstall: "Demoapp"
demoapp_version: 1.3.443.2

demoapp/tasks/main.yml

This tasks file handles the packaging logic.

---
- name: Demoapp Playbook
  block:
    - name: Unsupported Package Behavior
      fail:
        msg:
          - "The following package behavior is unsupported: {{ demoapp_package_behavior|lower }}"
          - "Valid selections (install,uninstall)"
      when: demoapp_package_behavior|lower != "install" and demoapp_package_behavior|lower != "uninstall"

    - name: Check if Demoapp is Installed
      win_service:
        name: Demoapp
      register: demoapp_svc

    - name: Demoapp Service Info
      debug:
        var: demoapp_svc
      when: demoapp_svc.exists == True

    - name: Get Installed Demoapp Version
      win_file_version:
        path: "{{ demoapp_svc.path | regex_search('[a-z](.*)(.exe)(?i)') }}"
      when: demoapp_svc.exists == True
      register: demoapp_file_version

    - name: Clean Install
      debug:
        msg:
          - "Demoapp is not installed"
          - "Installing Demoapp version {{ demoapp_version }}"
      when: demoapp_svc.exists == False and demoapp_package_behavior|lower == "install"
      changed_when: true
      notify: "install demoapp"

    - name: Upgrade
      debug:
        msg:
          - "It appears that Demoapp version {{ demoapp_file_version.win_file_version.product_version }} is currently installed"
          - "Upgrading to version {{ demoapp_version }}"
      when: demoapp_svc.exists == True and demoapp_file_version.win_file_version.product_version is version(demoapp_version, operator='<') and demoapp_package_behavior|lower == "install"
      changed_when: true
      notify: "install demoapp"

    - name: No Change
      debug:
        msg:
          - "It appears that Demoapp version {{ demoapp_file_version.win_file_version.product_version }} is currently installed"
          - "Nothing to do"
      when: demoapp_svc.exists == True and demoapp_file_version.win_file_version.product_version is version(demoapp_version, operator='>=') and demoapp_package_behavior|lower == "install"

    - name: Uninstall
      debug:
        msg:
          - "Uninstall was requested"
          - "Uninstalling Demoapp version {{ demoapp_file_version.win_file_version.product_version }}"
      when: demoapp_svc.exists == True and demoapp_package_behavior|lower == "uninstall"
      changed_when: true
      notify: "uninstall demoapp"

    - name: Uninstall Requested but Product not Found
      debug:
        msg:
          - "Uninstall was requested"
          - "Demoapp was not found on the system"
      when: demoapp_svc.exists == False and demoapp_package_behavior|lower == "uninstall"

  rescue:
    - name: Clean Up Files
      win_file:
        path: C:\temp\Ansible
        state: absent
      ignore_errors: True

    - name: Ansible Play Failed
      fail:
        msg: "The playbook has failed, please see previous errors."

demoapp/handlers/main.yml

This handlers file strictly just listens to instructions from the tasks file.

---
- name: Create Directory for Local Execution
  win_file:
    path: C:\temp\Ansible
    state: directory
  listen:
    - "install demoapp"
    - "uninstall demoapp"

- name: Download demoapp
  win_get_url:
    url: https://<source_path>/Windows/demoapp/v{{ demoapp_version }}/demoapp-Windows-{{ demoapp_version }}.zip
    dest: C:\temp\Ansible
  listen: "install demoapp"

- name: Unzip Files
  win_unzip:
    src: C:\temp\Ansible\demoapp-Windows-{{ demoapp_version }}.zip
    dest: C:\temp\Ansible
  listen: "install demoapp"

- name: Install demoapp
  win_package:
    path: C:\temp\Ansible\demoapp-Windows-{{ demoapp_version }}.msi
    creates_path: C:\demoapp\demoapp.exe
    creates_version: "{{ demoapp_version }}"
    state: present
  listen: "install demoapp"

- name: Copy get Product ID script
  win_copy:
    src: ../../shared_files/Get-ApplicationProductId.ps1
    dest: C:\temp\Ansible\Get-ApplicationProductId.ps1
  listen: "uninstall demoapp"

- name: Get Product ID for Uninstall (demoapp)
  win_shell: C:\temp\Ansible\Get-ApplicationProductId.ps1 "{{ demoapp_package_display_name_uninstall }}"
  register: product_id_demoapp
  listen: "uninstall demoapp"

- name: Uninstall demoapp
  win_package:
    product_id: '{{ product_id_demoapp.stdout_lines[0] }}'
    state: absent
  listen: "uninstall demoapp"

- name: Clean Up Files
  win_file:
    path: C:\temp\Ansible
    state: absent
  ignore_errors: True
  listen:
    - "install demoapp"
    - "uninstall demoapp"

shared_files/Get-ApplicationProductId.ps1

This is a PowerShell helper script leveraged by the Ansible role. When you pass the product Name (demoapp_package_display_name_uninstall) as seen under “Control Panel\All Control Panel Items\Programs and Features” to the script, it will return the Product ID which is used to uninstall the application.

<#
  .SYNOPSIS
    Gets the Product Id for an installed application.
  .PARAMETER Name
    Name of the installed product.
  .DESCRIPTION
    This script retrieves the Product Id for an installed application. The Product Id is returned for consumption by Ansible.

    The Name parameter is the product name as seen in the "Control Panel" under "Programs and Features".

    Name can also be retrieved using 'wmic product list' or from the registry.
  .EXAMPLE
    .\Get-ApplicationProductId.ps1 -Name "Application01"
#>

param(
  [Parameter(Mandatory = $true)]
  [String]$Name
)

Try {
  $regPath = @("HKLM:Software\Microsoft\Windows\CurrentVersion\Uninstall\","HKLM:Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall")
  foreach ($element in $regPath) {
      Get-ChildItem $element -Recurse | ForEach-Object {
        $oKey = (Get-ItemProperty -Path $_.PsPath)
        If ($oKey -match $Name){
          Write-Output $_.PSChildName
          exit 0
        }
      }
    }
  Write-Output "$Name not found on the system, could not locate corresponding ProductId."
  exit 1
}
Catch {
  Write-Output "An error occurred while getting the ProductId for uninstall."
  exit 2
}