Montag, September 14, 2026

Netbox - Anpassung der Semaphore Playbooks nach Update von Netbox auf 4.7.0

Netbox/Semaphore usw. läuft bisher nur bei mir zuhause und wie sich aktuell zeigt, aus gutem Grund. Das letzte Netbox Update, genauer gesagt ein Update des DHCP Plugins hat beim Sprung auf Netbox 4.7.0 diverse Änderungen mit sich gebracht, die eines meiner Playbooks leider in die Knie gezwungen haben. Das DHCP Plugin von Peter Eckel ist weiterhin beta, weswegen ich das Setup mit Netbox/Semaphore auch weiterhin (noch) nicht in einem produktiven Umfeld einsetzen werde. Ich vermute die Änderungen vom 04.08.26 im Netbox DHCP Plugin sind der konkrete Grund.

Die Fehlermeldung bei Semaphore lautet (exemplarisch für das dashy LXC):

7:35:10 PM
ok: [dns-redacted] => (item=dashy) => {
7:35:10 PM
    "msg": "WARNUNG: dashy hat hw_address_id={'id': 7, 'url': 'https://192.168.2.10/api/dcim/mac-addresses/7/', 'display': 'BC:24:11:xx:xx:xx', 'mac_address': 'BC:24:11:xx:xx:xx', 'description': ''}, keine passende MAC in mac_lookup gefunden - wird uebersprungen"
7:35:10 PM
}

ich habe jedenfalls das dhcp-push skript an die neuen Gegebenheiten angepasst. –>

