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.

Samstag, September 5, 2026

Netbox - Semaphore Housekeeping, nachdem LXC vollgelaufen ist

Semaphore konnte keine Tasks mehr ausführen.

11:00:08 AM
fatal: [localhost]: FAILED! => {"msg": "failed to transfer file to /root/.ansible/tmp/ansible-tmp-1788598808.745682-600055-252320871827258/AnsiballZ_uri.py: [Errno 28] No space left on device: b'/opt/semaphore/tmp/project_2/repository_1_template_7_home/.ansible/tmp/ansible-local-599946yh9hvxgy/tmp9rlk1asu' -> b'/root/.ansible/tmp/ansible-tmp-1788598808.745682-600055-252320871827258/AnsiballZ_uri.py'"}

Das LXC war schlicht vollgelaufen. Als Schuldigen habe ich /opt/semaphore/database.sqlite ausgemacht. Die Datenbank ist auf 1,2gb angewachsen. Jeder Durchlauf der Templates wird scheinbar in der Datenbank gespeichert. Notfallmäßig habe ich das Semaphore LXC zunächst um 4GB vergrößert. Um die Datenbank automatisch aufzuräumen, muss man die config.json von Semaphore etwas ergänzen.

{
  "sqlite": {
    "host": "/opt/semaphore/database.sqlite"
  },
  "dialect": "sqlite",
  "max_tasks_per_template": 30,
  "tmp_path": "/opt/semaphore/tmp",

die neue Zeile “max_tasks_per_template”: 30, regelt, das nur die letzten 30 Durchläufe aufbewahrt werden. Man muss den Dienst semaphore nach dem Ändern der Config neu starten

Nachtrag: Ich habe den Wert inzwischen auf 670 gesetzt, das entspricht bei meinen Logs derzeit in etwa 7 Tage. 30 ist viel zu wenig.

systemctl restart semaphore

Die GUI ist danach erst mal nicht mehr erreichbar. Via Proxmox Shell komme ich noch auf das LXC. Neben der database.sqlite ist eine neue Datei erschienen mit dem Namen database.sqlite-journal

Every 2.0s: ls -la                                                                                semaphore: Sat Sep  5 13:35:15 2026
total 1271488
drwxr-xr-x 4 root root       4096 Sep  5 13:15 .
drwxr-xr-x 3 root root       4096 Jul 14 00:46 ..
-rw------- 1 root root        411 Jul 25 12:59 ansible-key
-rw-r--r-- 1 root root         99 Jul 25 12:59 ansible-key.pub
-rw-r--r-- 1 root root        370 Sep  5 13:11 config.json
-rw-r--r-- 1 root root        338 Sep  5 13:06 config.json.bak
-rw-r--r-- 1 root root 1269415936 Sep  5 13:35 database.sqlite
-rw-r--r-- 1 root root   32554168 Sep  5 13:35 database.sqlite-journal
drwxr-xr-x 4 root root       4096 Aug 30 11:44 playbooks
drwxr-xr-x 3 root root       4096 Jul 24 21:21 tmp

Es müssen zahlreiche Einträge gelöscht werden und für ein eventuelles Recovery wird eine solche Sicherungsdatei angelegt. Sobald der Löschvorgang abgschlossen ist, verschwindet auch die database.sqlite-journal wieder. In meinem Fall hätte ich ohne Vergößerung des LXCs auch nichts löschen können, da die Recoverydatei ja auch Platz bekegt. Man muss also den o.g. Mechanismus direkt nach der Installation des Semaphore LXCs aktivieren, um das Semaphore LXC dauerhaft schlank zu halten. Der Löschprozess hat bei mir seeehr lange gedauert. Danach ist die database.sqlite immer noch 1,2gb groß, allerdings sind das nur leere Seiten. Endgültig los wird man die leeren Seiten erst durch einen Eingriff in sqlite. Dazu muss der o.g. Löschprozess zuerst abgeschlossen sein und der semaphore Dienst angehalten werden

systemctl stop semaphore
sqlite3 /opt/semaphore/database.sqlite "VACUUM;"
systemctl start semaphore

sqlite3 muss man evtl vorher noch über apt installieren.

Bildschirmfoto_20260906_085947.png

Wie man auf dem Screenshot sehen kann ist die Datei database.sqlite nun wesentlich kleiner als vorher.

Dienstag, September 1, 2026

Netbox - PVE Manipulation/Auslesen von Settings via Semaphore/Netbox

Manchmal möchte ich nach einem Reboot eines PVE Hosts nicht, dass die VMs und LXCs automatisch hochgefahren werden. Ich habe bisher vor so einem Reboot immer den Autostart Haken bei jedem einzelnen LXC und VM entfernen und später wieder setzen müssen.

Bildschirmfoto_20260902_131246.png

Das wollte ich unbedingt vereinfachen und gleichzeitig die jeweils aktuellen Settings der einzelnen VMs und LXCs nach Netbox synchronisieren. In diesem Fall dienen tatsächlich die jeweiligen PVE Hosts als “source of truth” für Netbox. Ich habe mir ausserdem das Leben etwas leichter gemacht, indem ich VSCodium (Opensource Alternative zu VSCode von M$) mit zwei praktischen Plugins nachgerüstet habe –> 1. Open Remote - SSH und 2. YAML. Open Remote - SSH macht entfernte Dateisysteme auf dem lokalen Rechner via ssh in VSCodium zugänglich und YAML hilft dabei keine Fehler z.B. bei den Leerzeichen in den Playbooks zu machen. Die Einrichtung erkläre ich hier nicht, das sollte jeder mit dem bereits vorhandenen Material selbst hinkriegen können. VSCodium kann nativ mit git commits umgehen, das macht das Anlegen und V.a. das Ändern von Playbooks wesentlich komfortabler.

An den PVE Hosts die ferngesteuert und abgefragt werden sollen, müssen noch API Token generiert werden. Bei mir zuhause sind das zwei PVE Hosts, jeweils standalone. Ich muss also zwei API Token generieren. Der Token wird nur einmal, direkt nach dem Erzeugen angezeigt, es empfiehlt sich daher den Token an einem sicheren Ort (Passwortmanager) zu speichern.

Bildschirmfoto_20260901_153712.png

Bei den Playbooks habe ich ein neues yml File angelegt mit folgendem Inhalt:

---
- name: NetBox VM-Config gegen PVE abgleichen (Standort_A)
  hosts: localhost
  connection: local
  gather_facts: false
  vars:
    netbox_api: "https://192.168.2.10"
    netbox_token: "{{ lookup('env', 'NETBOX_TOKEN') }}"
    # ACHTUNG: Slug, steht bei Standort und ist nur in Bearbeitung sichtbnar
    target_site_slug: "standort-a"
        pve_nodes:
      - name: pve01
        api: "https://192.168.2.16:8006/api2/json"
        token_id: "{{ lookup('env', 'PVE01_TOKEN_ID') }}"
        token_secret: "{{ lookup('env', 'PVE01_TOKEN_SECRET') }}"
      - name: pve03
        api: "https://192.168.2.18:8006/api2/json"
        token_id: "{{ lookup('env', 'PVE03_TOKEN_ID') }}"
        token_secret: "{{ lookup('env', 'PVE03_TOKEN_SECRET') }}"
        dry_run: false
    start_on_boot_enabled_value: "on"
    start_on_boot_disabled_value: "off"
    start_on_boot_laststate_value: "last-state"
    resource_drift: []
    onboot_actions: []
    netbox_update_actions: []
  tasks:
    # -----------------------------------------------------------------
    # 1. NetBox-VMs der Ziel-Site holen und auf vmid pruefen
    # -----------------------------------------------------------------
    - name: NetBox-VMs der Ziel-Site holen
      ansible.builtin.set_fact:
        netbox_vms: >-
          {{ query('netbox.netbox.nb_lookup', 'virtual-machines',
                    api_endpoint=netbox_api,
                    token=netbox_token,
                    api_filter='site=' ~ target_site_slug,
                    validate_certs=false)
             | map(attribute='value')
             | list }}
    - name: Anzahl gefundener NetBox-VMs
      ansible.builtin.debug:
        msg: "{{ netbox_vms | length }} VMs in NetBox fuer Site '{{ target_site_slug }}' gefunden"
    - name: Nur VMs mit gepflegter vmid behalten
      ansible.builtin.set_fact:
        netbox_vms_ready: >-
          {{ netbox_vms
             | selectattr('custom_fields.vmid', 'defined')
             | selectattr('custom_fields.vmid', 'ne', None)
             | list }}
        netbox_vms_incomplete: >-
          {{ netbox_vms
             | rejectattr('custom_fields.vmid', 'defined')
             | list
             + netbox_vms | selectattr('custom_fields.vmid', 'defined')
             | selectattr('custom_fields.vmid', 'equalto', None) | list }}
    - name: "WARNUNG - VMs ohne vmid in NetBox (werden uebersprungen)"
      ansible.builtin.debug:
        msg: "'{{ item.name }}': vmid={{ item.custom_fields.vmid | default('FEHLT') }}"
      loop: "{{ netbox_vms_incomplete }}"
      loop_control:
        label: "{{ item.name }}"
    # -----------------------------------------------------------------
    # 2. PVE-Ressourcen von BEIDEN Standalone-Nodes holen, zusammenfuehren
    #    und nach vmid indexieren (liefert vmid, name, node, maxcpu,
    #    maxmem, maxdisk - reicht fuer node-Aufloesung UND den
    #    vCPU/RAM/Disk-Diff)
    # -----------------------------------------------------------------
    - name: Ressourcen-Stand von JEDEM PVE-Node einzeln holen (Standalone, kein Proxmox-Cluster)
      ansible.builtin.uri:
        url: "{{ item.api }}/cluster/resources?type=vm"
        method: GET
        headers:
          Authorization: "PVEAPIToken={{ item.token_id }}={{ item.token_secret }}"
        validate_certs: false
        status_code: [200]
      loop: "{{ pve_nodes }}"
      loop_control:
        label: "{{ item.name }}"
      register: pve_resources_raw
      no_log: true
    - name: Ergebnisse aller Nodes zu einer Liste zusammenfuehren
      ansible.builtin.set_fact:
        pve_resources_all: >-
          {{ pve_resources_raw.results
             | map(attribute='json')
             | map(attribute='data')
             | flatten(levels=1) }}
    - name: PVE-Node-Name zu vollen Node-Infos (API-URL + Token) Lookup bauen
      ansible.builtin.set_fact:
        pve_node_lookup_helper: "{{ dict(pve_nodes | map(attribute='name') | list | zip(pve_nodes)) }}"
    - name: "PVE-Ressourcen (LXC UND QEMU) nach vmid indexieren (inkl. API-URL + Token + Typ des jeweiligen Nodes)"
      ansible.builtin.set_fact:
        pve_by_vmid: "{{ pve_by_vmid | default({}) | combine({ (item.vmid | string): (item | combine({
          'api': pve_node_lookup_helper[item.node].api,
          'token_id': pve_node_lookup_helper[item.node].token_id,
          'token_secret': pve_node_lookup_helper[item.node].token_secret
        })) }) }}"
      loop: "{{ pve_resources_all | selectattr('type', 'in', ['lxc', 'qemu']) | list }}"
      loop_control:
        label: "{{ item.name }}"
    - name: "WARNUNG - NetBox-vmid ohne Treffer in PVE (werden uebersprungen)"
      ansible.builtin.debug:
        msg: "'{{ item.name }}' (vmid={{ item.custom_fields.vmid }}) nicht in PVE gefunden"
      loop: "{{ netbox_vms_ready }}"
      loop_control:
        label: "{{ item.name }}"
      when: (item.custom_fields.vmid | string) not in pve_by_vmid
    - name: Nur tatsaechlich in PVE vorhandene VMs weiterverarbeiten
      ansible.builtin.set_fact:
        netbox_vms_matched: >-
          {{ netbox_vms_ready
             | selectattr('custom_fields.vmid', 'in', pve_by_vmid.keys() | map('int') | list)
             | list }}
    # -----------------------------------------------------------------
    # 3. vCPU/RAM/Disk-Drift anzeigen (NUR Anzeige, noch kein Push)
    # -----------------------------------------------------------------
    - name: Ressourcen-Drift bauen (vCPU/RAM/Disk, Diff-Anzeige only)
      ansible.builtin.set_fact:
        resource_drift: "{{ resource_drift | default([]) + [{
          'name': item.name,
          'netbox_id': item.id,
          'vmid': item.custom_fields.vmid,
          'node': pve_by_vmid[item.custom_fields.vmid | string].node,
          'netbox_vcpus': item.vcpus | default(None),
          'pve_vcpus': pve_by_vmid[item.custom_fields.vmid | string].maxcpu | int,
          'netbox_memory_mb': item.memory | default(None),
          'pve_memory_mb': (pve_by_vmid[item.custom_fields.vmid | string].maxmem | int / 1024 / 1024) | round(0, 'common') | int,
          'netbox_disk_mb': item.disk | default(None),
          'pve_disk_mb': (pve_by_vmid[item.custom_fields.vmid | string].maxdisk | int / 1024 / 1024) | round(0, 'common') | int
        }] }}"
      loop: "{{ netbox_vms_matched }}"
      loop_control:
        label: "{{ item.name }}"
    - name: "INFO - Ressourcen-Drift je VM (Anzeige; Reverse-Push PVE->NetBox folgt darunter)"
      ansible.builtin.debug:
        msg: >-
          {{ item.name }} (vmid={{ item.vmid }}, node={{ item.node }}):
          vCPU NetBox={{ item.netbox_vcpus }} PVE={{ item.pve_vcpus }}
          {{ '[ABWEICHUNG]' if item.netbox_vcpus | int != item.pve_vcpus else '' }} |
          RAM(MB) NetBox={{ item.netbox_memory_mb }} PVE={{ item.pve_memory_mb }}
          {{ '[ABWEICHUNG]' if item.netbox_memory_mb | int != item.pve_memory_mb else '' }} |
          Disk(MB) NetBox={{ item.netbox_disk_mb }} PVE={{ item.pve_disk_mb }}
          {{ '[ABWEICHUNG - SHRINK, wird NICHT gepusht]' if item.netbox_disk_mb | int < item.pve_disk_mb
             else ('[ABWEICHUNG - GROW]' if item.netbox_disk_mb | int > item.pve_disk_mb else '') }}
      loop: "{{ resource_drift }}"
      loop_control:
        label: "{{ item.name }}"
    - name: NetBox-Update-Aktionsplan bauen (PVE-Ist-Werte sollen nach NetBox uebernommen werden)
      ansible.builtin.set_fact:
        netbox_update_actions: "{{ netbox_update_actions | default([]) + [{
          'name': item.name,
          'netbox_id': item.netbox_id,
          'vmid': item.vmid,
          'target_vcpus': item.pve_vcpus,
          'target_memory': item.pve_memory_mb,
          'target_disk': item.pve_disk_mb,
          'any_change': (item.netbox_vcpus | int != item.pve_vcpus)
                         or (item.netbox_memory_mb | int != item.pve_memory_mb)
                         or (item.netbox_disk_mb | int != item.pve_disk_mb)
        }] }}"
      loop: "{{ resource_drift }}"
      loop_control:
        label: "{{ item.name }}"
    - name: "UEBERSICHT - NetBox-Objekte, die auf PVE-Ist-Werte aktualisiert werden"
      ansible.builtin.debug:
        msg: "'{{ item.name }}' (netbox_id={{ item.netbox_id }}): vCPU->{{ item.target_vcpus }} RAM->{{ item.target_memory }}MB Disk->{{ item.target_disk }}MB"
      loop: "{{ netbox_update_actions }}"
      loop_control:
        label: "{{ item.name }}"
      when: item.any_change
    - name: "PVE-Ist-Werte nach NetBox schreiben (NUR wenn dry_run=false, NUR bei Abweichung)"
      ansible.builtin.uri:
        url: "{{ netbox_api }}/api/virtualization/virtual-machines/{{ item.netbox_id }}/"
        method: PATCH
        headers:
          Authorization: "Token {{ netbox_token }}"
        body_format: json
        body:
          vcpus: "{{ item.target_vcpus }}"
          memory: "{{ item.target_memory }}"
          disk: "{{ item.target_disk }}"
        validate_certs: false
        status_code: [200]
      loop: "{{ netbox_update_actions }}"
      loop_control:
        label: "{{ item.name }}"
      when:
        - not (dry_run | bool)
        - item.any_change
      register: netbox_update_result
      failed_when: false
    - name: Fehlgeschlagene NetBox-Updates auflisten
      ansible.builtin.debug:
        msg: "FEHLER bei {{ item.item.name }}: {{ item.msg | default('unbekannt') }}"
      loop: "{{ netbox_update_result.results | default([]) }}"
      loop_control:
        label: "{{ item.item.name }}"
      when:
        - item.item is defined
        - item.status is defined
        - item.status != 200
    # -----------------------------------------------------------------
    # 4. onboot / start_on_boot: vollstaendig implementiert, inkl. Push
    # -----------------------------------------------------------------
    - name: "'Letzter Status' VMs herausfiltern (werden bewusst nicht gepusht)"
      ansible.builtin.set_fact:
        netbox_vms_laststate: "{{ netbox_vms_matched | selectattr('start_on_boot.value', 'equalto', start_on_boot_laststate_value) | list }}"
        netbox_vms_pushable: "{{ netbox_vms_matched | rejectattr('start_on_boot.value', 'equalto', start_on_boot_laststate_value) | list }}"
    - name: "INFO - VMs mit 'Letzter Status' (nicht abbildbar in Proxmox, wird uebersprungen)"
      ansible.builtin.debug:
        msg: "'{{ item.name }}' (vmid={{ item.custom_fields.vmid }}) steht auf 'Letzter Status' - wird nicht angefasst"
      loop: "{{ netbox_vms_laststate }}"
      loop_control:
        label: "{{ item.name }}"
    - name: Aktuelle PVE-Config je gematchter, pushbarer VM/LXC holen (fuer onboot-Wert)
      ansible.builtin.uri:
        url: "{{ pve_by_vmid[item.custom_fields.vmid | string].api }}/nodes/{{ pve_by_vmid[item.custom_fields.vmid | string].node }}/{{ pve_by_vmid[item.custom_fields.vmid | string].type }}/{{ item.custom_fields.vmid }}/config"
        method: GET
        headers:
          Authorization: "PVEAPIToken={{ pve_by_vmid[item.custom_fields.vmid | string].token_id }}={{ pve_by_vmid[item.custom_fields.vmid | string].token_secret }}"
        validate_certs: false
        status_code: [200]
      loop: "{{ netbox_vms_pushable }}"
      loop_control:
        label: "{{ item.name }}"
      register: pve_configs_raw
    - name: Aktionsplan bauen (Diff NetBox start_on_boot vs. PVE onboot)
      ansible.builtin.set_fact:
        onboot_actions: "{{ onboot_actions | default([]) + [{
          'name': item.item.name,
          'vmid': item.item.custom_fields.vmid,
          'node': pve_by_vmid[item.item.custom_fields.vmid | string].node,
          'type': pve_by_vmid[item.item.custom_fields.vmid | string].type,
          'api': pve_by_vmid[item.item.custom_fields.vmid | string].api,
          'token_id': pve_by_vmid[item.item.custom_fields.vmid | string].token_id,
          'token_secret': pve_by_vmid[item.item.custom_fields.vmid | string].token_secret,
          'netbox_start_on_boot': item.item.start_on_boot.value,
          'netbox_wants_enabled': (item.item.start_on_boot.value == start_on_boot_enabled_value),
          'pve_onboot': (item.json.data.onboot | default(0) | int == 1)
        }] }}"
      loop: "{{ pve_configs_raw.results }}"
      loop_control:
        label: "{{ item.item.name }}"
    - name: "DRY RUN / UEBERSICHT - Alle gematchten, pushbaren VMs mit onboot-Stand"
      ansible.builtin.debug:
        msg: >-
          {{ item.name }} (vmid={{ item.vmid }}, node={{ item.node }}):
          NetBox={{ item.netbox_start_on_boot }} ({{ 'on' if item.netbox_wants_enabled else 'off' }})
          PVE-onboot={{ item.pve_onboot }}
          {{ '[WIRD GEAENDERT]' if item.netbox_wants_enabled != item.pve_onboot else '[unveraendert]' }}
      loop: "{{ onboot_actions }}"
      loop_control:
        label: "{{ item.name }}"
    - name: onboot-Wert in PVE setzen (nur bei tatsaechlicher Abweichung, nur wenn dry_run=false)
      ansible.builtin.uri:
        url: "{{ item.api }}/nodes/{{ item.node }}/{{ item.type }}/{{ item.vmid }}/config"
        method: PUT
        headers:
          Authorization: "PVEAPIToken={{ item.token_id }}={{ item.token_secret }}"
        body_format: form-urlencoded
        body:
          onboot: "{{ 1 if item.netbox_wants_enabled else 0 }}"
        validate_certs: false
        status_code: [200]
      loop: "{{ onboot_actions }}"
      loop_control:
        label: "{{ item.name }}"
      when:
        - not (dry_run | bool)
        - item.netbox_wants_enabled != item.pve_onboot
      register: onboot_push_result
      failed_when: false
    - name: Fehlgeschlagene onboot-Updates auflisten
      ansible.builtin.debug:
        msg: "FEHLER bei {{ item.item.name }}: {{ item.msg | default('unbekannt') }}"
      loop: "{{ onboot_push_result.results | default([]) }}"
      loop_control:
        label: "{{ item.item.name }}"
      when:
        - item.item is defined
        - item.status is defined
        - item.status != 200

