Montag, September 14, 2026

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

Edit: Am 16.09.26 die neueste Version des DNS Push Playbooks ergänzt. s.u.

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.

Das Playbook für das pushen der DNS Einträge habe ich in der Zwischenzeit mehrmals überarbeitet, aktueller Stand wäre dieser hier:

---
# push-dns-site-a.yml
# Pusht A-Records aus NetBox (netbox-plugin-dns) nach Technitium und
# schreibt Comment + MAC-Adresse zurück auf das jeweilige NetBox
# IP-Address-Objekt (analog zu SyncDynamicLeases, nur für statisch
# gemanagte Records).
#
# Standort-Zuordnung erfolgt wie bei push-dhcp.yml über IP-in-Prefix-
# Matching gegen die Ziel-VRF, da nur eine NetBox-DNS-View existiert.
# Kein AAAA (IPv6 noch nicht in NetBox erfasst). Kein separater PTR-
# Push, Technitium erzeugt PTR selbst (ptr=true, createPtrZone=true).
#
# Voraussetzungen: netbox.netbox Collection, Ansible-SSH-Key auf dem
# Technitium-Host, NETBOX_TOKEN/TECHNITIUM_TOKEN als Env-Vars (z.B. in
# Semaphore als Environment-Variablen hinterlegt, nicht im Repo).
#
# Test (nur Anzeige): ansible-playbook -i inventory/netbox.yml push-dns-site-a.yml 
#         --limit dns-site-a -e dry_run=true
# Testlauf gegen einen einzelnen Record (schreibt echt, aber nur diesen einen):
#   ansible-playbook -i inventory/netbox.yml push-dns-site-a.yml --limit dns-site-a 
#     -e dry_run=false -e test_fqdn=example-host.site-a.internal
- name: NetBox DNS Records nach Technitium pushen
  hosts: dns-site-a
  gather_facts: false
  vars:
    netbox_api: "https://netbox.internal.example"
    netbox_token: "{{ lookup('env', 'NETBOX_TOKEN') }}"
    # Technitium leitet HTTP (5380) per 307 auf HTTPS (53443) um -> direkt HTTPS nutzen.
    technitium_url: "https://{{ ansible_host }}:53443"
    technitium_token: "{{ lookup('env', 'TECHNITIUM_TOKEN') }}"
    target_vrf_name: "VRF Site-A"
    expected_zone_name: "site-a.internal"
    overwrite_existing: true
    managed_comment: "Managed via NetBox (Ansible push-dns-site-a)"
    dry_run: true
    # Leer = alle Records verarbeiten. Für einen Testlauf gegen einen
    # einzelnen Eintrag: -e test_fqdn=example-host.site-a.internal
    test_fqdn: ""
  tasks:
    - name: Alle VRFs aus NetBox 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 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 aktiven DNS-Records aus netbox-plugin-dns holen
      ansible.builtin.set_fact:
        all_dns_records: >-
          {{ query('netbox.netbox.nb_lookup', 'records',
                    api_endpoint=netbox_api, token=netbox_token,
                    plugin='netbox-dns', api_filter='status=active',
                    validate_certs=false)
             | map(attribute='value') | list }}
    - name: Auf A-Records reduzieren
      ansible.builtin.set_fact:
        address_records: "{{ all_dns_records | selectattr('type', 'equalto', 'A') | list }}"
    - name: Records ermitteln, deren IP in einem Prefix der Ziel-VRF liegt
      ansible.builtin.set_fact:
        site_records: "{{ site_records | default([]) + [item] }}"
      loop: "{{ address_records }}"
      loop_control:
        label: "{{ item.fqdn | default(item.name) }}"
      vars:
        record_ip: "{{ item.value | ansible.utils.ipaddr('address') }}"
      when: record_ip is ansible.utils.in_one_network target_prefixes
    - name: Auf einzelnen Test-Record einschränken, falls test_fqdn gesetzt
      ansible.builtin.set_fact:
        site_records: "{{ site_records | default([]) | selectattr('fqdn', 'equalto', test_fqdn) | list }}"
      when: test_fqdn | length > 0
    - name: Anzahl relevanter Records
      ansible.builtin.debug:
        msg: "{{ site_records | default([]) | length }} Records werden nach {{ inventory_hostname }} gepusht"
    - name: "DRY RUN - geplante Records anzeigen"
      ansible.builtin.debug:
        msg: "{{ item.fqdn | default(item.name) }} -> {{ item.value }} (Zone: {{ item.zone.name }}, TTL: {{ item.ttl | default(3600, true) }})"
      loop: "{{ site_records | default([]) }}"
      loop_control:
        label: "{{ item.fqdn | default(item.name) }}"
      when: dry_run | bool
    - name: Warnen bei abweichender Zone
      ansible.builtin.debug:
        msg: >-
          WARNUNG: {{ item.fqdn | default(item.name) }} hat Zone '{{ item.zone.name }}',
          erwartet '{{ expected_zone_name }}'. In NetBox DNS prüfen, sonst schlägt der
          Push mit "zone not found" fehl.
      loop: "{{ site_records | default([]) }}"
      loop_control:
        label: "{{ item.fqdn | default(item.name) }}"
      when: item.zone.name != expected_zone_name
    - name: Records auf Technitium anlegen bzw. aktualisieren
      ansible.builtin.uri:
        url: "{{ technitium_url }}/api/zones/records/add"
        method: POST
        body_format: form-urlencoded
        body:
          token: "{{ technitium_token }}"
          domain: "{{ item.fqdn | default(item.name) }}"
          zone: "{{ item.zone.name }}"
          type: "{{ item.type }}"
          ttl: "{{ item.ttl | default(3600, true) }}"
          overwrite: "{{ overwrite_existing | lower }}"
          ipAddress: "{{ item.value }}"
          ptr: "true"
          createPtrZone: "true"
          comments: "{{ managed_comment }}"
        status_code: [200]
        validate_certs: false
      loop: "{{ site_records | default([]) }}"
      loop_control:
        label: "{{ item.fqdn | default(item.name) }}"
      no_log: true
      register: push_result
      failed_when: >-
        push_result.json is defined and push_result.json.status is defined
        and push_result.json.status != 'ok'
      ignore_errors: true
      when: not (dry_run | bool)
    # --- Rückschreiben nach NetBox: Comment + MAC auf dem IP-Address-Objekt ---
    # Analog zu SyncDynamicLeases (dhcp_lease_mac), aber für statisch via
    # NetBox gemanagte Records. MAC kommt aus dem nachgeladenen Interface-
    # Objekt (primary_mac_address, seit NetBox 4.2), bleibt leer wenn die
    # IP keinem Interface zugewiesen ist.
    - name: Alle IP-Address-Objekte der Ziel-VRF aus NetBox holen (ein Bulk-Call statt einer pro Record)
      ansible.builtin.set_fact:
        all_vrf_ip_objects: >-
          {{ query('netbox.netbox.nb_lookup', 'ip-addresses',
                    api_endpoint=netbox_api, token=netbox_token,
                    api_filter='vrf_id=' ~ target_vrf.id,
                    validate_certs=false)
             | map(attribute='value') | list }}
      when: not (dry_run | bool)
    - name: IP-Objekte den gepushten Records lokal zuordnen (kein API-Call pro Record)
      ansible.builtin.set_fact:
        site_ip_objects: >-
          {{ site_ip_objects | default([]) + (
            all_vrf_ip_objects | default([])
            | selectattr('address', 'defined')
            | selectattr('address', 'search', '^' ~ (item.value | ansible.utils.ipaddr('address')) ~ '/')
            | list
          ) }}
      loop: "{{ site_records | default([]) }}"
      loop_control:
        label: "{{ item.fqdn | default(item.name) }}"
      when: not (dry_run | bool)
    - name: Volles Interface-Objekt nachladen (assigned_object liefert nur Kurzform ohne MAC)
      ansible.builtin.uri:
        url: "{{ item.assigned_object.url }}"
        method: GET
        headers:
          Authorization: "Token {{ netbox_token }}"
        status_code: [200]
        validate_certs: false
      loop: "{{ site_ip_objects | default([]) }}"
      loop_control:
        label: "{{ item.address }}"
      when:
        - not (dry_run | bool)
        - item.assigned_object is defined and item.assigned_object is not none
      register: interface_details
    - name: MAC-Lookup-Dictionary aus nachgeladenen Interfaces bauen
      ansible.builtin.set_fact:
        mac_by_ip_id: >-
          {{ mac_by_ip_id | default({}) | combine({
               (result.item.id | string): (result.json.primary_mac_address.mac_address | default('', true))
          }) }}
      loop: "{{ interface_details.results | default([]) }}"
      loop_control:
        loop_var: result
        label: "{{ result.item.address | default('?') }}"
      when:
        - not (dry_run | bool)
        - result.skipped is not defined
    - name: ip_mac_pairs für ALLE Records bauen (MAC aus Lookup, sonst leer)
      ansible.builtin.set_fact:
        ip_mac_pairs: >-
          {{ ip_mac_pairs | default([]) + [{
              'ip_id': item.id,
              'mac': (mac_by_ip_id | default({})).get(item.id | string, '')
          }] }}
      loop: "{{ site_ip_objects | default([]) }}"
      loop_control:
        label: "{{ item.address }}"
      when: not (dry_run | bool)
    - name: Comment + MAC-Custom-Field auf dem NetBox IP-Address-Objekt setzen
      ansible.builtin.uri:
        url: "{{ netbox_api }}/api/ipam/ip-addresses/{{ item.ip_id }}/"
        method: PATCH
        headers:
          Authorization: "Token {{ netbox_token }}"
          Content-Type: "application/json"
        body_format: json
        body:
          description: "{{ managed_comment }}"
          custom_fields:
            dhcp_lease_mac: "{{ item.mac | default(omit) }}"
        status_code: [200]
        validate_certs: false
      loop: "{{ ip_mac_pairs | default([]) }}"
      loop_control:
        label: "{{ item.ip_id }}"
      no_log: true
      register: netbox_patch_result
      failed_when: false
      when: not (dry_run | bool)
    - name: Fehlgeschlagene Technitium-Records auflisten
      ansible.builtin.debug:
        msg: "FEHLER bei {{ item.item.fqdn | default(item.item.name) }}: {{ item.msg | default('unbekannt') }}"
      loop: "{{ push_result.results | default([]) }}"
      loop_control:
        label: "{{ item.item.fqdn | default(item.item.name) }}"
      when:
        - not (dry_run | bool)
        - item.failed | default(false)
    - name: Fehlgeschlagene NetBox-Rückschreib-Requests auflisten
      ansible.builtin.debug:
        msg: "FEHLER bei NetBox IP-Objekt id={{ item.item.ip_id }}: HTTP {{ item.status | default('?') }}"
      loop: "{{ netbox_patch_result.results | default([]) }}"
      loop_control:
        label: "{{ item.item.ip_id }}"
      when:
        - not (dry_run | bool)
        - item.status | default(0) != 200

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.