---
# push-dhcp-standort-a.yml
#
# Discovery-Phase: schreibt noch NICHTS in Technitium, liest nur
# Host-Reservations aus netbox-plugin-dhcp + Scope-Config aus Technitium.
#
# Aufruf:
#   ansible-playbook -i inventory/netbox.yml push-dhcp-standort-a.yml --limit dns-standort-a
- name: DHCP-Reservierungen NetBox <-> Technitium - Discovery
  hosts: dns-standort-a
  gather_facts: false
  vars:
    netbox_api: "https://192.168.2.10"
    netbox_token: "{{ lookup('env', 'NETBOX_TOKEN') }}"
    technitium_url: "https://{{ ansible_host }}:53443"
    technitium_token: "{{ lookup('env', 'TECHNITIUM_TOKEN') }}"
    target_vrf_name: "VRF Standort A"
    technitium_scope_name: "StandortA"
    dry_run: true  # true = nur anzeigen, kein Schreibzugriff
  tasks:
    - name: Alle VRFs holen (ungefiltert, wegen Leerzeichen im Namen)
      ansible.builtin.set_fact:
        all_vrfs: >-
          {{ query('netbox.netbox.nb_lookup', 'vrfs',
                    api_endpoint=netbox_api, token=netbox_token,
                    validate_certs=false)
             | map(attribute='value') | list }}
    - name: Ziel-VRF lokal per Namen herausfiltern
      ansible.builtin.set_fact:
        target_vrf: "{{ all_vrfs | selectattr('name', 'equalto', target_vrf_name) | first }}"
    - name: Prefixe der Ziel-VRF holen
      ansible.builtin.set_fact:
        target_prefixes: >-
          {{ query('netbox.netbox.nb_lookup', 'prefixes',
                    api_endpoint=netbox_api, token=netbox_token,
                    api_filter='vrf_id=' ~ target_vrf.id, validate_certs=false)
             | map(attribute='value') | map(attribute='prefix') | list }}
    - name: Alle Host Reservations holen
      ansible.builtin.set_fact:
        all_dhcp_reservations: >-
          {{ query('netbox.netbox.nb_lookup', 'hostreservations',
                    api_endpoint=netbox_api, token=netbox_token,
                    plugin='netbox-dhcp', validate_certs=false)
             | map(attribute='value') | list }}
    - name: Anzahl gefundener Reservations
      ansible.builtin.debug:
        msg: "{{ all_dhcp_reservations | length }} Host Reservations gesamt gefunden"
    # Fallback-Lookup fuer den Fall, dass hw_address wieder nur eine ID liefert
    # (Plugin < 0.1.10). Seit 0.1.10 ("Fixed nested serializers") liefert
    # hw_address bereits ein verschachteltes Objekt inkl. mac_address.
    - name: MAC-Address-Objekte holen (limit=0, fuer Lookup-Fallback)
      ansible.builtin.uri:
        url: "{{ netbox_api }}/api/dcim/mac-addresses/?limit=0"
        method: GET
        headers:
          Authorization: "Token {{ netbox_token }}"
        validate_certs: false
        status_code: [200]
      register: mac_addresses_raw
      ignore_errors: true
    - name: MAC-Adressen-Lookup-Tabelle bauen (id -> mac_address, Fallback)
      ansible.builtin.set_fact:
        mac_lookup: "{{ dict(mac_addresses_raw.json.results | map(attribute='id') | map('string')
                         | zip(mac_addresses_raw.json.results | map(attribute='mac_address'))) }}"
      when: mac_addresses_raw.json is defined and mac_addresses_raw.json.results is defined
    - name: Aktuelle Technitium-Scope-Konfiguration holen (inkl. reservedLeases)
      ansible.builtin.uri:
        url: "{{ technitium_url }}/api/dhcp/scopes/get"
        method: GET
        body_format: form-urlencoded
        body:
          token: "{{ technitium_token }}"
          name: "{{ technitium_scope_name }}"
        validate_certs: false
        status_code: [200]
      register: technitium_scope_raw
      no_log: true
    # -----------------------------------------------------------------
    # Push-Vorbereitung (noch kein Schreibzugriff)
    # -----------------------------------------------------------------
    - name: Ziel-Reservierungen filtern (IP innerhalb Ziel-VRF-Prefixe)
      ansible.builtin.set_fact:
        site_reservations: "{{ site_reservations | default([]) + [item] }}"
      loop: "{{ all_dhcp_reservations }}"
      loop_control:
        label: "{{ item.hostname }}"
      vars:
        record_ip: "{{ item.ipv4_address.address | ansible.utils.ipaddr('address') }}"
      when:
        - item.ipv4_address is defined
        - item.ipv4_address.address is defined
        - record_ip is ansible.utils.in_one_network target_prefixes
    # hw_address ist seit Plugin 0.1.10 ein verschachteltes Objekt mit
    # mac_address inline; nutze das direkt, mit Fallback auf mac_lookup.
    - name: Ziel-Reservierungen im Technitium-Format aufbauen
      ansible.builtin.set_fact:
        target_reserved_leases: "{{ target_reserved_leases | default([]) + [{
          'hostName': item.hostname,
          'address': item.ipv4_address.address | ansible.utils.ipaddr('address'),
          'hardwareAddress': (
            (item.hw_address.mac_address if (item.hw_address is mapping and item.hw_address.mac_address is defined)
             else mac_lookup[item.hw_address | string] | default('UNBEKANNT'))
            | replace(':', '-') | upper
          ),
          'comments': item.comments | default(None)
        }] }}"
      loop: "{{ site_reservations | default([]) }}"
      loop_control:
        label: "{{ item.hostname }}"
      when: >-
        item.hw_address is defined and
        ((item.hw_address is mapping and item.hw_address.mac_address is defined)
         or (item.hw_address is not mapping and (item.hw_address | string) in mac_lookup))
    - name: Reservierungen ohne aufgeloeste MAC warnen (werden NICHT gepusht)
      ansible.builtin.debug:
        msg: "WARNUNG: {{ item.hostname }} hat hw_address={{ item.hw_address | default('NULL') }}, keine MAC aufloesbar - wird uebersprungen"
      loop: "{{ site_reservations | default([]) }}"
      loop_control:
        label: "{{ item.hostname }}"
      when: >-
        not (item.hw_address is defined and
             ((item.hw_address is mapping and item.hw_address.mac_address is defined)
              or (item.hw_address is not mapping and (item.hw_address | string) in mac_lookup)))
    - name: Anzahl push-bereiter Reservierungen
      ansible.builtin.debug:
        msg: "{{ target_reserved_leases | default([]) | length }} von {{ site_reservations | default([]) | length }} push-bereit"
    - name: "DRY RUN - geplante reservedLeases-Liste anzeigen"
      ansible.builtin.debug:
        var: target_reserved_leases
      when: dry_run | bool
    - name: Bestehende Technitium-Reservierungen nach hardwareAddress indexieren
      ansible.builtin.set_fact:
        existing_leases_by_mac: "{{ dict(technitium_scope_raw.json.response.reservedLeases | map(attribute='hardwareAddress')
                                     | zip(technitium_scope_raw.json.response.reservedLeases)) }}"
    - name: Aktionsplan bauen (neu / aendern / unveraendert)
      ansible.builtin.set_fact:
        lease_actions: "{{ lease_actions | default([]) + [{
          'mac': item.hardwareAddress,
          'target': item,
          'existing': existing_leases_by_mac.get(item.hardwareAddress),
          'action': ('unveraendert'
                     if (existing_leases_by_mac.get(item.hardwareAddress) is not none and
                         existing_leases_by_mac[item.hardwareAddress].address == item.address and
                         existing_leases_by_mac[item.hardwareAddress].hostName == item.hostName)
                     else ('aendern' if existing_leases_by_mac.get(item.hardwareAddress) is not none else 'neu'))
        }] }}"
      loop: "{{ target_reserved_leases | default([]) }}"
      loop_control:
        label: "{{ item.hostName }}"
        loop_var: item
    - name: Zusammenfassung des Aktionsplans
      ansible.builtin.debug:
        msg: >-
          {{ lease_actions | selectattr('action', 'equalto', 'neu') | list | length }} neu,
          {{ lease_actions | selectattr('action', 'equalto', 'aendern') | list | length }} aendern,
          {{ lease_actions | selectattr('action', 'equalto', 'unveraendert') | list | length }} unveraendert
    - name: "DRY RUN - Details je geplanter Aktion"
      ansible.builtin.debug:
        msg: >-
          [{{ item.action | upper }}] {{ item.target.hostName }}
          ({{ item.mac }}) -> {{ item.target.address }}
          {{ '(vorher: ' ~ item.existing.hostName ~ ' -> ' ~ item.existing.address ~ ')' if item.action == 'aendern' else '' }}
      loop: "{{ lease_actions | rejectattr('action', 'equalto', 'unveraendert') | list }}"
      loop_control:
        label: "{{ item.target.hostName }}"
      when: dry_run | bool
    - name: Bestehende Reservierung entfernen vor Aenderung (Remove-vor-Add)
      ansible.builtin.uri:
        url: "{{ technitium_url }}/api/dhcp/scopes/removeReservedLease"
        method: POST
        body_format: form-urlencoded
        body:
          token: "{{ technitium_token }}"
          name: "{{ technitium_scope_name }}"
          hardwareAddress: "{{ item.mac }}"
        validate_certs: false
        status_code: [200]
      loop: "{{ lease_actions | selectattr('action', 'equalto', 'aendern') | list }}"
      loop_control:
        label: "{{ item.target.hostName }}"
      no_log: true
      register: remove_result
      when: not (dry_run | bool)
    - name: Reservierung anlegen (neu oder nach Remove bei Aenderung)
      ansible.builtin.uri:
        url: "{{ technitium_url }}/api/dhcp/scopes/addReservedLease"
        method: POST
        body_format: form-urlencoded
        body:
          token: "{{ technitium_token }}"
          name: "{{ technitium_scope_name }}"
          hardwareAddress: "{{ item.mac }}"
          ipAddress: "{{ item.target.address }}"
          hostName: "{{ item.target.hostName }}"
          comments: "{{ item.target.comments | default('') }}"
        validate_certs: false
        status_code: [200]
      loop: "{{ lease_actions | rejectattr('action', 'equalto', 'unveraendert') | list }}"
      loop_control:
        label: "{{ item.target.hostName }}"
      no_log: true
      register: add_result
      ignore_errors: true
      when: not (dry_run | bool)
    - name: Fehlgeschlagene Add-Aktionen auflisten
      ansible.builtin.debug:
        msg: "FEHLER bei {{ item.item.target.hostName }}: {{ item.msg | default('unbekannt') }}"
      loop: "{{ add_result.results | default([]) }}"
      loop_control:
        label: "{{ item.item.target.hostName }}"
      when:
        - not (dry_run | bool)
        - item.failed | default(false)