den “slug” sieht man am Standort wenn man auf Bearbeiten klickt. Da ich meinen Wohnrt nicht im Internet teilen möchte, habe ich die Felder verpixelt. In meinem Fall sind zwei PVE Hosts vorhanden und deren beiden IPs sind hart in das Skript kodiert. Wer das Skript verwenden möchte muss es an die eigene Situation anpassen.

Bildschirmfoto_20260901_161631.png

Aus Faulheitsgründen habe ich die vorher erzeugten PVE Token einfach in das Variablenfile der dns-push skripte dazugepackt. Man könnte das natürlich auch trennen, hat beides Vor- und Nachteile…

Bildschirmfoto_20260901_162918.png

Dem bereits vorhandenen Netbox Token habe ich nachträglich noch Scheibrechte eingeräumt (geht nachträglich über die GUI), sonst ließen sich keine Werte (vCPU, RAM etc.) aus PVE in Netbox schreiben. Zum Schluss noch das neue Playbook via Template in Semaphore einfügen.

Bildschirmfoto_20260901_163224.png

Die virtuellen Maschinen kennen bei Netbox bereits ein Feld für den Zustand nach Reboot

Bildschirmfoto_20260901_163659.png

Bei PVE wird als “Primary Key” zur Identifikation die sogenannte VMID verwendet. Dafür gibt es (noch) kein Feld bei Netbox. Nachdem man das neue Feld erzeugt hat, muss man es noch für jede virtuelle Instanz manuell befüllen. Nur LXCs und VMs die in Netbox manuell eine solche vmid zugewiesen bekommen haben, werden von obigen Skript auch berücksichtigt, der Rest wird ignoriert.

Bildschirmfoto_20260901_164407.png
Bildschirmfoto_20260902_095710-1.png

Nachdem ich das Skript ein paar mal getestet hatte, habe ich einen cronjob alle 10 Minuten dafür eingerichtet, das geht in Semaphore ja auch über die GUI. Ich kann jetzt zentral in Netbox das Verhalten aller virtuellen Instanzen nach einem Reboot des jeweiligen PVE Hosts steuern. Im gleichen Schritt werden die Werte für vCPU, RAM etc von den virtuellen Instanzen ausgelesen und in Netbox synchronisiert. Netbox tut sich etwas schwer mit der Umrechnung (Bei Netbox sind 1000MB ein GB und nicht 1024MB wie es richtig wäre), das stört mich aber nicht weiter. Der große Vorteil dabei ist nun, dass ich das Rebootverhalten nun auch massenhaft ändern kann und ich mich nicht mehr einzeln durchklicken muss.