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.

RSS | ATOM


Kommentar hinzufügen

Die Felder Name und Kommentar sind Pflichtfelder.


BBCode Hilfe