und kommentartechnisch ein bisschen gestrafft. Ich bin wegen der Fragilität meines aktuellen Setups etwas unglücklich. Ich werde die nächsten Tage ein paar alternative Ansätze bei dem Skript testen, die hoffentlich etwas robuster sind. Bei Netbox sind beim letzten Update diverse neue Felder hinzugekommen, das muss ich mir aber zunächst in Ruhe ansehen.

Nachtrag: Dafür wollte ich keinen eigenen neuen Blogpost erstellen, aber ich habe grade gesehen, dass es in Semaphore UI scheinbar als neues Feature sogenannte Workflows gibt –>

Bildschirmfoto_20260914_215305.png

Das sieht einem Blatt bei NodeRed verdächtig ähnlich. Damit ließen sich anschaulich verschiedene Playbooks miteinander verknüpfen bzw. aneinanderreihen, ggf sogar verschiedene Wege je nach Ausgabe wählen. Das sieht jedenfalls äußerst spannend aus und könnte meinen bisherigen Ansatz ganz erheblich vereinfachen. Man könnte die einzelnen Arbeitsschritte in einzelne Skripte modularisieren und durch Aneinanderreihung miteinander verknüpfen. Das wäre wesentlich einfacher zu warten, als ein monolithisches Skript das mit der Zeit immer größer und fehleranfälliger wird.

RSS | ATOM


Kommentar hinzufügen

Die Felder Name und Kommentar sind Pflichtfelder.


BBCode Hilfe