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:

  • Ubiquiti is focused on monitoring first, so any Site Manager or Network endpoints in active development will first pick up telemetry and statistics
  • Idempotency isn't achieved by the API itself, so practice due care when applying things to an existing site
  • There are probably more API endpoints than there are documented. I'm going to assume that if an endpoint isn't documented, it may not be 100% ready

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

 1---
 2name: 'On-Demand: Build Unifi'
 3
 4on:
 5  workflow_dispatch:
 6
 7permissions:
 8  contents: read
 9
10jobs:
11  build:
12    name: 'Build Configurations (Unifi)'
13    runs-on: self-hosted
14    steps:
15      - uses: actions/checkout@v6
16      - name: Configure 1Password
17        uses: 1password/load-secrets-action/configure@v4
18        with:
19          service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
20      - name: Load secret
21        uses: 1password/load-secrets-action@v4
22        with:
23          # Export loaded secrets as environment variables
24          export-env: true
25        env:
26          UNIFI_API: 'op://devops/unifi_api/hostname'
27          UNIFI_API_KEY: 'op://devops/unifi_api/credential'
28          UNIFI_SITE: 'op://devops/unifi_api/text'
29      - name: 'Build Unifi'
30        run: |
31          python3 -m venv .
32          source bin/activate
33          python3 -m pip install --upgrade pip
34          python3 -m pip install -r requirements.txt
35          curl -L -o ubiquiti-unifi_api-latest.tar.gz https://apidoc-cdn.ui.com/ansible-module/ubiquiti-unifi_api-latest.tar.gz
36          ansible-galaxy collection install ubiquiti-unifi_api-latest.tar.gz
37          ansible-playbook build_unifi.yml
38        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.

1###### Requirements without Version Specifiers ######
2jinja2
3requests
4urllib3
5httpx
6
7###### Requirements with Version Specifiers ######
8ansible >= 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):

 1---
 2- name: 'Build Unifi Configs'
 3  hosts: localhost
 4  gather_facts: false
 5  # Before executing ensure that the prerequisites are installed
 6  # We start with a pre-check playbook, if it fails, we don't want to
 7  # make changes
 8  any_errors_fatal: true
 9  module_defaults:
10    group/ubiquiti.unifi_api.common:
11      base_url: "{{ lookup('env', 'UNIFI_API') }}"
12      token: "{{ lookup('env', 'UNIFI_API_KEY') }}"
13  vars:
14    unifi_site_name: "{{ lookup('env', 'UNIFI_SITE') }}"
15  tasks:
16    - name: 'Get Sites List'
17      ubiquiti.unifi_api.network:
18        path: '/v1/sites'
19        method: 'GET'
20      register: get_sites_result
21    - name: 'Select site based on name'
22      ansible.builtin.set_fact:
23        unifi_site_data: "{{ get_sites_result.data.data | selectattr('name', 'equalto', unifi_site_name) | first }}"
24    - name: 'Print Site data'
25      ansible.builtin.debug:
26        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:

1fatal: [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:

1    - name: 'Create Network'
2      ubiquiti.unifi_api.network:
3        path: '/v1/sites/{{ unifi_site_data.id }}/networks'
4        method: 'POST'
5        body:
6          management: 'UNMANAGED'
7          name: 'test_net'
8          enabled: true
9          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:

1    - name: 'Get Networks'
2      ubiquiti.unifi_api.network:
3        path: '/v1/sites/{{ unifi_site_data.id }}/networks'
4        method: 'GET'
5        query:
6          filter: "or(vlanId.eq({{ item.vlanId }}), name.eq('{{ item.name }}'))"
7      loop: '{{ vlans }}'
8      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:

  • If item.data.data size is 0, then it's a new VLAN. Create from item.item (POST)
  • If item.data.data and item.item don't match, update (PUT)
  • If item.data.data and item.item do match, don't do anything.

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

 1{
 2   "changed":false,
 3   "msg":"All items completed",
 4   "results":[
 5      {
 6         "ansible_loop_var":"item",
 7         "changed":false,
 8         "failed":false,
 9         "item":{
10            "ansible_loop_var":"item",
11            "changed":false,
12            "data":{
13               "count":1,
14               "data":[
15                  {
16                     "default":false,
17                     "enabled":true,
18                     "id":"uuid",
19                     "management":"UNMANAGED",
20                     "metadata":{
21                        "origin":"USER_DEFINED"
22                     },
23                     "name":"test_net",
24                     "vlanId":22
25                  }
26               ],
27               "limit":25,
28               "offset":0,
29               "totalCount":1
30            },
31            "failed":false,
32            "invocation":{
33               "module_args":{
34                  "api_key_header":"X-API-KEY",
35                  "base_url":"***",
36                  "body":null,
37                  "ca_path":null,
38                  "client_cert":null,
39                  "client_key":null,
40                  "console_id":null,
41                  "files":null,
42                  "headers":{
43                     
44                  },
45                  "method":"GET",
46                  "path":"/v1/sites/uuid/networks",
47                  "query":{
48                     "filter":"or(vlanId.eq(22), name.eq('TestVlan'))"
49                  },
50                  "token":"VALUE_SPECIFIED_IN_NO_LOG_PARAMETER",
51                  "validate_certs":false
52               }
53            },
54            "item":{
55               "enabled":true,
56               "management":"UNMANAGED",
57               "name":"TestVlan",
58               "vlanId":22
59            },
60            "status":200
61         },
62         "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}"
63      }
64   ],
65   "skipped":false
66}

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

 1    - name: 'Update pre-existing VLANs'
 2      ubiquiti.unifi_api.network:
 3        path: '/v1/sites/{{ unifi_site_data.id }}/networks/{{ item.data.data[0].id }}'
 4        method: 'PUT'
 5        body:
 6          management: '{{ item.item.management }}'
 7          name: '{{ item.item.name }}'
 8          enabled: '{{ item.item.enabled }}'
 9          vlanId: '{{ item.item.vlanId }}'
10      loop: '{{ get_vlans_result.results }}'
11      when:
12        - item.item.name != item.data.data[0].name
13    - name: 'Create VLANs'
14      ubiquiti.unifi_api.network:
15        path: '/v1/sites/{{ unifi_site_data.id }}/networks'
16        method: 'POST'
17        body:
18          management: '{{ item.item.management }}'
19          name: '{{ item.item.name }}'
20          enabled: '{{ item.item.enabled }}'
21          vlanId: '{{ item.item.vlanId }}'
22      loop: '{{ get_vlans_result.results }}'
23      when:
24        - 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