Idempotently Manage Ubiquiti Unifi resources with Ansible

Ubiquiti is beginning to roll out inbound APIs for their current product line (documentation here) as part of a broader effort to enable programmability. There are a number of useful features here, so I’ll provide my summary assessment of the state of things first:

Like with all new API implementations, we do see some typical late-comer benefits here. The Ubiquiti developer portal provides code generators for common API consumption tasks, which provides both an easy way to get started and a rough idea of how the API integrations should work (Read: any idiosyncrasies).

Building an Action

As always, I try to keep all deployment automation encapsulated into a CI/CD pipeline. For this example, I’m also going to use 1Password’s Devops tooling for password management. This GitHub Action also includes leveraging a Python 3 venv to ensure no pre-existing gunk is carried over from the OS, and it will install Ubiquiti’s latest Ansible collection from scratch on every execution.

Note: This will require a requirements.txt file with all Python 3 dependencies, as it doesn’t use the system’s packages. Examples below.

Action

---
name: 'On-Demand: Build Unifi'

on:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  build:
    name: 'Build Configurations (Unifi)'
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v6
      - name: Configure 1Password
        uses: 1password/load-secrets-action/configure@v4
        with:
          service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
      - name: Load secret
        uses: 1password/load-secrets-action@v4
        with:
          # Export loaded secrets as environment variables
          export-env: true
        env:
          UNIFI_API: 'op://devops/unifi_api/hostname'
          UNIFI_API_KEY: 'op://devops/unifi_api/credential'
          UNIFI_SITE: 'op://devops/unifi_api/text'
      - name: 'Build Unifi'
        run: |
          python3 -m venv .
          source bin/activate
          python3 -m pip install --upgrade pip
          python3 -m pip install -r requirements.txt
          curl -L -o ubiquiti-unifi_api-latest.tar.gz https://apidoc-cdn.ui.com/ansible-module/ubiquiti-unifi_api-latest.tar.gz
          ansible-galaxy collection install ubiquiti-unifi_api-latest.tar.gz
          ansible-playbook build_unifi.yml
        working-directory: roles/unifi

requirements.txt

The most important one to include here is httpx. The software package provided by Ubiquiti doesn’t provide it.

###### Requirements without Version Specifiers ######
jinja2
requests
urllib3
httpx

###### Requirements with Version Specifiers ######
ansible >= 8.4.0              # Mostly just don't use old Ansible (e.g. v2, v3)

Building a Playbook

Unifi’s Ansible offering all appears to leverage the same API wrapper module, mostly just for some quality of life enhancements like attaching API keys to HTTP headers, and module_defaults that prevent repetitive code.

Like with most network product playbooks, these executions are not designed to execute on a target node, and will require some tweaks. Don’t become, don’t gather_facts, and force controller execution.

Before we start applying configurations, we need to be aware of the controller’s site options. The example here is a multi-site configuration, which would be the same as their SaaS controller. The following playbook will be my start point; it will leverage the Unifi Site Manager API and use selectaddr to return the first entry matching the assigned name (from the environment variable):

---
- name: 'Build Unifi Configs'
  hosts: localhost
  gather_facts: false
  # Before executing ensure that the prerequisites are installed
  # We start with a pre-check playbook, if it fails, we don't want to
  # make changes
  any_errors_fatal: true
  module_defaults:
    group/ubiquiti.unifi_api.common:
      base_url: "{{ lookup('env', 'UNIFI_API') }}"
      token: "{{ lookup('env', 'UNIFI_API_KEY') }}"
  vars:
    unifi_site_name: "{{ lookup('env', 'UNIFI_SITE') }}"
  tasks:
    - name: 'Get Sites List'
      ubiquiti.unifi_api.network:
        path: '/v1/sites'
        method: 'GET'
      register: get_sites_result
    - name: 'Select site based on name'
      ansible.builtin.set_fact:
        unifi_site_data: "{{ get_sites_result.data.data | selectattr('name', 'equalto', unifi_site_name) | first }}"
    - name: 'Print Site data'
      ansible.builtin.debug:
        msg: 'Site data: {{ unifi_site_data }}'

Now - about Ubiquiti’s Ansible module. It’s not idemopotent, meaning that repeated runs of the same playbook are not “safe”; it will blindly apply the same change over itself without determining if any change is necessary. Repeated runs of a POST return an error:

fatal: [localhost]: FAILED! => {"changed": false, "data": {"code": "api.network.validation.vlan-id-conflict", "message": "VLAN ID 22 is already in use by network: test_net", "requestId": "66afeb7c-aa6b-4532-9217-23734f386809", "requestPath": "/integration/v1/sites/{{site }}/networks", "statusCode": 400, "statusName": "BAD_REQUEST", "timestamp": "2026-04-12T18:11:39.682017935Z"}, "msg": "API call failed", "status": 400

While this is better than, say, destroying and recreating a VLAN with devices in it, idempotency is developer’s responsibility. We need to modify the following play:

    - name: 'Create Network'
      ubiquiti.unifi_api.network:
        path: '/v1/sites/{{ unifi_site_data.id }}/networks'
        method: 'POST'
        body:
          management: 'UNMANAGED'
          name: 'test_net'
          enabled: true
          vlanId: 22

To be more clever. The ideal method here would be to write our own Ansible module, but that negates the benefits of using a vendor-provided module (simplicity). Here’s an improvised way to handle things - first, we must gather the filtered list of VLANs from the Unifi API:

    - name: 'Get Networks'
      ubiquiti.unifi_api.network:
        path: '/v1/sites/{{ unifi_site_data.id }}/networks'
        method: 'GET'
        query:
          filter: "or(vlanId.eq({{ item.vlanId }}), name.eq('{{ item.name }}'))"
      loop: '{{ vlans }}'
      register: get_vlans_result

This will submit an API request for each VLAN we intend to “manage” with Ansible, filtered per Ubiquiti’s documentation here.

Ansible then formats the results from each run specially when we use a loop. It’ll produce a list with the key results, which provides plenty of metadata from the run! Both the item.item (the variables we provided) and the item.data.data[0] variables are useful, allowing us to create a decision tree:

Here’s an example output below. It’s generic to Ansible loop, so it can be re-used for just about anything:

{
   "changed":false,
   "msg":"All items completed",
   "results":[
      {
         "ansible_loop_var":"item",
         "changed":false,
         "failed":false,
         "item":{
            "ansible_loop_var":"item",
            "changed":false,
            "data":{
               "count":1,
               "data":[
                  {
                     "default":false,
                     "enabled":true,
                     "id":"uuid",
                     "management":"UNMANAGED",
                     "metadata":{
                        "origin":"USER_DEFINED"
                     },
                     "name":"test_net",
                     "vlanId":22
                  }
               ],
               "limit":25,
               "offset":0,
               "totalCount":1
            },
            "failed":false,
            "invocation":{
               "module_args":{
                  "api_key_header":"X-API-KEY",
                  "base_url":"***",
                  "body":null,
                  "ca_path":null,
                  "client_cert":null,
                  "client_key":null,
                  "console_id":null,
                  "files":null,
                  "headers":{
                     
                  },
                  "method":"GET",
                  "path":"/v1/sites/uuid/networks",
                  "query":{
                     "filter":"or(vlanId.eq(22), name.eq('TestVlan'))"
                  },
                  "token":"VALUE_SPECIFIED_IN_NO_LOG_PARAMETER",
                  "validate_certs":false
               }
            },
            "item":{
               "enabled":true,
               "management":"UNMANAGED",
               "name":"TestVlan",
               "vlanId":22
            },
            "status":200
         },
         "msg":"{'name': 'TestVlan', 'vlanId': 22, 'enabled': True, 'management': 'UNMANAGED'} was found as a potential match for {'management': 'UNMANAGED', 'id': 'uuid', 'name': 'test_net', 'enabled': True, 'vlanId': 22, 'metadata': {'origin': 'USER_DEFINED'}, 'default': False}"
      }
   ],
   "skipped":false
}

Here’s an example for the Create/Update plays, with the conditional logic:

    - name: 'Update pre-existing VLANs'
      ubiquiti.unifi_api.network:
        path: '/v1/sites/{{ unifi_site_data.id }}/networks/{{ item.data.data[0].id }}'
        method: 'PUT'
        body:
          management: '{{ item.item.management }}'
          name: '{{ item.item.name }}'
          enabled: '{{ item.item.enabled }}'
          vlanId: '{{ item.item.vlanId }}'
      loop: '{{ get_vlans_result.results }}'
      when:
        - item.item.name != item.data.data[0].name
    - name: 'Create VLANs'
      ubiquiti.unifi_api.network:
        path: '/v1/sites/{{ unifi_site_data.id }}/networks'
        method: 'POST'
        body:
          management: '{{ item.item.management }}'
          name: '{{ item.item.name }}'
          enabled: '{{ item.item.enabled }}'
          vlanId: '{{ item.item.vlanId }}'
      loop: '{{ get_vlans_result.results }}'
      when:
        - item.data.data | length == 0

And that’s a (slightly verbose) method to implement idempotency with an Ansible module that doesn’t provide it natively, without code. The when directive for the update method takes a list, each field you want to test for a match will have to be explicitly defined as a test. This can get pretty top-heavy pretty quickly, which is why most mature API providers just process a client request and implement idempotency on the backend.

The only downside to waiting for a provider to do that is that you’ll be waiting a while.

Skeleton Waiting