fix: heal DNS nameserver group domains=null state on apply

A self-hosted NetBird dashboard crashed when loading the DNS → Nameservers
page because the installer-default "Public DNS" nameserver group was stored
with `domains: null` and the dashboard frontend does not null-guard that
field. The collection's apply cycle was preserving the null state rather
than healing it.

Four layers of fix:

- roles/export/templates/export/dns/nameservers.yml.j2 — always emit
  `domains: []` on export instead of omitting the key when the API
  returned null. Ensures the exported YAML carries an explicit list
  into the apply cycle.

- roles/configure/tasks/main.yml — coerce `item.domains is null` to
  `[]` via `default([], true)` before passing to the module, so that
  a hand-edited YAML with `domains: null` still produces `[]`.

- plugins/module_utils/netbird_api.py — `update_nameserver_group` now
  always includes `domains` in the PUT body and coerces `None → []`.
  Previously it skipped the field on `None`, which preserved the
  backend's null state.

- plugins/modules/netbird_dns.py — `nsgroup_needs_update` now treats
  backend `domains: null` (or `groups: null`) as a heal-eligible change
  against a list-valued desired state, so a PUT fires to coerce the
  field. Previously it used `current.get('domains') or []` which
  silently equated null with [] and returned "no change".

With these together, a `make safe-apply-test` cycle heals the
installer-seeded null state, and the dashboard loads. Verified against
netbird.cybersunset.net.
This commit is contained in:
Jack Carter
2026-04-17 18:03:39 +02:00
parent 39066a36c2
commit bbea38eae0
4 changed files with 25 additions and 11 deletions
+5 -2
View File
@@ -636,10 +636,13 @@ class NetBirdAPI:
data['nameservers'] = nameservers
if description is not None:
data['description'] = description
# domains: never send null. The NetBird dashboard crashes when this
# field is null, and the backend installer can seed it that way.
# Coerce None to [] and always include it so an update cycle heals
# pre-existing null state rather than preserving it.
data['domains'] = [] if domains is None else domains
if groups is not None:
data['groups'] = groups
if domains is not None:
data['domains'] = domains
if enabled is not None:
data['enabled'] = enabled
if primary is not None:
+14 -7
View File
@@ -270,17 +270,24 @@ def nsgroup_needs_update(current, params):
if nameservers_need_update(current.get('nameservers'), params['nameservers']):
return True
# Check groups
# Check groups -- treat backend null as a heal-eligible change
if params.get('groups') is not None:
current_groups = set(extract_ids(current.get('groups') or []))
desired_groups = set(extract_ids(params['groups'] or []))
current_raw_groups = current.get('groups')
if current_raw_groups is None:
return True
current_groups = set(extract_ids(current_raw_groups))
desired_groups = set(extract_ids(params['groups']))
if current_groups != desired_groups:
return True
# Check domains
# Check domains -- treat backend null as a heal-eligible change
# (the dashboard crashes on null; an update must fire to coerce it to [])
if params.get('domains') is not None:
current_domains = set(current.get('domains') or [])
desired_domains = set(params['domains'] or [])
current_raw_domains = current.get('domains')
if current_raw_domains is None:
return True
current_domains = set(current_raw_domains)
desired_domains = set(params['domains'])
if current_domains != desired_domains:
return True
+4 -2
View File
@@ -342,8 +342,10 @@
name: "{{ item.name }}"
description: "{{ item.description | default(omit) }}"
nameservers: "{{ item.nameservers }}"
groups: "{{ item.groups | default([]) | map('extract', group_ids) | list }}"
domains: "{{ item.domains | default(omit) }}"
groups: "{{ item.groups | default([], true) | map('extract', group_ids) | list }}"
# Coerce null/undefined to [] so the apply path heals backend state
# where domains was seeded as null (the dashboard crashes on null).
domains: "{{ item.domains | default([], true) }}"
enabled: "{{ item.enabled | default(true) }}"
primary: "{{ item.primary | default(false) }}"
search_domains_enabled: "{{ item.search_domains_enabled | default(omit) }}"
@@ -33,6 +33,8 @@ netbird_dns_nameserver_groups:
{% for domain in ns.domains %}
- "{{ domain }}"
{% endfor %}
{% else %}
domains: []
{% endif %}
enabled: {{ ns.enabled | default(true) | lower }}
primary: {{ ns.primary | default(false) | lower }}