<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nick Schmidt</title>
    <description>The latest articles on DEV Community by Nick Schmidt (@ngschmidt).</description>
    <link>https://dev.to/ngschmidt</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F347506%2F2a9799f0-45c8-4a6b-8e2f-7560fc902686.jpeg</url>
      <title>DEV Community: Nick Schmidt</title>
      <link>https://dev.to/ngschmidt</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ngschmidt"/>
    <language>en</language>
    <item>
      <title>Grafana PLAG Starter Kit</title>
      <dc:creator>Nick Schmidt</dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ngschmidt/grafana-plag-starter-kit-459j</link>
      <guid>https://dev.to/ngschmidt/grafana-plag-starter-kit-459j</guid>
      <description>&lt;h2&gt;
  
  
  Modern, but complex and highly customizable
&lt;/h2&gt;

&lt;p&gt;The IT industry hasn’t deviated too much from the “fundamentals” of basic Network Monitoring Systems (NMS); software projects like Cacti and Librenms have been a staple for organizations that want to get good infrastructure visibility for decades.&lt;/p&gt;

&lt;p&gt;The “default option” for NMS excels at providing basics (CPU, Memory/Disk monitoring, Interface statistics) but it struggles in a few areas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Correlating events&lt;/li&gt;
&lt;li&gt;End-to-end testing of performance (e.g. synthetic testing)&lt;/li&gt;
&lt;li&gt;Custom metric tracking via API (doable, but it takes some code)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To solve this problem for my home lab, I’ve set a few objectives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All application configuration &lt;strong&gt;must&lt;/strong&gt; be done by a CI/CD template, from code&lt;/li&gt;
&lt;li&gt;All applications &lt;strong&gt;must&lt;/strong&gt; be modular and scalable&lt;/li&gt;
&lt;li&gt;All monitoring data &lt;strong&gt;must&lt;/strong&gt; be visible from one front-end&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How it Works
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqyx6wi405670nyaoyjd5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqyx6wi405670nyaoyjd5.png" alt="Diagram" width="800" height="845"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Grafana works as a presentation layer / front end; it doesn’t store the presented data&lt;/li&gt;
&lt;li&gt;Loki stores log data, and it’s picky. It receives log data from Alloy and presents it over an HTTP API&lt;/li&gt;
&lt;li&gt;Prometheus ingests and stores metrics, time series data like memory %, CPU and presents it over an HTTP API&lt;/li&gt;
&lt;li&gt;Exporters (Gatus in this example) expose their metrics to Prometheus in a standard HTTP API format (&lt;code&gt;/metrics&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Grafana’s ecosystem may feel a little messy, but it’s just how microservices are designed to function. Each service focuses on delivering a specific function instead of trying to do it all.&lt;/p&gt;

&lt;p&gt;The IaC (Ansible playbooks, Jinja templates, Docker compose files) here build this out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Grafana (systemd unit)&lt;/li&gt;
&lt;li&gt;Prometheus (systemd unit)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;prometheus-node-exporter&lt;/code&gt; - on Hypervisors and the Grafana VM&lt;/li&gt;
&lt;li&gt;Alloy (container), Scraping the local journal, docker logs, and a syslog forwarder&lt;/li&gt;
&lt;li&gt;Loki (container), receives data from Alloy&lt;/li&gt;
&lt;li&gt;Gatus, tests service reachability and generates Prometheus metrics&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Deployment
&lt;/h2&gt;

&lt;p&gt;To build this, I created a new host inventory tag in Netbox, and used the linked inventory. Here is the code:&lt;/p&gt;

&lt;p&gt;&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;&lt;a class="mentioned-user" href="https://dev.to/import"&gt;@import&lt;/a&gt; url(&amp;amp;#39;&amp;lt;a href="https://cdn.rawgit.com/lonekorean/gist-syntax-themes/d49b91b3/stylesheets/idle-fingers.css&amp;amp;#x27;"&amp;gt;https://cdn.rawgit.com/lonekorean/gist-syntax-themes/d49b91b3/stylesheets/idle-fingers.css&amp;amp;#39;&amp;lt;/a&amp;gt;);&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;&lt;a class="mentioned-user" href="https://dev.to/import"&gt;@import&lt;/a&gt; url(&amp;amp;#39;&amp;lt;a href="https://fonts.googleapis.com/css?family=Open+Sans&amp;amp;#x27;"&amp;gt;https://fonts.googleapis.com/css?family=Open+Sans&amp;amp;#39;&amp;lt;/a&amp;gt;);&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;body {&amp;lt;br&amp;gt;
    font: 16px &amp;amp;#39;Open Sans&amp;amp;#39;, sans-serif;&amp;lt;br&amp;gt;
  }&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;body .gist .gist-file {&amp;lt;br&amp;gt;
    border-color: #555 #555 #444&amp;lt;br&amp;gt;
  }&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;body .gist .gist-data {&amp;lt;br&amp;gt;
    border-color: #555&amp;lt;br&amp;gt;
  }&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;body .gist .gist-meta {&amp;lt;br&amp;gt;
    color: #ffffff;&amp;lt;br&amp;gt;
    background: #373737;&amp;lt;br&amp;gt;
  }&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;body .gist .gist-meta a {&amp;lt;br&amp;gt;
    color: #ffffff&amp;lt;br&amp;gt;
  }&amp;lt;br&amp;gt;
&lt;/p&gt;

&lt;p&gt;The Ansible playbook delivers all configurations, adds relevant repositories and installs packages all in one shot.&lt;/p&gt;

&lt;p&gt;This took a lot of trial and error to build, and it thoroughly validated my choices to manage the deployment with CI/CD. Here are some of the gotchas I ran into along the way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker forwards TCP &lt;em&gt;only&lt;/em&gt; by default when you specify a port in compose, so syslog needs &lt;code&gt;/udp&lt;/code&gt; appended to it&lt;/li&gt;
&lt;li&gt;Prometheus’ systemd service didn’t easily let me set a custom listening address, so I customized the systemd unit&lt;/li&gt;
&lt;li&gt;Uptime-Kuma may be popular for localized monitoring, but it does not support programmatic configuration. Gatus does, and it quickly became my alternative&lt;/li&gt;
&lt;li&gt;Alloy can scrape all kinds of useful logs that would otherwise be hard to get!&lt;/li&gt;
&lt;li&gt;Loki and Prometheus rotate storage by default, so if they’re assigned a persistent storage volume the application itself will keep things nice and tidy. Retention policies are well-supported.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  After Deployment, it’s still complicated
&lt;/h2&gt;

&lt;p&gt;Here’s where I think most people get stuck with Grafana - there is a dizzying array of different, all highly valuable metrics and none of the monitoring is “canned”; all dashboarding/alerting/presentation must by assembled manually.&lt;/p&gt;

&lt;p&gt;Here are the most useful metrics from what I’ve seen so far:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gatus Exporter

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gatus_results_certificate_expiration_seconds&lt;/code&gt;: This can be translated by Grafana into a more meaningful interval, e.g. &lt;code&gt;weeks&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gatus_results_duration_seconds&lt;/code&gt;: Transaction time for a monitored asset. “Latency”&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Node Exporter

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;node_filesystem_free_bytes&lt;/code&gt;: Proactively detect when the disk is full&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;node_filesystem_readonly&lt;/code&gt;: FSRO is a very real issue in shared infrastructure, and detecting it is usually symptom-based (once something’s failed)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;node_memory_MemAvailable_bytes&lt;/code&gt;: This metric helps disambiguate filesystem caching for those unfamiliar with Linux - this eliminates users’ need to understand that and one simple metric can be tracked instead&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;node_nf_conntrack_entries&lt;/code&gt;: Open connections (UDP/TCP for the system)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;node_os_info&lt;/code&gt;: Not really useful for monitoring, but it’s possible to report on a fleet’s OS inventory this way&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;node_reboot_required&lt;/code&gt;: Updates will trigger this flag, if a patching workflow doesn’t automatically include a reboot step this is a useful thing to track&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;node_time_clocksource_current_info&lt;/code&gt;: Detect when NTP stops doing what it should&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Security
&lt;/h2&gt;

&lt;p&gt;All of this network traffic is cleartext, which with some exporters (&lt;code&gt;json_exporter&lt;/code&gt; in particular) is a problem, because it will be sending or storing credentials in cleartext.&lt;/p&gt;

&lt;p&gt;If confidentiality is not a concern, a way to enforce some level of hardening would be to implement a firewall (&lt;code&gt;ufw&lt;/code&gt;, AWS Security Groups, Azure Network Security Groups) only allowing Prometheus access to the Alloy or exporter agents. This is very simple and should be considered the minimum.&lt;/p&gt;

&lt;p&gt;If confidentiality is a concern, each component will need to implement TLS, and it’s done differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://prometheus.io/docs/guides/tls-encryption/" rel="noopener noreferrer"&gt;Prometheus&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://grafana.com/docs/alloy/latest/reference/config-blocks/http/" rel="noopener noreferrer"&gt;Alloy&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://grafana.com/docs/loki/latest/configure/#server" rel="noopener noreferrer"&gt;Loki&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Docker and systemd both have a weakness when it comes to storing key material in the filesystem - it has to be retrieved from somewhere. I’d recommend exploring Hashicorp Vault or any other CI alternatives for just-in-time storage of key material&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;PLAG (Prometheus, Loki, Alloy, Grafana) is an excellent stack to build extensible and comprehensive monitoring for a platform, but it doesn’t make it easy. Commercial products excel at “easy button” deployments where you don’t really need to comprehensively learn your environment to monitor it effectively, and none of that is provided here.&lt;/p&gt;

&lt;p&gt;If the Grafana ecosystem is on your shortlist, I’d recommend having an honest conversation about how much time is available to invest in monitoring; Grafana is high-effort and high-reward. All of this data is worth it, but it will take some work to get things usable.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Idempotently Manage Ubiquiti Unifi resources with Ansible</title>
      <dc:creator>Nick Schmidt</dc:creator>
      <pubDate>Fri, 17 Apr 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ngschmidt/idempotently-manage-ubiquiti-unifi-resources-with-ansible-3j0</link>
      <guid>https://dev.to/ngschmidt/idempotently-manage-ubiquiti-unifi-resources-with-ansible-3j0</guid>
      <description>&lt;p&gt;Ubiquiti is beginning to roll out inbound APIs for their current product line (&lt;a href="https://developer.ui.com/site-manager/v1.0.0/gettingstarted" rel="noopener noreferrer"&gt;documentation here&lt;/a&gt;) as part of a broader effort to enable programmability. There are a number of useful features here, so I’ll provide my summary assessment of the state of things first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ubiquiti is focused on monitoring first, so any Site Manager or Network endpoints in active development will first pick up telemetry and statistics&lt;/li&gt;
&lt;li&gt;Idempotency isn’t achieved by the API itself, so practice due care when applying things to an existing site&lt;/li&gt;
&lt;li&gt;There are probably more API endpoints than there are documented. I’m going to assume that if an endpoint isn’t documented, it may not be 100% ready&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Like with all new API implementations, we do see some typical late-comer benefits here. The Ubiquiti developer portal provides code generators for common API consumption tasks, which provides both an easy way to get started and a rough idea of how the API integrations should work ( &lt;strong&gt;Read: any idiosyncrasies&lt;/strong&gt; ).&lt;/p&gt;

&lt;h2&gt;
  
  
  Building an Action
&lt;/h2&gt;

&lt;p&gt;As always, I try to keep all deployment automation encapsulated into a CI/CD pipeline. For this example, I’m also going to use 1Password’s Devops tooling for password management. This GitHub Action also includes leveraging a Python 3 &lt;code&gt;venv&lt;/code&gt; to ensure no pre-existing gunk is carried over from the OS, and it will install Ubiquiti’s latest Ansible collection from scratch on every execution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note: This will require a &lt;code&gt;requirements.txt&lt;/code&gt; file with all Python 3 dependencies, as it doesn’t use the system’s packages. Examples below.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Action
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;On-Demand:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Build&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Unifi'&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Build&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Configurations&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(Unifi)'&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;self-hosted&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure 1Password&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1password/load-secrets-action/configure@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service-account-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Load secret&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1password/load-secrets-action@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="c1"&gt;# Export loaded secrets as environment variables&lt;/span&gt;
          &lt;span class="na"&gt;export-env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;UNIFI_API&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;op://devops/unifi_api/hostname'&lt;/span&gt;
          &lt;span class="na"&gt;UNIFI_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;op://devops/unifi_api/credential'&lt;/span&gt;
          &lt;span class="na"&gt;UNIFI_SITE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;op://devops/unifi_api/text'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Build&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Unifi'&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;python3 -m venv .&lt;/span&gt;
          &lt;span class="s"&gt;source bin/activate&lt;/span&gt;
          &lt;span class="s"&gt;python3 -m pip install --upgrade pip&lt;/span&gt;
          &lt;span class="s"&gt;python3 -m pip install -r requirements.txt&lt;/span&gt;
          &lt;span class="s"&gt;curl -L -o ubiquiti-unifi_api-latest.tar.gz https://apidoc-cdn.ui.com/ansible-module/ubiquiti-unifi_api-latest.tar.gz&lt;/span&gt;
          &lt;span class="s"&gt;ansible-galaxy collection install ubiquiti-unifi_api-latest.tar.gz&lt;/span&gt;
          &lt;span class="s"&gt;ansible-playbook build_unifi.yml&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;roles/unifi&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;requirements.txt&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The most important one to include here is &lt;code&gt;httpx&lt;/code&gt;. The software package provided by Ubiquiti doesn’t provide it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;###### Requirements without Version Specifiers ######
jinja2
requests
urllib3
httpx

###### Requirements with Version Specifiers ######
ansible &amp;gt;= 8.4.0 # Mostly just don't use old Ansible (e.g. v2, v3)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Building a Playbook
&lt;/h2&gt;

&lt;p&gt;Unifi’s Ansible offering all appears to leverage the same API wrapper module, mostly just for some quality of life enhancements like attaching API keys to HTTP headers, and &lt;code&gt;module_defaults&lt;/code&gt; that prevent repetitive code.&lt;/p&gt;

&lt;p&gt;Like with most network product playbooks, these executions are not designed to execute on a target node, and will require some tweaks. Don’t &lt;code&gt;become&lt;/code&gt;, don’t &lt;code&gt;gather_facts&lt;/code&gt;, and force controller execution.&lt;/p&gt;

&lt;p&gt;Before we start applying configurations, we need to be aware of the controller’s &lt;code&gt;site&lt;/code&gt; options. The example here is a multi-site configuration, which would be the same as their SaaS controller. The following playbook will be my start point; it will leverage the Unifi Site Manager API and use &lt;code&gt;selectaddr&lt;/code&gt; to return the first entry matching the assigned &lt;code&gt;name&lt;/code&gt; (from the environment variable):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Build&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Unifi&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Configs'&lt;/span&gt;
  &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
  &lt;span class="na"&gt;gather_facts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="c1"&gt;# Before executing ensure that the prerequisites are installed&lt;/span&gt;
  &lt;span class="c1"&gt;# We start with a pre-check playbook, if it fails, we don't want to&lt;/span&gt;
  &lt;span class="c1"&gt;# make changes&lt;/span&gt;
  &lt;span class="na"&gt;any_errors_fatal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;module_defaults&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;group/ubiquiti.unifi_api.common&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;base_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup('env',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'UNIFI_API')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
      &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup('env',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'UNIFI_API_KEY')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
  &lt;span class="na"&gt;vars&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;unifi_site_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup('env',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'UNIFI_SITE')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
  &lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Get&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Sites&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;List'&lt;/span&gt;
      &lt;span class="na"&gt;ubiquiti.unifi_api.network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/v1/sites'&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;GET'&lt;/span&gt;
      &lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;get_sites_result&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Select&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;site&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;based&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;name'&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.set_fact&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;unifi_site_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;get_sites_result.data.data&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;selectattr('name',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'equalto',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;unifi_site_name)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;first&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Print&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Site&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;data'&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.debug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;msg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Site&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;data:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;unifi_site_data&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now - about Ubiquiti’s Ansible module. It’s not &lt;em&gt;idemopotent&lt;/em&gt;, meaning that repeated runs of the same playbook are not “safe”; it will blindly apply the same change over itself without determining if any change is necessary. Repeated runs of a &lt;code&gt;POST&lt;/code&gt; return an error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;fatal:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;localhost&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;FAILED!&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"changed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"api.network.validation.vlan-id-conflict"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"VLAN ID 22 is already in use by network: test_net"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"requestId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"66afeb7c-aa6b-4532-9217-23734f386809"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"requestPath"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/integration/v1/sites/{{site }}/networks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"statusCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"statusName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BAD_REQUEST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-04-12T18:11:39.682017935Z"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"API call failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While this is better than, say, destroying and recreating a VLAN with devices in it, idempotency is developer’s responsibility. We need to modify the following play:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Create&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Network'&lt;/span&gt;
      &lt;span class="na"&gt;ubiquiti.unifi_api.network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/v1/sites/{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;unifi_site_data.id&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/networks'&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;POST'&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;management&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;UNMANAGED'&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;test_net'&lt;/span&gt;
          &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;vlanId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;22&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To be more clever. The ideal method here would be to write our own Ansible module, but that negates the benefits of using a vendor-provided module (simplicity). Here’s an improvised way to handle things - first, we must gather the filtered list of VLANs from the Unifi API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Get&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Networks'&lt;/span&gt;
      &lt;span class="na"&gt;ubiquiti.unifi_api.network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/v1/sites/{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;unifi_site_data.id&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/networks'&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;GET'&lt;/span&gt;
        &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;or(vlanId.eq({{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.vlanId&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}),&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;name.eq('{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'))"&lt;/span&gt;
      &lt;span class="na"&gt;loop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vlans&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
      &lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;get_vlans_result&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will submit an API request for each VLAN we intend to “manage” with Ansible, &lt;code&gt;filter&lt;/code&gt;ed per Ubiquiti’s documentation &lt;a href="https://developer.ui.com/network/v10.1.84/filtering" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Ansible then formats the results from each run specially when we use a &lt;code&gt;loop&lt;/code&gt;. It’ll produce a &lt;code&gt;list&lt;/code&gt; with the key &lt;code&gt;results&lt;/code&gt;, which provides plenty of metadata from the run! &lt;strong&gt;Both&lt;/strong&gt; the &lt;code&gt;item.item&lt;/code&gt; (the variables &lt;strong&gt;we&lt;/strong&gt; provided) and the &lt;code&gt;item.data.data[0]&lt;/code&gt; variables are useful, allowing us to create a decision tree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If&lt;/strong&gt; &lt;code&gt;item.data.data&lt;/code&gt; size is 0, then it’s a new VLAN. Create from &lt;code&gt;item.item&lt;/code&gt; ( &lt;strong&gt;&lt;code&gt;POST&lt;/code&gt;&lt;/strong&gt; )&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If&lt;/strong&gt; &lt;code&gt;item.data.data&lt;/code&gt; and &lt;code&gt;item.item&lt;/code&gt; &lt;em&gt;don’t&lt;/em&gt; match, update ( &lt;strong&gt;&lt;code&gt;PUT&lt;/code&gt;&lt;/strong&gt; )&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If&lt;/strong&gt; &lt;code&gt;item.data.data&lt;/code&gt; and &lt;code&gt;item.item&lt;/code&gt; &lt;em&gt;do&lt;/em&gt; match, don’t do anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here’s an example output below. It’s generic to Ansible &lt;code&gt;loop&lt;/code&gt;, so it can be re-used for just about anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"changed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"All items completed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="nl"&gt;"ansible_loop_var"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="nl"&gt;"changed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="nl"&gt;"failed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="nl"&gt;"item"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"ansible_loop_var"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"changed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                     &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                     &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                     &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"uuid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                     &lt;/span&gt;&lt;span class="nl"&gt;"management"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"UNMANAGED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                     &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt;
                        &lt;/span&gt;&lt;span class="nl"&gt;"origin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"USER_DEFINED"&lt;/span&gt;&lt;span class="w"&gt;
                     &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
                     &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"test_net"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                     &lt;/span&gt;&lt;span class="nl"&gt;"vlanId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"limit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"totalCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"failed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"invocation"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"module_args"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"api_key_header"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"X-API-KEY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"base_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"***"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"ca_path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"client_cert"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"client_key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"console_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"files"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt;

                  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"/v1/sites/uuid/networks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt;
                     &lt;/span&gt;&lt;span class="nl"&gt;"filter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"or(vlanId.eq(22), name.eq('TestVlan'))"&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"VALUE_SPECIFIED_IN_NO_LOG_PARAMETER"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"validate_certs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"item"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"management"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"UNMANAGED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"TestVlan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"vlanId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"{'name': 'TestVlan', 'vlanId': 22, 'enabled': True, 'management': 'UNMANAGED'} was found as a potential match for {'management': 'UNMANAGED', 'id': 'uuid', 'name': 'test_net', 'enabled': True, 'vlanId': 22, 'metadata': {'origin': 'USER_DEFINED'}, 'default': False}"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"skipped"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here’s an example for the Create/Update plays, with the conditional logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Update&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pre-existing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;VLANs'&lt;/span&gt;
      &lt;span class="na"&gt;ubiquiti.unifi_api.network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/v1/sites/{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;unifi_site_data.id&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/networks/{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.data.data[0].id&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PUT'&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;management&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.item.management&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.item.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.item.enabled&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;vlanId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.item.vlanId&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
      &lt;span class="na"&gt;loop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;get_vlans_result.results&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
      &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;item.item.name != item.data.data[0].name&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Create&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;VLANs'&lt;/span&gt;
      &lt;span class="na"&gt;ubiquiti.unifi_api.network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/v1/sites/{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;unifi_site_data.id&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/networks'&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;POST'&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;management&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.item.management&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.item.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.item.enabled&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;vlanId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.item.vlanId&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
      &lt;span class="na"&gt;loop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;get_vlans_result.results&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
      &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;item.data.data | length == &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that’s a (slightly verbose) method to implement idempotency with an Ansible module that doesn’t provide it natively, without code. The &lt;code&gt;when&lt;/code&gt; directive for the update method takes a &lt;code&gt;list&lt;/code&gt;, each field you want to test for a match will &lt;strong&gt;have to be explicitly defined as a test&lt;/strong&gt;. This can get pretty top-heavy pretty quickly, which is why most mature API providers just process a client request and implement idempotency on the backend.&lt;/p&gt;

&lt;p&gt;The only downside to waiting for a provider to do that is that you’ll be waiting a while.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fse7pfpm96qa2u6yv09fr.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fse7pfpm96qa2u6yv09fr.jpg" alt="Skeleton Waiting" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Starting an IaC Repository with GitHub and Terraform</title>
      <dc:creator>Nick Schmidt</dc:creator>
      <pubDate>Sat, 13 Dec 2025 09:00:00 +0000</pubDate>
      <link>https://dev.to/ngschmidt/starting-an-iac-repository-with-github-and-terraform-54bk</link>
      <guid>https://dev.to/ngschmidt/starting-an-iac-repository-with-github-and-terraform-54bk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;There are only two hard things in Computer Science: cache invalidation, naming things, and off by one errors.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;Loosely attributed to Phil Karlton&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's start off with a bit of a hot take - Terraform isn't particularly hard to learn. It does use unique configuration languages, but most people don't struggle with learning the code.&lt;/p&gt;

&lt;p&gt;Infrastructure-as-Code (IaC) isn't about the programming language - &lt;em&gt;it's about establishing a body of discipline around managing infrastructure&lt;/em&gt;. Tools like Ansible and Terraform simply facilitate the practice.&lt;/p&gt;

&lt;p&gt;Instead of focusing on some programmatically elegant tricks here, let's try to focus on how to build a "starter kit" of sorts to build upon this practice. The &lt;em&gt;managed resources&lt;/em&gt; in this example will be intentionally simple to shift focus to the structure, naming, and release management aspects of Infrastructure-as-Code.&lt;/p&gt;

&lt;p&gt;[&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F66swsmobt38z2st5lefz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F66swsmobt38z2st5lefz.png" alt="IaC Starter Kit" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;](iac_starter_kit.png)&lt;/p&gt;

&lt;h2&gt;
  
  
  Repositories (Structure and Naming)
&lt;/h2&gt;

&lt;p&gt;Start a GitHub repository with some basic documentation before contributing code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;README.md&lt;/code&gt; should describe what the project is for, describe the project structure: how the software works.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;USAGE.md&lt;/code&gt; should describe how to consume resources within the project, how release management works.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CONTRIBUTING.md&lt;/code&gt; should describe how to contribute to the codebase: the branch and merge workflows and rules of conduct go here.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CHANGELOG.md&lt;/code&gt; should be created based on the &lt;a href="https://keepachangelog.com/en/1.0.0/" rel="noopener noreferrer"&gt;Keep a Changelog&lt;/a&gt; standards&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.gitignore&lt;/code&gt; should make sure that any temporary files created by tools, like &lt;code&gt;pycache&lt;/code&gt;, Terraform locks don't accidentally get committed to the repository&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;markdownlint.json&lt;/code&gt; and any other linting rules - automated code QC is a good thing&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;img/&lt;/code&gt; should be created to contain rendered images for documentation. Use illustrations to make the repository easy to understand!&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dwg/&lt;/code&gt; should be created to contain unrendered diagrams, e.g. &lt;code&gt;svg&lt;/code&gt;, &lt;code&gt;d2&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;doc/&lt;/code&gt; may be created for any automatically rendered documentation, e.g. ReadTheDocs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once these are created, start mapping out what loose structures should be included in the repository. Here are some examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;conf.d/&lt;/code&gt; for any flat file configurations that may get deployed

&lt;ul&gt;
&lt;li&gt;Make subdirectories for any machine targets&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;roles/&lt;/code&gt; for any Ansible roles. Since this is IaC, breaking this down into roles instead of one giant pile will be simpler

&lt;ul&gt;
&lt;li&gt;Within each &lt;code&gt;role&lt;/code&gt;:&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;templates/&lt;/code&gt; should contain any Jinja2 templates. Ansible will auto-detect this folder by name, and it simplifies structure quite a bit.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;requirements.txt&lt;/code&gt; should contain any software prerequisites for the Ansible playbooks. This facilitates CI/CD tooling with virtual environments, in addition to better documenting software dependencies.&lt;/li&gt;
&lt;li&gt;Playbooks and truth files, of course&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;terraform/&lt;/code&gt; for any Terraform code

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;modules/&lt;/code&gt; for any Terraform re-usable modules&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;accounts/&lt;/code&gt; for any Terraform tenants, e.g. AWS Accounts, CloudFlare accounts, or other unrelated resources to keep them separate and organized&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;python/&lt;/code&gt; for any Python code&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;js/&lt;/code&gt; for any JavaScript&lt;/li&gt;

&lt;li&gt;...and so on.&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;Now that the raw structure is somewhat laid out, we can shift focus to the Terraform account's subdirectory (in &lt;code&gt;/terraform/accounts/{{ account_type }}_{{ account_id }}_{{account_name}}&lt;/code&gt;) structure. Here's what I've seen lead to a maintainable code base:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/terraform/accounts/cloudflare_12345_engyak_co&lt;/code&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;templates/&lt;/code&gt; for any &lt;code&gt;gotmpl&lt;/code&gt; templates&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;provider.tf&lt;/code&gt; should declare any Terraform pre-requisites, e.g. the Cloudflare provider minimum version&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vars.tf&lt;/code&gt; should declare any input variables. In my experience, this is a good place for module inputs, but not as useful for actual infrastructure declarations&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;locals.tf&lt;/code&gt; should declare any Don't Repeat Yourself (DRY) variables. I typically use them for consistent resource names and IDs. There are a lot of opinions about &lt;code&gt;vars&lt;/code&gt; versus &lt;code&gt;locals&lt;/code&gt;, but there are a few key differences:&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vars&lt;/code&gt; should actually be variable (non-static multiples of a &lt;code&gt;resource&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;locals&lt;/code&gt; can render and iterate on an input, e.g. with &lt;code&gt;for_each&lt;/code&gt; loops&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;backend.tf&lt;/code&gt; should indicate where &lt;code&gt;terraform.tfstate&lt;/code&gt; is placed, any file locking. Normally, this points to an S3 bucket and provides authorization for it&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data.tf&lt;/code&gt; should have any external data resources. This example doesn't need any, but AWS IAM policy documents and S3 bucket policies fit this category. Any resource prefixed with &lt;code&gt;data&lt;/code&gt; instead of &lt;code&gt;resource&lt;/code&gt; goes here, essentially&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;Now that all that's out of the way, we're able to &lt;em&gt;actually create resources&lt;/em&gt;. Things can be a lot more free-form here, because the definition of &lt;em&gt;related resources&lt;/em&gt; can vary greatly based on who's doing the work.&lt;/p&gt;

&lt;p&gt;My personal preference is to maintain small, easily readable files that function independently wherever possible. In this example, we'll use one file for each DNS zone. Here's &lt;code&gt;/terraform/accounts/cloudflare_youwish_engyak_co/engyak.co.tf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 1resource "cloudflare_record" "engyak_co_blog" {
 2 content = "blog-engyak-co.pages.dev"
 3 name = "blog"
 4 proxied = false
 5 ttl = 1
 6 type = "CNAME"
 7 zone_id = "redacted"
 8}
 9
10resource "cloudflare_record" "engyak_co_root" {
11 content = "blog-engyak-co.pages.dev"
12 name = "engyak.co"
13 proxied = true
14 ttl = 1
15 type = "CNAME"
16 zone_id = "redacted"
17}
18
19resource "cloudflare_record" "engyak_co_uri_blog" {
20 name = "engyak.co"
21 priority = 1
22 proxied = false
23 ttl = 1
24 type = "URI"
25 zone_id = "redacted"
26 data {
27 target = "blog.engyak.co"
28 weight = 1
29 }
30}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These &lt;code&gt;resource&lt;/code&gt;s are built according to the &lt;code&gt;provider&lt;/code&gt; in &lt;code&gt;provider.tf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 1terraform {
 2 required_providers {
 3 cloudflare = {
 4 source = "cloudflare/cloudflare"
 5 version = "~&amp;gt; 4"
 6 }
 7 }
 8}
 9
10provider "cloudflare" {
11}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always consult the &lt;code&gt;provider&lt;/code&gt;'s documentation on how to use their &lt;code&gt;resource&lt;/code&gt;s.&lt;/p&gt;

&lt;h2&gt;
  
  
  Actions (Release Management)
&lt;/h2&gt;

&lt;p&gt;The biggest advantage a Git repository has for Infrastructure-as-Code is its versioning capability, but the ability to control the release of changes can really take things to the next level.&lt;/p&gt;

&lt;p&gt;First, I'd recommend starting out with a &lt;em&gt;branch management plan&lt;/em&gt;. It can start simple, like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Don't allow any commits directly to &lt;code&gt;main&lt;/code&gt; (GitHub branch protection rules, plus general threads in &lt;code&gt;CONTRIBUTING.md&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Only allow code to be pushed to &lt;code&gt;main&lt;/code&gt; via a successful pull request (GitHub branch protection rules do this as well)

&lt;ul&gt;
&lt;li&gt;At least 1 approving peer review&lt;/li&gt;
&lt;li&gt;All testing must &lt;strong&gt;PASS&lt;/strong&gt; (more on this later)&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;All prospective changes must start as a diverging branch (or fork, but forking is &lt;em&gt;much&lt;/em&gt; more advanced) that is &lt;strong&gt;up-to-date&lt;/strong&gt; with &lt;code&gt;main&lt;/code&gt;
&lt;/li&gt;

&lt;li&gt;Outline appropriate change windows, if applicable&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;At this point, the rules are in place, but none of it actually controls release. GitHub doesn't have credentials to release changes; ideally no users should either. The objective here is to &lt;strong&gt;prevent all direct changes to infrastructure&lt;/strong&gt;. This can be achieved with AWS IAM roles, Cloudflare RBAC, or an equivalent. Take away the keys!&lt;/p&gt;

&lt;p&gt;GitHub Actions provides a (usually free or cheap) amnesic container service to run ephemeral code from source control. This is going to be the foundation for this example moving forward, but other providers like GitLab and Atlassian have equivalents as well. If the source control provider doesn't have a built-in service, plenty of other CI tools exist to fill that gap, like Jenkins and Concourse.&lt;/p&gt;

&lt;p&gt;For a Terraform pipeline, there should be two Actions per account:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;terraform plan&lt;/code&gt;: This will test your code for validity, and also explain any potential impacts the change might have&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terraform apply&lt;/code&gt;: This will implement tested changes. This Action &lt;em&gt;should&lt;/em&gt; be restricted to the &lt;code&gt;main&lt;/code&gt; branch!&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's an example &lt;code&gt;plan&lt;/code&gt; Action. I named it based on `{{ event trigger }}: {{ provider }} {{ action }} to keep things organized.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
 1---&lt;br&gt;
 2name: 'On-Commit: Cloudflare Terraform Plan'&lt;br&gt;
 3&lt;br&gt;
 4on:&lt;br&gt;
 5 push:&lt;br&gt;
 6&lt;br&gt;
 7permissions:&lt;br&gt;
 8 contents: read&lt;br&gt;
 9&lt;br&gt;
10jobs:&lt;br&gt;
11 plan:&lt;br&gt;
12 name: 'Terraform Plan'&lt;br&gt;
13 env:&lt;br&gt;
14 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}&lt;br&gt;
15 runs-on: ubuntu-latest&lt;br&gt;
16 steps:&lt;br&gt;
17 - uses: actions/checkout@v4&lt;br&gt;
18 - name: 'Terraform Setup'&lt;br&gt;
19 uses: hashicorp/setup-terraform@v3&lt;br&gt;
20 with:&lt;br&gt;
21 terraform_version: '&amp;gt;= 1.10.5'&lt;br&gt;
22 - name: 'Terraform Plan'&lt;br&gt;
23 run: |&lt;br&gt;
24 terraform init&lt;br&gt;
25 terraform validate&lt;br&gt;
26 terraform plan -input=false&lt;br&gt;&lt;br&gt;
27 working-directory: terraform/accounts/cloudflare_youwish_engyak_co/&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Here's a rundown on how the testing works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We use the &lt;code&gt;env&lt;/code&gt; directive to expose &lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt; (specified in the &lt;code&gt;cloudflare&lt;/code&gt; provider as the way to pass credentials)&lt;/li&gt;
&lt;li&gt;We use &lt;code&gt;actions/checkout@v4&lt;/code&gt; (or latest version) to load a copy of &lt;code&gt;main&lt;/code&gt; into the Actions runner&lt;/li&gt;
&lt;li&gt;We use &lt;code&gt;hashicorp/setup-terraform@v3&lt;/code&gt;. Previous Actions runners shipped with Terraform, but the base image didn't update this package frequently enough. Now it doesn't ship with the image - but this tool lets us restrict and control software versions as part of the pipeline. This lets us slow releases if breaking changes occur with &lt;code&gt;terraform&lt;/code&gt; without having to monkey around with internals - it's a much better system.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;Terraform Plan&lt;/code&gt; step is where most of the work gets done. We initialize Terraform in &lt;strong&gt;non-interactive mode&lt;/strong&gt; (&lt;code&gt;-input=false&lt;/code&gt;) using our workspace with the &lt;code&gt;working-directory&lt;/code&gt; key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This will now run every time code is committed to the repository, and it'll display any expected changes every time code is contributed. If it fails, it will produce an error and (ideally) notify engineers/developers on where to fix it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note: &lt;code&gt;terraform validate&lt;/code&gt; and &lt;code&gt;terraform plan&lt;/code&gt; do not catch all problems, just test for config validity. Resource conflicts, API idiosyncrasies will pass this step and only reveal things on &lt;code&gt;apply&lt;/code&gt;!&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Now, we can finally start releasing changes:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
 1---&lt;br&gt;
 2name: 'Cron-Demand: Cloudflare Terraform Apply'&lt;br&gt;
 3&lt;br&gt;
 4on:&lt;br&gt;
 5 workflow_dispatch:&lt;br&gt;
 6 branches: ['main']&lt;br&gt;
 7 schedule:&lt;br&gt;
 8 - cron: "15 4,5 * * *"&lt;br&gt;
 9&lt;br&gt;
10permissions:&lt;br&gt;
11 contents: read&lt;br&gt;
12&lt;br&gt;
13jobs:&lt;br&gt;
14 plan:&lt;br&gt;
15 name: 'Terraform Plan'&lt;br&gt;
16 env:&lt;br&gt;
17 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}&lt;br&gt;
18 runs-on: ubuntu-latest&lt;br&gt;
19 steps:&lt;br&gt;
20 - uses: actions/checkout@v4&lt;br&gt;
21 - name: 'Terraform Setup'&lt;br&gt;
22 uses: hashicorp/setup-terraform@v3&lt;br&gt;
23 with:&lt;br&gt;
24 terraform_version: '&amp;gt;= 1.10.5'&lt;br&gt;
25 - name: 'Terraform Plan'&lt;br&gt;
26 id: tf_plan&lt;br&gt;
27 run: |&lt;br&gt;
28 terraform init&lt;br&gt;
29 terraform validate&lt;br&gt;
30 terraform plan -input=false --detailed-exitcode&lt;br&gt;&lt;br&gt;
31 continue-on-error: true&lt;br&gt;
32 working-directory: terraform/accounts/cloudflare_youwish_engyak_co/&lt;br&gt;
33 - name: 'Terraform Apply'&lt;br&gt;
34 run: |&lt;br&gt;
35 terraform apply&lt;br&gt;&lt;br&gt;
36 working-directory: terraform/accounts/cloudflare_youwish_engyak_co/&lt;br&gt;
37 if: github.ref != 'refs/heads/main' &amp;amp;&amp;amp; needs.tf_plan.outputs.exit-code == 2&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This Action will either run daily at 0415-0515 UTC or if executed manually. We've established a "change window", and there are quite a few more complexities added to this workflow to implemet change safety:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;detailed-exitcode&lt;/code&gt; and &lt;code&gt;id: tf_plan&lt;/code&gt; allow us to "catch" the results of &lt;code&gt;terraform plan&lt;/code&gt;. A return code of &lt;code&gt;0&lt;/code&gt; means no changes required, and &lt;code&gt;2&lt;/code&gt; means changes are required.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;if:&lt;/code&gt; conditionals restrict the dangerous parts of the workflow to &lt;strong&gt;only&lt;/strong&gt; execute when the branch is &lt;code&gt;main&lt;/code&gt; and &lt;code&gt;plan&lt;/code&gt; is valid and expects changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Terraform Starter Kit
&lt;/h2&gt;

&lt;p&gt;This template should act as a foundational "starter kit" for establishing an effective, robust, mature Infrastructure-as-Code practice. I've found that it's easier to modify and improve an existing process than to start anew - the objective here is to get engineers past that "writer's block."&lt;/p&gt;

&lt;p&gt;Happy coding!&lt;/p&gt;

</description>
      <category>devops</category>
      <category>github</category>
      <category>tutorial</category>
      <category>terraform</category>
    </item>
    <item>
      <title>Automate Netbox Inventory Ingestion with Diode</title>
      <dc:creator>Nick Schmidt</dc:creator>
      <pubDate>Fri, 05 Dec 2025 09:00:00 +0000</pubDate>
      <link>https://dev.to/ngschmidt/automate-netbox-inventory-ingestion-with-diode-3mme</link>
      <guid>https://dev.to/ngschmidt/automate-netbox-inventory-ingestion-with-diode-3mme</guid>
      <description>&lt;p&gt;Data processing, particularly with large estates, can be a really painful process. There are a few ways to break down massive data imports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build a giant spreadsheet, ship it to the GUI, and hope that things go well&lt;/li&gt;
&lt;li&gt;Bite-size the import work and write some code to supervise line-by-line import of data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Generally, flooding a web application with a giant pile of load all at once isn’t the greatest idea, particularly if there are failures. It’s not great for the application itself, as usually API requests are prioritized in a way that doesn’t put them before more urgent user queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enter &lt;a href="https://netboxlabs.com/docs/diode/" rel="noopener noreferrer"&gt;Diode&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;Diode is a software tool that can support ingestion jobs from multiple other data sources, and releases it to Netbox for storage after processing. Think of it like a staging area for equipment about to enter the warehouse.&lt;/p&gt;

&lt;p&gt;The only difference here is that Diode ships with an SDK, allowing us to build hooks from all kinds of other data sources without having to re-write any client code to respectfully import data to Netbox.&lt;/p&gt;

&lt;p&gt;For those trying to do this just once, the Ansible &lt;code&gt;netbox.netbox&lt;/code&gt; collection excels at this task, but it’s considerably slower. Imports with hundreds to thousands of sites can take hours when configured to pace things and not impact other clients. Keep in mind that Netbox can drive Ansible playbooks, user documentation, NOC knowledge management - all of these things incur load.&lt;/p&gt;

&lt;p&gt;Diode is packaged tidily as a Docker container.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;a href="https://github.com/netboxlabs/diode/blob/develop/diode-server/README.md" rel="noopener noreferrer"&gt;Installing Diode&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;I used the &lt;code&gt;quickstart.sh&lt;/code&gt; method, as it templates the `docker-compose.yml and persistent files accordingly:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`shell&lt;br&gt;
bash quickstart.sh &lt;a href="https://netbox.lab.engyak.net" rel="noopener noreferrer"&gt;https://netbox.lab.engyak.net&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note: when running external shell scripts like this, look at them first. It’s good to ensure what code does before you run it on your machine, particularly when it’s this simple.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We also need to set it up in &lt;a href="https://github.com/netboxlabs/diode-netbox-plugin/blob/develop/README.md" rel="noopener noreferrer"&gt;Netbox by installing a plugin&lt;/a&gt;. I disagree with the instructions here, as &lt;code&gt;venv&lt;/code&gt; typically doesn’t persist across installations. I added the software requirements to &lt;code&gt;local_requirements.txt&lt;/code&gt; to make sure that everything gets installed when Netbox gets patched (or Python gets upgraded):&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`shell&lt;br&gt;
echo "netboxlabs.diode.netbox.plugin" | tee -a /opt/netbox/local_requirements.txt&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;After that, we add the required configuration to Netbox:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`python&lt;br&gt;
PLUGINS = [&lt;br&gt;
        "netbox_diode_plugin",&lt;br&gt;
]&lt;/p&gt;

&lt;p&gt;PLUGINS_CONFIG = {&lt;br&gt;
        "netbox_diode_plugin": {&lt;br&gt;
            "diode_target_override": "grpc://diode.lab.engyak.net:8080/diode",&lt;br&gt;
            "diode_username": "notbox-to-diode",&lt;br&gt;
            "netbox_to_diode_client_secret": "nicetry"&lt;br&gt;
        },&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Normally, I just run the upgrade script when I made changes. It’s possible to hop into the virtual environment and use &lt;code&gt;manage.py&lt;/code&gt;, but I personally find &lt;code&gt;upgrade.sh&lt;/code&gt; cleaner and easier. It also tests &lt;code&gt;local_requirements.txt&lt;/code&gt; to ensure that the &lt;code&gt;venv&lt;/code&gt; sets itself up properly:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`shell&lt;br&gt;
cd /opt/netbox&lt;br&gt;
bash upgrade.sh&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;After this, we’ll get a new icon in the Netbox UI Labeled &lt;code&gt;Diode&lt;/code&gt;. Let’s create a new client credential:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm1g7e9vtwxx3m6gu1opd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm1g7e9vtwxx3m6gu1opd.png" alt="Diode Client" width="771" height="216"&gt;&lt;/a&gt;]&lt;/p&gt;

&lt;h2&gt;
  
  
  Diode SDK
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Code examples go here
&lt;/h2&gt;

</description>
    </item>
    <item>
      <title>Visualize and Report Ansible with OpenTelemetry and Syslog</title>
      <dc:creator>Nick Schmidt</dc:creator>
      <pubDate>Sun, 23 Nov 2025 09:00:00 +0000</pubDate>
      <link>https://dev.to/ngschmidt/visualize-and-report-ansible-with-opentelemetry-and-syslog-4ef2</link>
      <guid>https://dev.to/ngschmidt/visualize-and-report-ansible-with-opentelemetry-and-syslog-4ef2</guid>
      <description>&lt;p&gt;Ansible is a fantastic tool to manage fleets of machines, but it's difficult to provide effective reporting when the fleet massively scales. Imagine hundreds of lines like this; try to find the one that failed (and why):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1PLAY RECAP *********************************************************************
2dev.lab.engyak.net : ok=6 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0   

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;...it's not difficult to read, but it doesn't decide what might deserve individual attention. It's possible to create Jinja reports that will be more executive-friendly, but they're focused on individual executions as well.&lt;/p&gt;

&lt;p&gt;Ansible &lt;a href="https://docs.ansible.com/projects/ansible/latest/plugins/callback.html" rel="noopener noreferrer"&gt;callback plugins&lt;/a&gt; provide us a framework to aggregate and analyze information about playbook execution without compromising idempotency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Types of Callback Plugins
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;aggregate&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;aggregate&lt;/code&gt; callback plugins modify the summary at the end of a task's output. They don't appear to impact recap, and don't have many useful examples.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.ansible.com/projects/ansible/latest/collections/callback_index_aggregate.html" rel="noopener noreferrer"&gt;Aggregate Plugin list&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;stdout&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;stdout&lt;/code&gt; callback plugins modify the continual output presented as Ansible completes work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1TASK [Update Apt!] *************************************************************
2ok: [dev.lab.engyak.net]

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where the fun begins! Note that only one plugin for &lt;code&gt;stdout&lt;/code&gt; can be selected for a given playbook.&lt;/p&gt;

&lt;h4&gt;
  
  
  Using &lt;code&gt;stdout&lt;/code&gt; callbacks
&lt;/h4&gt;

&lt;p&gt;The process for &lt;a href="https://docs.ansible.com/projects/ansible/latest/reference_appendices/config.html#ansible-configuration-settings" rel="noopener noreferrer"&gt;enabling callback plugins in &lt;code&gt;ansible.cfg&lt;/code&gt;&lt;/a&gt;. Since this is executed from an environment (GitHub Actions), I prefer leveraging environment injection.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ANSIBLE_CALLBACK_RESULT_FORMAT&lt;/code&gt; controls how data is printed out from individual tasks on the screen, this is up to preference. I prefer &lt;code&gt;yaml&lt;/code&gt;, and recommend playing with this setting to see what works best for you.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ANSIBLE_PYTHON_INTERPRETER&lt;/code&gt; silences any chatter about the discovered Python interpreter. Since this is a consistent environment without any tight coupling to specific releases, I don't feel the need to pin one, and I don't want to see the messages.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DEFAULT_STDOUT_CALLBACK&lt;/code&gt; will let you set the &lt;code&gt;stdout&lt;/code&gt; callback plugin&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In GitHub Actions, you can use the &lt;code&gt;env&lt;/code&gt; key to manipulate outputs without having to change any code. I'm also &lt;a href="https://blog.engyak.co/2024/04/patching/" rel="noopener noreferrer"&gt;integrating Netbox into this pipeline&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 1jobs:
 2 build:
 3 name: 'Manage Lab Configurations'
 4 runs-on: self-hosted
 5 env:
 6 ANSIBLE_PYTHON_INTERPRETER: 'auto_silent'
 7 ANSIBLE_STDOUT_CALLBACK: 'default'
 8 ANSIBLE_CALLBACK_RESULT_FORMAT: 'yaml'
 9 NETBOX_TOKEN: ${{ secrets.NETBOX_TOKEN }}
10 NETBOX_API: ${{ vars.NETBOX_URL }}
11 steps:
12 - uses: actions/checkout@v4
13 - name: Execute Ansible Management Playbook
14 run: |
15 python3 -m venv .
16 source bin/activate
17 python3 -m pip install --upgrade pip
18 python3 -m pip install -r requirements.txt
19 ansible-inventory -i local.netbox.netbox.nb_inventory.yml --graph
20 ansible-playbook -i local.netbox.netbox.nb_inventory.yml lab-management.yml          

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For reference purposes, I've added all compatible fields here. The &lt;code&gt;yaml&lt;/code&gt; results format is considerably more compact given the character limit per line.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dense&lt;/code&gt; seems to be a popular callback, and it uses colorization to generate play output, and tries to place things all on one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1task 1.task 1: ns2.lab.engyak.nettask 1: ns2.lab.engyak.net ns.lab.engyak.nettask 2.task 2: ns2.lab.engyak.nettask 2: ns2.lab.engyak.net ns.lab.engyak.nettask 3.task 3: ns2.lab.engyak.nettask 3: ns2.lab.engyak.net ns.lab.engyak.nettask 4.task 4: ns.lab.engyak.nettask 4: ns.lab.engyak.net ns2.lab.engyak.nettask 5.task 5: ns2.lab.engyak.nettask 5: ns2.lab.engyak.net ns.lab.engyak.nettask 6.task 6: ns2.lab.engyak.nettask 6: ns2.lab.engyak.nettask 6: ns2.lab.engyak.nettask 6: ns2.lab.engyak.net ns.lab.engyak.nettask 6: ns2.lab.engyak.net ns.lab.engyak.nettask 6: ns2.lab.engyak.net ns.lab.engyak.nettask 7.task 7: ns.lab.engyak.nettask 7: ns.lab.engyak.net ns2.lab.engyak.nettask 7: ns.lab.engyak.net ns2.lab.engyak.nettask 7: ns.lab.engyak.net ns2.lab.engyak.nettask 7: ns.lab.engyak.net ns2.lab.engyak.nettask 7: ns.lab.engyak.net ns2.lab.engyak.nettask 8.task 8: ns2.lab.engyak.nettask 8: ns2.lab.engyak.net ns.lab.engyak.nettask 9.task 9: ns2.lab.engyak.nettask 9: ns2.lab.engyak.net ns.lab.engyak.nettask 10.task 10: ns2.lab.engyak.nettask 10: ns2.lab.engyak.net ns.lab.engyak.net

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's definitely compact, but not super readable. &lt;code&gt;oneline&lt;/code&gt; is probably the best non-default plugin of the bunch, but it's much more verbose than the default one. It also displays a lot of system-specific information, so no snippet here.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;notification&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This is where things get really good for those of us with execution environments! &lt;code&gt;notification&lt;/code&gt; callback plugins send data to external systems when a play finishes.&lt;/p&gt;

&lt;h4&gt;
  
  
  Directing results to OpenTelemetry
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://opentelemetry.io/" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt; is a truly neat open standard for exchanging "trace information" between systems.&lt;/p&gt;

&lt;p&gt;This is incredibly useful, but also difficult to explain in a way that's clear without providing concrete examples. Essentially, OpenTelemetry-based traces allow debugging systems that do not all exist in the same software package, and it offers a timeline for each step. As it happens, Ansible's callback plugin is well-architected and a good example of the value that a trace can have, even from an application perspective.&lt;/p&gt;

&lt;p&gt;First, we'll need to assemble an OpenTelemetry-compliant platform to stream Ansible results to. I've selected &lt;a href="https://www.jaegertracing.io/docs/2.12/getting-started/" rel="noopener noreferrer"&gt;Jaeger&lt;/a&gt; for this purpose. It has an all-in-one quickstart function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1docker run --rm --name jaeger \
2 -p 16686:16686 \
3 -p 4317:4317 \
4 -p 4318:4318 \
5 -p 5778:5778 \
6 -p 9411:9411 \
7 cr.jaegertracing.io/jaegertracing/jaeger:2.12.0

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once it's running, we need to instruct Ansible to forward data. This is achievable exclusively with environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 env:
2 ANSIBLE_CALLBACKS_ENABLED: 'community.general.opentelemetry'
3 ANSIBLE_OPENTELEMETRY_ENABLE_FROM_ENVIRONMENT: 'ANSIBLE_OPENTELEMETRY_ENABLED'
4 ANSIBLE_OPENTELEMETRY_ENABLED: 'true'
5 OTEL_EXPORTER_OTLP_ENDPOINT: 'http://jaeger.lab.engyak.net:4317'
6 OTEL_EXPORTER_INSECURE: 'true'
7 OTEL_SERVICE_NAME: 'ansible'

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In addition to these variables, the module requires the following additions to &lt;code&gt;requirements.txt&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1opentelemetry-sdk
2opentelemetry-exporter-otlp-proto-grpc
3opentelemetry-exporter-otlp-proto-http

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once these changes get applied, with _no other required changes to the Ansible code &lt;strong&gt;itself&lt;/strong&gt; _, all subsequent runs submit OTLP traces to Jaeger It looks like this:&lt;/p&gt;

&lt;p&gt;[&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fly6oa48wnylkgpwb0igz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fly6oa48wnylkgpwb0igz.png" alt="Jaeger UI #1" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;](jaeger_1.png)[&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkv3xxp8ux3m16p1ky70j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkv3xxp8ux3m16p1ky70j.png" alt="Jaeger UI #2" width="800" height="169"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;](jaeger_2.png)&lt;/p&gt;

&lt;p&gt;This provides a comprehensive "drill down" for every step taken by Ansible, and I've honestly never seen this level of detail before. Every single programmatic step is logged with a timestamp, allowing an engineer to find out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which node took too long&lt;/li&gt;
&lt;li&gt;Which step slowed things down the most&lt;/li&gt;
&lt;li&gt;Whether that matches the baseline for other nodes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a transactional application this has to be even more useful.&lt;/p&gt;

&lt;h4&gt;
  
  
  Directing Results to Syslog
&lt;/h4&gt;

&lt;p&gt;Now, for something quite a bit more boring (but equally important). If OpenTelemetry is a microscope, Syslog is the 10,000 foot view. This can also be set up by CI, and should run in parallel with OpenTelemetry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 env:
2 ANSIBLE_CALLBACKS_ENABLED: 'community.general.opentelemetry,community.general.syslog_json'
3 ANSIBLE_OPENTELEMETRY_ENABLE_FROM_ENVIRONMENT: 'ANSIBLE_OPENTELEMETRY_ENABLED'
4 ANSIBLE_OPENTELEMETRY_ENABLED: 'true'
5 OTEL_EXPORTER_OTLP_ENDPOINT: 'http://jaeger.lab.engyak.net:4317'
6 OTEL_EXPORTER_INSECURE: 'true'
7 OTEL_SERVICE_NAME: 'ansible'
8 SYSLOG_PORT: '54514'
9 SYSLOG_SERVER: '127.0.0.1'

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each of these callback plugins serves a different purpose. Syslog callbacks provide a shorter summary as JSON, which can easily be dashboarded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1&amp;lt;14&amp;gt;1 2025-11-30T07:41:00-09:00 10.66.1.143 gh-runner2 - - - ansible-command: task execution OK; host: ns.lab.engyak.net; message: {"changed": false, "checksum": "a46e7011b00c560dddcc193ef16f01fd2d05970e", "dest": "/etc/unbound/unbound.conf", "gid": 0, "group": "root", "mode": "0640", "owner": "root", "path": "/etc/unbound/unbound.conf", "size": 4531, "state": "file", "uid": 0}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some example conditions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;"changed": true&lt;/code&gt; would indicate how many modifications were made per hostname (identified by &lt;code&gt;host: ns.lab.engyak.net&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;!= 'task execution OK'&lt;/code&gt; would search for job failures&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Modernizing the Monitoring Stack
&lt;/h2&gt;

&lt;p&gt;Ansible, despite being an infrastructure tool, provides a good example of the different types of modern monitoring. Thematically, these concepts &lt;strong&gt;should&lt;/strong&gt; be applied to actual applications.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Traces are an excellent tool to identify software process bottlenecks. Any tool that has long-running jobs can benefit from tracing. They're computationally costly, so they should be saved for any tool where performance degradation truly matters.&lt;/li&gt;
&lt;li&gt;Syslog is the "swiss army knife" of monitoring. It's the best tool for simple events, and can be the foundation for event-driven programming.&lt;/li&gt;
&lt;li&gt;Metrics allow infrastructure engineers to "just send the important bits" via tools like &lt;code&gt;protobuf&lt;/code&gt;, sort of like SNMP but better. In the network realm, this is where Model-Driven Telemetry reigns supreme, and in the application stack Prometheus is a popular option.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing I did find interesting - Grafana + Alloy allowed the unification of all of these data types. Here's a preview of what Jaeger in Grafana looks like:&lt;/p&gt;

&lt;p&gt;[&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fimmva4qy9kj8nxigic39.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fimmva4qy9kj8nxigic39.png" alt="Grafana Preview" width="800" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;](grafana_1.png)&lt;/p&gt;

</description>
      <category>monitoring</category>
      <category>devops</category>
      <category>automation</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Automate DNS Zone Generation and Deployment with Ansible and Netbox</title>
      <dc:creator>Nick Schmidt</dc:creator>
      <pubDate>Sun, 10 Nov 2024 09:00:00 +0000</pubDate>
      <link>https://dev.to/ngschmidt/automate-dns-zone-generation-and-deployment-with-ansible-and-netbox-1pke</link>
      <guid>https://dev.to/ngschmidt/automate-dns-zone-generation-and-deployment-with-ansible-and-netbox-1pke</guid>
      <description>&lt;p&gt;In a &lt;a href="https://blog.engyak.co/2024/01/dns-automation/" rel="noopener noreferrer"&gt;previous post&lt;/a&gt;, I covered a method to automatically generate DNS zones from an embedded YAML list.&lt;/p&gt;

&lt;p&gt;This wasn't the most useful on its own, only ensuring that forward and reverse DNS entries match each other (you'll be shocked by how many places it isn't!) - and we need a good way to simplify DNS administration with tooling less expensive that, say, Infoblox.&lt;/p&gt;

&lt;p&gt;This isn't to say that Infoblox is bad, but a fully loaded Infoblox license is a little pricy for home labs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern
&lt;/h2&gt;

&lt;p&gt;First, let's illustrate a potential design:&lt;/p&gt;

&lt;p&gt;[&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fblog.engyak.co%2F2024%2F11%2Fdns-e2e%2Fpattern.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fblog.engyak.co%2F2024%2F11%2Fdns-e2e%2Fpattern.svg" alt="Solution Diagram" width="733" height="604"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;](pattern.svg)&lt;/p&gt;

&lt;h2&gt;
  
  
  The Code
&lt;/h2&gt;

&lt;p&gt;In order to do this, we're going to need to find a good way to pull &lt;code&gt;pre-filtered data&lt;/code&gt; for ansible to work with, and Netbox has a GraphQL API (&lt;code&gt;/graphql/&lt;/code&gt;) that's perfect for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 1{
 2 ip_address_list(filters: {dns_name: {i_contains: "example.net"}, family: 4}) {
 3 dns_name
 4 address
 5 }
 6}
 7{
 8 ip_address_list(filters: {dns_name: {i_contains: "example.net"}, family: 6}) {
 9 dns_name
10 address
11 }
12}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will give us a separate sheet for IPv4 and IPv6 addresses attached to a given zone - and we can assemble it without any postprocessing in Ansible.&lt;/p&gt;

&lt;p&gt;Netbox's GraphQL sandbox produces the following data output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 1{
 2 "data": {
 3 "ip_address_list": [
 4 {
 5 "dns_name": "ns.example.net",
 6 "address": "1.1.1.1/32"
 7 }
 8 ]
 9 }
10}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, this is received via a &lt;em&gt;graphical interface&lt;/em&gt;, which means we can't consume it programmatically. In order to do that, we'll need to package the GraphQL payload in JSON. Here's an Ansible task that does just that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 1 - name: "Try Fetching `lab.engyak.net` IPv4 GraphQL!"
 2 ansible.builtin.uri:
 3 url: "https://netbox/graphql/"
 4 method: POST
 5 body:
 6 query: "query { ip_address_list(filters: {dns_name: {i_contains: \"example.com\"}, family: 4}) { dns_name address }}"
 7 body_format: "json"
 8 headers:
 9 Authorization: "Token {{ lookup('ansible.builtin.env', 'NETBOX_TOKEN') }}"
10 Content-Type: "application/json"
11 Accept: "application/json"
12 validate_certs: false
13 register: result_example_net_v4

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There aren't any &lt;code&gt;pynetbox&lt;/code&gt; based modules that automate this into Ansible, so here we're using the &lt;code&gt;ansible.builtin.uri&lt;/code&gt; module (also known as the &lt;strong&gt;Jack of All Trades&lt;/strong&gt; module) to pull JSON data. It also uses the environment variable &lt;code&gt;NETBOX_TOKEN&lt;/code&gt;, which must be exposed by secrets management / CI processes.&lt;/p&gt;

&lt;p&gt;In this case, I'm pulling IPv4 and IPv6 records separately. Jinja doesn't know the difference between types of record, so I cheat on postprocessing and let GraphQL do all the heavy lifting. IPv6 is the same, but with &lt;code&gt;family: 6&lt;/code&gt;/&lt;code&gt;result_example_net_v6&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The next step is to build Jinja templates to define the zonefiles. I created them in a previous post, but will include all of them in a Gist at the end of this post. They need to be modified to process output from GraphQL, because we don't control any of the field names with it.&lt;/p&gt;

&lt;p&gt;The Jinja templates used in this example are unique to ansible - the custom filter &lt;code&gt;ansible.utils.ipaddr&lt;/code&gt; is amazing, converting Netbox's &lt;code&gt;{{ address }}/{{ cidr }}&lt;/code&gt; notation is compact and efficient, but it doesn't work as an A record target. Invocations like &lt;code&gt;|ansible.utils.ipaddr('address')&lt;/code&gt; or &lt;code&gt;|ansible.utils.ipaddr('revdns')&lt;/code&gt; are particularly useful here.&lt;/p&gt;

&lt;p&gt;Finally, it's good to test the resulting zonefiles for sanity. It's included in the Gist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrospective
&lt;/h2&gt;

&lt;p&gt;Netbox's GraphQL API is a really effective tool for aggregating pre-filtered data and driving automation processes. I was quite impressed that I could just ask an API endpoint for this nice and tidy report, already pre-formatted for me!&lt;/p&gt;

&lt;p&gt;Lack of field and format control is an issue with GraphQL (you're stuck with whatever data structure the application architect has in store for you) - but Ansible and Jinja2 empower you to present the back-end data in any front-end manner you prefer (in my case, as DNS data loaded into an Unbound instance).&lt;/p&gt;

&lt;p&gt;Nearly any business reporting process can be driven from Netbox in this fashion, as long as the resulting format can be Jinjafied. Here are some ideas on how this can be used further:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Report on Circuits per &lt;code&gt;Region&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Report on IT-Managed assets in a given &lt;code&gt;Site&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Report on how many &lt;code&gt;Site&lt;/code&gt;s have IPv6 coverage&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Gist
&lt;/h2&gt;

&lt;p&gt;As promised, here's the raw code I created to automate DNS zonefile management from Netbox:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@import url('https://cdn.rawgit.com/lonekorean/gist-syntax-themes/d49b91b3/stylesheets/idle-fingers.css');

@import url('https://fonts.googleapis.com/css?family=Open+Sans');
body {
  font: 16px 'Open Sans', sans-serif;
}
body .gist .gist-file {
  border-color: #555 #555 #444
}
body .gist .gist-data {
  border-color: #555
}
body .gist .gist-meta {
  color: #ffffff;
  background: #373737; 
}
body .gist .gist-meta a {
  color: #ffffff
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;a href="https://gist.github.com/ngschmidt/33ce644c3873d1fe3e82f91378eaa2fc" rel="noopener noreferrer"&gt;GitHub Link&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>VM Deployment Pipelines with Proxmox</title>
      <dc:creator>Nick Schmidt</dc:creator>
      <pubDate>Sat, 31 Aug 2024 09:00:00 +0000</pubDate>
      <link>https://dev.to/ngschmidt/vm-deployment-pipelines-with-proxmox-ihm</link>
      <guid>https://dev.to/ngschmidt/vm-deployment-pipelines-with-proxmox-ihm</guid>
      <description>&lt;p&gt;Decoupled approaches to deployment of IaaS workloads are the way of the future.&lt;/p&gt;

&lt;p&gt;Here, we'll try to construct a VM deployment pipeline leveraging GitHub Actions and Ansible's community modules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proxmox Setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not featured here&lt;/strong&gt; : Loading a VM ISO is particular to the Proxmox deployment, but it's necessary for future steps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's create a VM named &lt;code&gt;deb12.6-template&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="proxmox-1.png"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuaemcz7paksgkpskifml.png" alt="First creation screen" width="721" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I set a separate VM ID range for templates to simplify visual automatic sorting.&lt;/p&gt;

&lt;p&gt;&lt;a href="proxmox-2.png"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3k63308bmzmox2iij92y.png" alt="Second creation screen" width="720" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="proxmox-3.png"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9cmuruvgb1vbrvg1cbo7.png" alt="Third creation screen" width="716" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note: Paravirtualized hardware is still the optimal choice, like with vSphere - but in this case, &lt;code&gt;VirtIO&lt;/code&gt; is the code supplier.&lt;/p&gt;

&lt;p&gt;&lt;a href="proxmox-4.png"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyxyn31w1eoj59u1nwpul.png" alt="Fourth creation screen" width="719" height="540"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note: SSD Emulation and &lt;code&gt;qemu-agent&lt;/code&gt; are required for virtual disk reclamation with QEMU. This is particularly important in my lab.&lt;/p&gt;

&lt;p&gt;&lt;a href="proxmox-5.png"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3k4d7uumtu18b18skxdk.png" alt="Fifth creation screen" width="717" height="535"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In this installation, I'm using paravirtualized network adapters and have separated my management(&lt;code&gt;vmbr0&lt;/code&gt;) and data plane(&lt;code&gt;vmbr1&lt;/code&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Debian Linux Setup
&lt;/h2&gt;

&lt;p&gt;I'll skip the Linux installer parts for brevity, Debian's installer is excellent and easy to use.&lt;/p&gt;

&lt;p&gt;At a high level, we'll want to do some preparatory steps before declaring this a usable base image:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create users

&lt;ul&gt;
&lt;li&gt;Recommended approach: Create a bootstrap user, then shred it&lt;/li&gt;
&lt;li&gt;Leave the &lt;code&gt;bootstrap&lt;/code&gt; user with an SSH key on the base image&lt;/li&gt;
&lt;li&gt;After creation, build a &lt;code&gt;takeover&lt;/code&gt; playbook that installs the latest and greatest username table, &lt;code&gt;sssd&lt;/code&gt;, SSH keys, APM, anything with confidential cryptographic material that should not be left unencrypted on the hypervisor&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;This won't slow the VM deployment speed by as much as you think&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;Install packages

&lt;ul&gt;
&lt;li&gt;This is just a list of some basics that I prefer to add to each machine. It's more network-centric; anything more comprehensive should be part of a build playbook specific to whatever's being deployed.&lt;/li&gt;
&lt;li&gt;Note: This is an Ansible playbook, and therefore, it needs Ansible to run (&lt;code&gt;apt install ansible&lt;/code&gt;)
&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Debian&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;machine&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;prep"&lt;/span&gt;
  &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
  &lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Install&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;standard&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;packages"&lt;/span&gt;
    &lt;span class="na"&gt;ansible.builtin.apt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;pkg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;curl'&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;dnsutils'&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;diffutils'&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ethtool'&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;git'&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;mtr'&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;net-tools'&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;netcat-traditional'&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;python3-requests'&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;python3-jinja2'&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tcpdump'&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;telnet'&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traceroute'&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;qemu-guest-agent'&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;vim'&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wget'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Clean up the disk. This will make our base image more compact - each clone will inherit any wasted space, so consider it a 10,20x savings in disk usage. I leave this as a file on the base image and name it &lt;code&gt;reset_vm.sh&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;

&lt;span class="c"&gt;# Clean Apt&lt;/span&gt;
apt clean

&lt;span class="c"&gt;# Cleaning logs.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/audit/audit.log &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /dev/null &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /var/log/audit/audit.log
&lt;span class="k"&gt;fi
if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/wtmp &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /dev/null &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /var/log/wtmp
&lt;span class="k"&gt;fi
if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/lastlog &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /dev/null &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /var/log/lastlog
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Cleaning udev rules.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/udev/rules.d/70-persistent-net.rules &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; /etc/udev/rules.d/70-persistent-net.rules
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Cleaning the /tmp directories&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /tmp/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/tmp/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="c"&gt;# Cleaning the SSH host keys&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/ssh/ssh_host_&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="c"&gt;# Cleaning the machine-id&lt;/span&gt;
&lt;span class="nb"&gt;truncate&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; 0 /etc/machine-id
&lt;span class="nb"&gt;rm&lt;/span&gt; /var/lib/dbus/machine-id
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /etc/machine-id /var/lib/dbus/machine-id

&lt;span class="c"&gt;# Cleaning the shell history&lt;/span&gt;
&lt;span class="nb"&gt;unset &lt;/span&gt;HISTFILE
&lt;span class="nb"&gt;history&lt;/span&gt; &lt;span class="nt"&gt;-cw&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/.bash_history
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-fr&lt;/span&gt; /root/.bash_history

&lt;span class="c"&gt;# Truncating hostname, hosts, resolv.conf and setting hostname to localhost&lt;/span&gt;
&lt;span class="nb"&gt;truncate&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; 0 /etc/&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;,hosts,resolv.conf&lt;span class="o"&gt;}&lt;/span&gt;
hostnamectl set-hostname localhost

&lt;span class="c"&gt;# Clean cloud-init - deprecated because cloud-init isn't currently used&lt;/span&gt;
&lt;span class="c"&gt;# cloud-init clean -s -l&lt;/span&gt;

&lt;span class="c"&gt;# Force a filesystem sync&lt;/span&gt;
&lt;span class="nb"&gt;sync&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shutdown the Virtual Machine. I prefer to start it back up and shut it down from the hypervisor to ensure that &lt;code&gt;qemu-guest-agent&lt;/code&gt; is working properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment Pipeline
&lt;/h2&gt;

&lt;p&gt;First, we will want to create an API token under "Datacenter -&amp;gt; Permissions -&amp;gt; API Tokens":&lt;/p&gt;

&lt;p&gt;&lt;a href="proxmox-6.png"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl485gv6d5ydv1zqypcls.png" alt="Proxmox API token screen" width="600" height="207"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are some oddities with the Ansible &lt;code&gt;proxmoxer&lt;/code&gt; based module and Ansible to keep in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;api_user&lt;/code&gt; is needed and used by the API client, formatted as &lt;code&gt;{{ user }}@domain&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api_token_id&lt;/code&gt; is not the same as the output from the command, it's what you put into the "Token ID" field.

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;{{ api_user}}!{{ api_token_id }}&lt;/code&gt; should form the combined credential presented to the API, and match the created token.&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;If you attempt to use the output from the API creation screen under &lt;code&gt;api_user&lt;/code&gt; or &lt;code&gt;api_token_id&lt;/code&gt;, it'll return a &lt;code&gt;401 Invalid user&lt;/code&gt; without much explanation as to what might be the issue.&lt;/p&gt;

&lt;p&gt;Here's the pipeline. Github's primary job is to set up the Python/Ansible environment, and translate the workflow inputs into something that Ansible can properly digest.&lt;/p&gt;

&lt;p&gt;I also added some &lt;code&gt;cat&lt;/code&gt; steps - this allows us to use the GitHub Actions log to store intent until Netbox registration completes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;On-Demand:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Build&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;VM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Proxmox"&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;machine_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Machine&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Name"&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;examplename"&lt;/span&gt;
      &lt;span class="na"&gt;machine_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ID&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(can't&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;re-use)"&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Template&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Name"&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;choice&lt;/span&gt;
        &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;deb12.6-template&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deb12.6-template"&lt;/span&gt;
      &lt;span class="na"&gt;hardware_cpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vCPU&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Count"&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
      &lt;span class="na"&gt;hardware_memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Memory&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Allocation&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;MB)"&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;512"&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;self-hosted&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Create Variable YAML File&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;cat &amp;lt;&amp;lt;EOF &amp;gt; roles/proxmox_kvm/parameters.yaml&lt;/span&gt;
          &lt;span class="s"&gt;---&lt;/span&gt;
            &lt;span class="s"&gt;vm_data:&lt;/span&gt;
              &lt;span class="s"&gt;name: "${{ github.event.inputs.machine_name }}"&lt;/span&gt;
              &lt;span class="s"&gt;id: ${{ github.event.inputs.machine_id }}&lt;/span&gt;
              &lt;span class="s"&gt;template: "${{ github.event.inputs.template }}"&lt;/span&gt;
              &lt;span class="s"&gt;node: node&lt;/span&gt;
              &lt;span class="s"&gt;hardware:&lt;/span&gt;
                &lt;span class="s"&gt;cpus: ${{ github.event.inputs.hardware_cpus }}&lt;/span&gt;
                &lt;span class="s"&gt;memory: ${{ github.event.inputs.hardware_memory }}&lt;/span&gt;
                &lt;span class="s"&gt;storage: ssd-tier&lt;/span&gt;
                &lt;span class="s"&gt;format: qcow2&lt;/span&gt;
          &lt;span class="s"&gt;EOF&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build VM&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;cd roles/proxmox_kvm/&lt;/span&gt;
          &lt;span class="s"&gt;cat parameters.yaml&lt;/span&gt;
          &lt;span class="s"&gt;python3 -m venv .&lt;/span&gt;
          &lt;span class="s"&gt;source bin/activate&lt;/span&gt;
          &lt;span class="s"&gt;python3 -m pip install --upgrade pip&lt;/span&gt;
          &lt;span class="s"&gt;python3 -m pip install -r requirements.txt&lt;/span&gt;
          &lt;span class="s"&gt;python3 --version&lt;/span&gt;
          &lt;span class="s"&gt;ansible --version&lt;/span&gt;

          &lt;span class="s"&gt;export PAPIUSER="${{ secrets.PAPIUSER }}"&lt;/span&gt;
          &lt;span class="s"&gt;export PAPI_TOKEN="${{ secrets.PAPI_TOKEN }}"&lt;/span&gt;
          &lt;span class="s"&gt;export PAPI_SECRET="${{ secrets.PAPI_SECRET }}"&lt;/span&gt;
          &lt;span class="s"&gt;export PHOSTNAME="${{ secrets.PHOSTNAME }}"&lt;/span&gt;
          &lt;span class="s"&gt;export NETBOX_TOKEN="${{ secrets.NETBOX_TOKEN }}"&lt;/span&gt;
          &lt;span class="s"&gt;export NETBOX_URL="${{ secrets.NETBOX_URL }}"&lt;/span&gt;
          &lt;span class="s"&gt;export NETBOX_CLUSTER="${{ secrets.NETBOX_CLUSTER_PROX }}"&lt;/span&gt;
          &lt;span class="s"&gt;ansible-playbook build_vm_prox.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In addition, a &lt;code&gt;requirements.txt&lt;/code&gt; is required by GitHub to set up the &lt;code&gt;venv&lt;/code&gt;, and belongs in the role folder (&lt;code&gt;roles/proxmox_kvm&lt;/code&gt; as above):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;###### Requirements without Version Specifiers ######
pytz
netaddr
django
jinja2
requests
pynetbox

###### Requirements with Version Specifiers ######
ansible &amp;gt;= 8.4.0              # Mostly just don't use old Ansible (e.g. v2, v3)
proxmoxer &amp;gt;= 2.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This Ansible playbook also integrates Netbox, as my vSphere workflow did, and uses a common schema to simplify code re-use. There are a few quirks with the Proxmox playbooks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;There's no module to grab VM Guest network information, but the API provides it, so I can get it with &lt;code&gt;uri&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Proxmox has a nasty habit of breaking Ansible with JSON keys that include &lt;code&gt;-&lt;/code&gt;. The best way to fix it is with a debug action: &lt;code&gt;{{ prox_network_result.json.data | replace('-','_') }}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Proxmox's VM copy needs a timeout configured, and announces it's done before the VM is ready for actions. I added an &lt;code&gt;ansible.builtin.pause&lt;/code&gt; step before starting the VM, and after (to allow it to boot)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Build&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;VM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Proxmox"&lt;/span&gt;
  &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
  &lt;span class="na"&gt;gather_facts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="c1"&gt;# Before executing ensure that the prerequisites are installed&lt;/span&gt;
  &lt;span class="c1"&gt;# `ansible-galaxy collection install netbox.netbox`&lt;/span&gt;
  &lt;span class="c1"&gt;# `python3 -m pip install aiohttp pynetbox`&lt;/span&gt;
  &lt;span class="c1"&gt;# We start with a pre-check playbook, if it fails, we don't want to&lt;/span&gt;
  &lt;span class="c1"&gt;# make changes&lt;/span&gt;
  &lt;span class="na"&gt;any_errors_fatal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;vars_files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parameters.yaml"&lt;/span&gt;

  &lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Debug"&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.debug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;msg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Test&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;connectivity&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;authentication"&lt;/span&gt;
      &lt;span class="na"&gt;community.general.proxmox_node_info&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;api_host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PHOSTNAME")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;api_user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PAPIUSER")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;api_token_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PAPI_TOKEN")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;api_token_secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PAPI_SECRET")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
      &lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prox_node_result&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Display&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Node&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Data"&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.debug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;msg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;prox_node_result&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Build&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;VM"&lt;/span&gt;
      &lt;span class="na"&gt;community.general.proxmox_kvm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;api_host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PHOSTNAME")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;api_user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PAPIUSER")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;api_token_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PAPI_TOKEN")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;api_token_secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PAPI_SECRET")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;node&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.node&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.hardware.storage&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;newid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.id&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;clone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.template&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.hardware.format&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Wait&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;VM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;fully&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;register"&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.pause&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Start&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;VM"&lt;/span&gt;
      &lt;span class="na"&gt;community.general.proxmox_kvm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;api_host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PHOSTNAME")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;api_user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PAPIUSER")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;api_token_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PAPI_TOKEN")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;api_token_secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PAPI_SECRET")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;started&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Wait&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;VM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;fully&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;boot"&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.pause&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;45&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Get&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;VM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;information"&lt;/span&gt;
      &lt;span class="na"&gt;community.general.proxmox_vm_info&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;api_host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PHOSTNAME")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;api_user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PAPIUSER")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;api_token_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PAPI_TOKEN")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;api_token_secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PAPI_SECRET")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;vmid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.id&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
      &lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prox_vm_result&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Report&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;VM!"&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.debug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;var&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prox_vm_result&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Fetch&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;VM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Networking&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;information"&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.uri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PHOSTNAME")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}:8006/api2/json/nodes/{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.node&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/qemu/{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.id&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/agent/network-get-interfaces'&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;GET'&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;application/json'&lt;/span&gt;
          &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PVEAPIToken={{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PAPIUSER")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}!{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PAPI_TOKEN")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}={{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PAPI_SECRET")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;validate_certs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prox_network_result&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Refactor&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Network&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Information"&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.debug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;msg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;prox_network_result.json.data&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;replace('-','_')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
      &lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prox_network_result_modified&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Register&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;VM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Netbox!"&lt;/span&gt;
      &lt;span class="na"&gt;netbox.netbox.netbox_virtual_machine&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;netbox_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"NETBOX_TOKEN")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;netbox_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"NETBOX_URL")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;validate_certs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cluster&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"NETBOX_CLUSTER")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Built&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;by&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;GH&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Actions&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Pipeline!'&lt;/span&gt;
          &lt;span class="na"&gt;local_context_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;prox_vm_result&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.hardware.memory&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;vcpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.hardware.cpus&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Configure&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;VM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Interface&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Netbox!"&lt;/span&gt;
      &lt;span class="na"&gt;netbox.netbox.netbox_vm_interface&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;netbox_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"NETBOX_TOKEN")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;netbox_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"NETBOX_URL")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;validate_certs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}_intf_{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.hardware_address&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;replace(":",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;safe&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;virtual_machine&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;vrf&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Campus'&lt;/span&gt;
          &lt;span class="na"&gt;mac_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.hardware_address&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
      &lt;span class="na"&gt;with_items&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;prox_network_result_modified.msg.result&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
      &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;item.hardware_address != '00:00:00:00:00:00'&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Reserve&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;IP"&lt;/span&gt;
      &lt;span class="na"&gt;netbox.netbox.netbox_ip_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;netbox_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"NETBOX_TOKEN")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;netbox_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"NETBOX_URL")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;validate_certs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.ip_addresses[0].ip_address&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.ip_addresses[0].prefix&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;vrf&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Campus'&lt;/span&gt;
          &lt;span class="na"&gt;assigned_object&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;virtual_machine&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
      &lt;span class="na"&gt;with_items&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;prox_network_result_modified.msg.result&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
      &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;item.hardware_address != '00:00:00:00:00:00'&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Finalize&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;VM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Netbox!"&lt;/span&gt;
      &lt;span class="na"&gt;netbox.netbox.netbox_virtual_machine&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;netbox_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"NETBOX_TOKEN")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;netbox_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"NETBOX_URL")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
        &lt;span class="na"&gt;validate_certs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cluster&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup("env",&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"NETBOX_CLUSTER")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; 
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;lab_debian_machines'&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;lab_linux_machines'&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;lab_apt_updates'&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vm_data.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;primary_ip4&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.ip_addresses[0].ip_address&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.ip_addresses[0].prefix&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
            &lt;span class="na"&gt;vrf&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Campus"&lt;/span&gt;
      &lt;span class="na"&gt;with_items&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;prox_network_result_modified.msg.result&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
      &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;item.hardware_address != '00:00:00:00:00:00'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Overall, the Proxmox API/playbooks are quite a bit simpler to use than the VMware ones. The &lt;code&gt;proxmoxer&lt;/code&gt; based modules are relatively feature complete compared to &lt;code&gt;vmware_rest&lt;/code&gt;, but the largest exception I found (examples not in this post) was that I could always fall back to Ansible's comprehensive Linux foundation to fill any gaps I needed to. It's a refreshing change.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Starting from scratch with Netbox IPAM</title>
      <dc:creator>Nick Schmidt</dc:creator>
      <pubDate>Sat, 11 May 2024 09:00:00 +0000</pubDate>
      <link>https://dev.to/ngschmidt/starting-from-scratch-with-netbox-ipam-4afj</link>
      <guid>https://dev.to/ngschmidt/starting-from-scratch-with-netbox-ipam-4afj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Spreadsheets are not an adequate method to manage IP addressing&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Different IP design strategies
&lt;/h2&gt;

&lt;h3&gt;
  
  
  IPv4
&lt;/h3&gt;

&lt;h3&gt;
  
  
  Bogons, and the basics
&lt;/h3&gt;

&lt;p&gt;There are a number of valid and invalid prefixes for use internally within an enterprise. Here's a list of &lt;em&gt;invalid&lt;/em&gt; prefixes in the global routing table; of those, the RFC 1918 prefixes are available for use:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prefix&lt;/th&gt;
&lt;th&gt;RFC&lt;/th&gt;
&lt;th&gt;Usable Internally?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.0.0.0&lt;/td&gt;
&lt;td&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc1122#section-3.2.1.3"&gt;1122&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;🤪 Everybody more or less agreed not to use it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10.0.0.0/8&lt;/td&gt;
&lt;td&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc1918"&gt;1918&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;✅ Use this block for &lt;em&gt;large&lt;/em&gt; prefix allocations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100.64.0.0/10&lt;/td&gt;
&lt;td&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc6598"&gt;6598&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;🙄 CG-NAT, can &lt;em&gt;technically&lt;/em&gt; be used, but will break in random cloud applications&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;127.0.0.0/8&lt;/td&gt;
&lt;td&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc1122#section-3.2.1.3"&gt;1122&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;❌ loopback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;169.254.0.0/16&lt;/td&gt;
&lt;td&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc3927"&gt;3927&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;✅, but don't allocate it (APIPA)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;172.16.0.0/12&lt;/td&gt;
&lt;td&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc1918"&gt;1918&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;✅ Use this block for &lt;em&gt;medium&lt;/em&gt; prefix allocations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.0.0.0/24&lt;/td&gt;
&lt;td&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc5736"&gt;5736&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;❌ IETF skunkworks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.0.2.0/24&lt;/td&gt;
&lt;td&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc5737"&gt;5737&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;❌ Carrier test networks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.88.99.0/24&lt;/td&gt;
&lt;td&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc3068"&gt;3068&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;❌ 6to4 relays&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.0.0/16&lt;/td&gt;
&lt;td&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc1918"&gt;1918&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;✅ Avoid this block for &lt;em&gt;enterprises&lt;/em&gt;, it'll collide with home networks when people use VPN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;198.18.0.0/15&lt;/td&gt;
&lt;td&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc2544"&gt;2544&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;❌ device benchmarking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;198.51.100.0/24&lt;/td&gt;
&lt;td&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc5737"&gt;5737&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;❌ Carrier test networks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;203.0.113.0/24&lt;/td&gt;
&lt;td&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc5737"&gt;5737&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;❌ Carrier test networks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;224.0.0.0/4&lt;/td&gt;
&lt;td&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc3171"&gt;3171&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;❌ multicast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;240.0.0.0/4&lt;/td&gt;
&lt;td&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc1112#section-4"&gt;1122&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;🤯 madlad play, might work, might not. Linux seems to live in this space just fine&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All of these prefixes &lt;em&gt;must&lt;/em&gt; be dropped at any network perimeter, e.g. firewalls, extranet routers, to prevent internal traffic or misconfigured NATs from leaking. It also prevents protocol abuse, which is a cheap and easy way to improve security.&lt;/p&gt;

&lt;p&gt;In multi-site networks, dropping &lt;strong&gt;all&lt;/strong&gt; of these prefixes would be wise - an ethernet loop + APIPA can turn a switching issue into a network-wide outage pretty easily. &lt;a href="https://en.wikipedia.org/wiki/Longest_prefix_match"&gt;Longest Prefix Match&lt;/a&gt; can ensure that any allocated networks remain reachable.&lt;/p&gt;

&lt;p&gt;Each of these prefixes should be created in Netbox, so you can use it as a reference later. I'd recommend &lt;em&gt;tagging&lt;/em&gt; them with some form of hint to indicate usability, e.g.:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;IP:Usable&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;IP:Unusable&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As you get more familiar with the API/search, tag-based filters become incredibly handy.&lt;/p&gt;

&lt;h3&gt;
  
  
  IPv6
&lt;/h3&gt;

&lt;p&gt;IPv6 is quite easy. All valid &lt;em&gt;routable&lt;/em&gt; addresses fall under one allocated prefix:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;2000::/3&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This means that you can implement a "default route" that won't accidentally leak bogons like in IPv4, but with a much simpler approach. Instead of implementing &lt;code&gt;::0/0&lt;/code&gt; for your default route, use &lt;code&gt;2000::/3&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you insist on using private addressing, I'd encourage a thorough review of why - but this is the prefix available:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fc00::/7&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Link-local addressing, or addressing that is "always on" regardless of prefix allocation, is also allocated a specific prefix. This prevents the need of a &lt;em&gt;bunch&lt;/em&gt; of little helper protocols that simply don't need to exist, or become standardized. Traffic like Router Advertisements(RA), Routing Protocols, First Hop Redundancy Protocols have a distinct source address that can be pinged even before a network is online.&lt;/p&gt;

&lt;p&gt;It's also incredibly handy when bootstrapping new devices! All that's required is some form of helper on the default gateway to act as an SSH proxy and some neighbor discovery, and you suddenly have always-on remote management.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fe80::/10&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Multicast also has its own prefix:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ff80::/8&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This is much simpler, but where to &lt;em&gt;get&lt;/em&gt; IPv6 addressing can be more complex. If it's in a lab environment and doesn't need internet access, &lt;code&gt;fc00::/7&lt;/code&gt; is just fine to use.&lt;/p&gt;

&lt;p&gt;The recommended method for acquiring an IPv6 prefix is to request it with &lt;a href="https://www.isc.org/blogs/dhcpv6-prefix-length-mode/"&gt;DHCP-PD&lt;/a&gt; or to request it through a &lt;a href="https://tunnelbroker.net/"&gt;tunnel broker&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;There's one more "gotcha" to keep in mind with IPv6 - weird stuff breaks if you go with a longer prefix than /64. I'd strongly encourage avoiding cutesy CIDR block allocations like /120 or /65; that's an IPv4 solution to a problem IPv6 doesn't have. Just request enough IP addressing for your site instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Constructing an IP hierarchy
&lt;/h2&gt;

&lt;p&gt;For the purposes of this post, we're going to use the following language to describe a &lt;em&gt;network address&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1{{ prefix }}{{ subprefix }}{{ host bits }}/{{ prefix length }}
2 10.99. 100. 0 /24

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The major first step here is to decide &lt;em&gt;how&lt;/em&gt; to break down your addressing. There are two major paths to follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Location&lt;/em&gt; based addressing is used when &lt;em&gt;prefix scale&lt;/em&gt; is a concern. If you need to summarize routes on routers due to RIB limits, this is the way to go. This can be for a few reasons:

&lt;ul&gt;
&lt;li&gt;"I have a lot of routes / lot of sites and am worried about RIB capacity in my hardware"&lt;/li&gt;
&lt;li&gt;Most enterprise equipment can handle 16-64k routes; if this is not enough, follow this approach&lt;/li&gt;
&lt;li&gt;ISPs will follow this path&lt;/li&gt;
&lt;li&gt;Cloud providers will follow this path&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Purpose&lt;/em&gt; based addressing is used when &lt;em&gt;perimeter security&lt;/em&gt; is a concern. Easy summarization to a common prefix per "network role" allows for straightforward firewall policy creation, including a number of microsegmentation tools that may have laughably low table capacities.

&lt;ul&gt;
&lt;li&gt;"I want to keep my workloads separated from each other"&lt;/li&gt;
&lt;li&gt;Financial services will follow this path&lt;/li&gt;
&lt;li&gt;Healthcare will follow this path&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Committing to one or the other before allocating blocks will simplify your life later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note: With IPv6, only the largest of organizations (ones that need more than 2^8 or 2^16 networks per site) will need to allocate their own top-level prefix. It's easier to just run DHCP-PD and ask for a /56 or /48.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Guidance on prefix allocation
&lt;/h2&gt;

&lt;p&gt;To assess the proper 1918/bogon prefix for use, first assess the number of prefixes you would need as a ceiling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1num_sites*num_network_roles

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attempt to select a site that will fit this prefix count with a minumum of 80% buffer (leaving a reserve for point-to-point connects, etc.)&lt;/p&gt;

&lt;p&gt;I would highly encourage &lt;em&gt;not&lt;/em&gt; getting creative with CIDR prefix lengths in IPv4-land. If possible, try and stick to &lt;code&gt;/24&lt;/code&gt; for a subprefix. &lt;a href="https://datatracker.ietf.org/doc/html/rfc4862"&gt;IPv6 does not support prefix lengths longer than &lt;code&gt;/64&lt;/code&gt; particularly well&lt;/a&gt; (with specific exceptions for point-to-point, &lt;code&gt;/126&lt;/code&gt; or &lt;code&gt;/127&lt;/code&gt; depending on hardware), and using prefixes like &lt;code&gt;/65&lt;/code&gt; for access segments will lead to trouble with end devices like Android.&lt;/p&gt;

&lt;p&gt;It's much simpler to translate the &lt;code&gt;/24&lt;/code&gt; in question linearly to a &lt;code&gt;/64&lt;/code&gt; and using that calculation to estimate what IPv6 prefix size you want. It's &lt;strong&gt;also much simpler to troubleshoot and maintain if you don't build a pile of weird stuff, even if it makes you feel smart!&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As a starting point, it's good to set up a set of standard t-shirt sizes for networks in `{{ IPv4 }}/{{ IPv6 }} format. Here's an example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Large Site/Role: &lt;code&gt;/16&lt;/code&gt;/&lt;code&gt;/56&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Medium Site/Role: &lt;code&gt;/18&lt;/code&gt;/&lt;code&gt;/60&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Small Site/Role: &lt;code&gt;/22&lt;/code&gt;/&lt;code&gt;/62&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;"Normal" subprefix: &lt;code&gt;/24&lt;/code&gt;/&lt;code&gt;/64&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;"Small" subprefix: &lt;code&gt;/26&lt;/code&gt;/&lt;code&gt;/64&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Point-to-point: &lt;code&gt;/31&lt;/code&gt;/&lt;code&gt;/127&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Note: Service providers don't always support weird DHCP-PD sizes, so options may be limited to the above.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note: Service providers are typically pretty generous with prefix allocations, and keep in mind a /56 is roughly equivalent to a /16. I'd recommend allocating a /56 per site in production or in the lab whenever permitted.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Automating it
&lt;/h2&gt;

&lt;p&gt;Once you have sizes set, it's actually pretty easy to let go of your artisanal, hand-crafted prefixes and automate aggressively. With Netbox and Ansible, it's incredibly easy to leverage the &lt;a href="https://docs.ansible.com/ansible/latest/collections/netbox/netbox/netbox_prefix_module.html#ansible-collections-netbox-netbox-netbox-prefix-module"&gt;&lt;code&gt;netbox.netbox.netbox_prefix&lt;/code&gt; module&lt;/a&gt;. The following example will grab a &lt;code&gt;/24&lt;/code&gt; from &lt;code&gt;10.99.0.0/16&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
 1- name: "Example Ansible Playbook"&lt;br&gt;
 2 connection: local&lt;br&gt;
 3 hosts: localhost&lt;br&gt;
 4 gather_facts: False&lt;br&gt;
 5 tasks:&lt;br&gt;
 6 - name: "Get next available prefix"&lt;br&gt;
 7 netbox.netbox.netbox_prefix:&lt;br&gt;
 8 netbox_url: "{{ netbox_url }}"&lt;br&gt;
 9 netbox_token: "{{ netbox_token }}"&lt;br&gt;
10 data:&lt;br&gt;
11 parent: "10.99.0.0/16"&lt;br&gt;
12 prefix_length: 24&lt;br&gt;
13 state: present&lt;br&gt;
14 first_available: yes&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;It's extremely rewarding to design, deploy, and automate an IP design in this manner - and you'll find that automation is considerably easier if &lt;em&gt;what&lt;/em&gt; to automate is well-defined.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Manage Linux patching with Ansible and Netbox!</title>
      <dc:creator>Nick Schmidt</dc:creator>
      <pubDate>Sun, 07 Apr 2024 09:00:00 +0000</pubDate>
      <link>https://dev.to/ngschmidt/manage-linux-patching-with-ansible-and-netbox-400d</link>
      <guid>https://dev.to/ngschmidt/manage-linux-patching-with-ansible-and-netbox-400d</guid>
      <description>&lt;h2&gt;
  
  
  Patching all of my random experiments took too much of my free time, so I automated it.
&lt;/h2&gt;

&lt;p&gt;This is a pretty cheesy thing to do, but over the years it became more and more time-consuming to maintain all the different deployed workloads and infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requirements
&lt;/h2&gt;

&lt;p&gt;With all system design, it's best to consider all relevant needs ahead of time. Given that this is a home lab, I decided to adopt an intentionally aggressive, but theoretically viable in production approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nightly patching&lt;/li&gt;
&lt;li&gt;Nightly reboots&lt;/li&gt;
&lt;li&gt;No exempt packages&lt;/li&gt;
&lt;li&gt;Distribution-agnostic, it should patch multiple distributions at once&lt;/li&gt;
&lt;li&gt;This workflow should execute consistently from-code&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Iteration 1: Ansible with Jenkins
&lt;/h2&gt;

&lt;p&gt;The earliest implementation I built here had the least refinement by far. Here I tied Jenkins to an internal repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="//ansible_jenkins.svg"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--VM1Y3Ipo--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://blog.engyak.co/2024/04/patching/ansible_jenkins.svg" alt="Generation 1: Ansible + Jenkins" width="637" height="507"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To leverage this, I started out with an &lt;a href="https://docs.ansible.com/ansible/latest/collections/ansible/builtin/ini_inventory.html"&gt;INI inventory&lt;/a&gt;, but it quickly became problematic. I wanted a hierarchy, with each distribution potentially fitting multiple categories. This became pretty messy pretty quickly, so I moved to a &lt;a href="https://docs.ansible.com/ansible/latest/inventory_guide/intro_inventory.html"&gt;YAML Inventory&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;debian_machines:
  hosts:
    hostname_1:
      ansible_host: "1.1.1.1"
    hostname_2:
      ansible_host: "2.2.2.2"
ubuntu_machines:
  hosts:
    hostname_3:
      ansible_host: "3.3.3.3"
apt_updates:
  children:
    debian_machines:
    ubuntu_machines:
nameservers:
  children:
    hostname_2:
    hostname_3:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This allowed me to simplify my playbooks and inventory by making "groups of groups", and avoid crazy stuff like taking down all nodes for an application at once. We'll use &lt;code&gt;nameservers:&lt;/code&gt; as an example here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
- name: "Reboot APT Machines, except DNS"
  hosts: apt_updates,!nameservers
  tasks:
    - name: "Ansible Self-Test!"
      ansible.builtin.ping:
    - name: "Reboot Apt Machines!"
      ansible.builtin.reboot:
- name: "Reboot nameservers"
  hosts: nameservers
  serial: 1
  tasks:
    - name: "Ansible Self-Test!"
      ansible.builtin.ping:
    - name: "Reboot nameservers serially!"
      ansible.builtin.reboot:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;serial: 1&lt;/code&gt; key instructs the Ansible controller to only execute this playbook on one machine at a time, so DNS continuity is preserved.&lt;/p&gt;

&lt;h3&gt;
  
  
  Retrospective
&lt;/h3&gt;

&lt;p&gt;I had several issues with this approach, but to my surprise, Linux patching and actual Ansible issues haven't cropped up at all. With most mainstream distributions, the QC must be good enough to patch nightly like this.&lt;/p&gt;

&lt;p&gt;I did have issues with inventory management, however. To update the Ansible inventory, I could deploy as-code, which was nice, but it was still clunky. If I &lt;a href="https://blog.engyak.co/2023/01/why-automate-vm-deployment-with/"&gt;deployed 5 Alpine images in a day&lt;/a&gt;, I want them to automatically be added to my inventory for maximum laziness.&lt;/p&gt;

&lt;p&gt;I also quickly discovered that maintaining Jenkins was labor-intensive. It's a truly powerful engine, and great if you need all the extra features, but there aren't many low-friction ways to automate all the required maintenance, particularly around plugins. I was able to update Jenkins &lt;em&gt;itself&lt;/em&gt; with a package manager, but it seems like every few days I had to patch plugins (manually).&lt;/p&gt;

&lt;h2&gt;
  
  
  Iteration 2: Ansible, Netbox, GitHub Actions
&lt;/h2&gt;

&lt;p&gt;I'll be up-front - for parameterized builds, GitHub Actions &lt;em&gt;is less capable.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It has some pretty big upsides, however:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You don't have to maintain the GUI &lt;em&gt;at all&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Logging is excellent&lt;/li&gt;
&lt;li&gt;Integration with GitHub is excellent&lt;/li&gt;
&lt;li&gt;Pipelines are YAML defined in their own repository&lt;/li&gt;
&lt;li&gt;Status badges in Markdown (we don't need some stinkin' badges!)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="//ansible_github_netbox.svg"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--AAxYEFvB--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://blog.engyak.co/2024/04/patching/ansible_github_netbox.svg" alt="GitHub Actions and Netbox" width="556" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This workflow has been much smoother to operate. Since the &lt;a href="https://blog.engyak.co/2023/09/vsphere-dayn/"&gt;deployment workflow already updates Netbox&lt;/a&gt;, all machines are added to the "maintenance loop after first boot.&lt;/p&gt;

&lt;p&gt;I was really surprised at how little work was required to convert these CI pipelines. This was naive of me - &lt;strong&gt;ease of conversion is the entire point of CI pipelines&lt;/strong&gt;, but it's still mind-boggling to realize how effective it is at times.&lt;/p&gt;

&lt;p&gt;To make this work, I first needed to create a CI process in &lt;code&gt;.github/workflows&lt;/code&gt; on my Lab repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name: "Nightly: @0100 Update Linux Machines"

on:
  schedule:
    - cron: "0 9 * * *"

permissions:
  contents: read

jobs:
  build:
    runs-on: self-hosted
    steps:
    - uses: actions/checkout@v3
    - name: Execute Ansible Nightly Job
      run: |
        python3 -m venv .
        source bin/activate
        python3 -m pip install --upgrade pip
        python3 -m pip install -r requirements.txt
        python3 --version
        ansible --version
        export NETBOX_TOKEN=${{ secrets.NETBOX_TOKEN }}
        export NETBOX_API=${{ vars.NETBOX_URL }}
        ansible-galaxy collection install netbox.netbox
        ansible-inventory -i local.netbox.netbox.nb_inventory.yml --graph
        ansible-playbook -i local.netbox.netbox.nb_inventory.yml lab-nightly.yml      
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This executes on a &lt;a href="https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners"&gt;GitHub Self-Hosted runner&lt;/a&gt; in my lab with a Python Virtual Environment. The workflow will run a clean build, every time - by wiping out the workspace prior to each execution. &lt;em&gt;No configuration artifacts are left behind.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;With GitHub Actions, all processes are listed alphabetically, you can't do folders and trees to keep it more organized. I developed a naming convention:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{ workflow_type }}: {{ description}}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To keep things sane.&lt;/p&gt;

&lt;p&gt;From there, we need a way to point to Netbox as an inventory source. This requires a few files:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;requirements.txt&lt;/em&gt; is the Python 3 Pip inventory - since things are running in a virtual environment, it will only use python packages in this list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
txt&lt;/p&gt;
&lt;h6&gt;
  
  
  Requirements without Version Specifiers
&lt;/h6&gt;

&lt;p&gt;pytz&lt;br&gt;
netaddr&lt;br&gt;
django&lt;br&gt;
jinja2&lt;br&gt;
requests&lt;br&gt;
pynetbox&lt;/p&gt;
&lt;h6&gt;
  
  
  Requirements with Version Specifiers
&lt;/h6&gt;

&lt;p&gt;ansible &amp;gt;= 8.4.0              # Mostly just don't use old Ansible (e.g. v2, v3)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
The next step is to build an inventory file. This has to be named specifically for the plugin to work - `local.netbox.netbox.nb_inventory.yml`:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;plugin: netbox.netbox.nb_inventory&lt;br&gt;
validate_certs: False&lt;br&gt;
config_context: True&lt;br&gt;
group_by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tags
device_query_filters:&lt;/li&gt;
&lt;li&gt;has_primary_ip: 'true'
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
- **Not featured here - The API Endpoint and API Token directives are handled by [GitHub Actions Secrets](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions), and therefore don't need to be in this file.**

This file is pretty straightforward. It indicates that we should use Netbox tags to develop our inventory, and we can assign multiple tags in the Netbox application to each Virtual Machine. I also added the `has_primary_ip` directive - if a machine doesn't get an IP address for some reason, it won't try to reach that VM and patch it, causing late night failures.

Here's a preview of the Netbox application with these tags:

[![Netbox Preview](https://blog.engyak.co/2024/04/patching/netbox_preview.png)](netbox_preview.png)

Refactoring the Ansible playbooks was hilariously easy. The Netbox inventory plugin prepends the `group_by` field onto the group, so all I had to do in each playbook was prepend `tags_` to each name. Here's an example:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;name: "Apt Machines"
hosts: tags_lab_apt_updates
tasks:

&lt;ul&gt;
&lt;li&gt;name: "Ansible Self-Test!"
ansible.builtin.ping:&lt;/li&gt;
&lt;li&gt;name: "Update Apt!"
ansible.builtin.apt:
name: "*"
state: latest
update_cache: true&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;
&lt;li&gt;name: "Apk Machines"
hosts: tags_lab_apk_updates
tasks:

&lt;ul&gt;
&lt;li&gt;name: "Ansible Self-Test!"
ansible.builtin.ping:&lt;/li&gt;
&lt;li&gt;name: "Update Apk!"
ansible.builtin.apt:
available: true
upgrade: true
update_cache: true&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;
&lt;/ul&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


After that, the CI tooling just takes care of it all for me!

### Retrospective

I'm going to stick with this method for a while. Netbox tagging makes inventory management much more intuitive, and I can develop tag "pre-sets" in my deployment pipeline to correctly categorize all the _stuff_ I deploy. Since it's effectively documentation, I have an easy place to put data I'll need to find later for those "what was I thinking?" moments.

I'll be honest - behind that, I haven't really given it much thought. This approach requires zero attention to continue, and it happens while I sleep. I haven't gotten any problems from it, and it allows me to focus my free time on things that are more important.

10/10 would recommend.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
    </item>
    <item>
      <title>Abstracting DNS Record Management with Ansible and Jinja 2</title>
      <dc:creator>Nick Schmidt</dc:creator>
      <pubDate>Sat, 06 Jan 2024 09:00:00 +0000</pubDate>
      <link>https://dev.to/ngschmidt/abstracting-dns-record-management-with-ansible-and-jinja-2-7mf</link>
      <guid>https://dev.to/ngschmidt/abstracting-dns-record-management-with-ansible-and-jinja-2-7mf</guid>
      <description>&lt;p&gt;Synchronizing properly implemented DNS zones is, to put it lightly, a &lt;em&gt;real chore&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creating &lt;em&gt;forward&lt;/em&gt; DNS entries, e.g. &lt;code&gt;A&lt;/code&gt;, &lt;code&gt;AAAA&lt;/code&gt;, &lt;code&gt;CNAME&lt;/code&gt;. These names are used to resolve to resources.&lt;/li&gt;
&lt;li&gt;Creating &lt;em&gt;reverse&lt;/em&gt; DNS entries, e.g. &lt;code&gt;PTR&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Creating DNS entries that define the zone, e.g. &lt;code&gt;SOA&lt;/code&gt;, &lt;code&gt;NS&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a system to behave properly, your &lt;em&gt;forward&lt;/em&gt; and &lt;em&gt;reverse&lt;/em&gt; entries need to be identical, but software like BIND/Unbound rely on zonefiles that don't connect the two. Many information systems / DNS zones exist with improperly implemented &lt;em&gt;reverse&lt;/em&gt; DNS, or partially implemented &lt;em&gt;forward&lt;/em&gt; DNS asymptomatically for a time. Certain events (e.g. CA validation, discovery, implementing IPv6) can bring things to the forefront if ordinary network management practice doesn't.&lt;/p&gt;

&lt;p&gt;For this post, we'll first work on &lt;em&gt;abstracting&lt;/em&gt; the DNS zonefile - ensuring that a user can deploy zonefiles conformant to a standard - and then we'll illustrate how that can be used with Netbox to automatically populate DNS entries from Netbox.&lt;/p&gt;

&lt;p&gt;Abstracting the zonefile here will achieve a few goals - but the file size is &lt;em&gt;guaranteed&lt;/em&gt; to be longer than if you simply managed the zone files from source. Here are some advantages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This pipeline &lt;strong&gt;ABSOLUTELY MUST&lt;/strong&gt; establish forward and reverse records &lt;strong&gt;from the same data!&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;This pipeline &lt;strong&gt;must&lt;/strong&gt; test zonefiles, and avoid installing them if they aren't good (prevents outages)&lt;/li&gt;
&lt;li&gt;This pipeline &lt;strong&gt;must&lt;/strong&gt; establish documentation standards for a DNS zone (abstract the standard)&lt;/li&gt;
&lt;li&gt;This pipeline &lt;strong&gt;must&lt;/strong&gt; scale to support large quantities of DNS zones / records&lt;/li&gt;
&lt;li&gt;This pipeline &lt;strong&gt;must&lt;/strong&gt; be easy to use, even with inexperienced DNS administrators (we can't have it all be on the shoulders of &lt;em&gt;that one guy who can safely make DNS changes&lt;/em&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To achieve this, we'll first establish a YAML schema and Jinja2 template to structure the data. Here's the YAML schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 1zones:
 2 - name: filename
 3 zonename:
 4 soa:
 5 settings:
 6 ttl:
 7 serial:
 8 refresh:
 9 retry:
10 expiry:
11 nameservers: []
12 reverse_zones:
13 ip4:
14 ip6:
15 records: [{ "name": "", "type": "", "addr": ""}]

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are also some subtle differences between IPv4 and IPv6 reverse zones, so in this case, we're going to use three Jinja2 templates (in the Gist below).&lt;/p&gt;

&lt;p&gt;It also assumes that there's a dedicated &lt;strong&gt;classful&lt;/strong&gt; prefix for each DNS zone. This isn't always true for more complex deployments, but they can also do stuff like buy Infoblox.&lt;/p&gt;

&lt;p&gt;I have also included a GitHub Action in the gist, because it provides a good place to demostrate best practices (e.g. using &lt;code&gt;venv&lt;/code&gt;) in one compact place. If you want to install generated zone files on-premises, you can run this on a self-hosted runner with an Ansible inventory group (e.g. &lt;code&gt;nameservers&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;It's still a little clunky, the next step should help with that (harvesting DDI information from Netbox IPAM data).&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@import url('https://cdn.rawgit.com/lonekorean/gist-syntax-themes/d49b91b3/stylesheets/idle-fingers.css');

@import url('https://fonts.googleapis.com/css?family=Open+Sans');
body {
  font: 16px 'Open Sans', sans-serif;
}
body .gist .gist-file {
  border-color: #555 #555 #444
}
body .gist .gist-data {
  border-color: #555
}
body .gist .gist-meta {
  color: #ffffff;
  background: #373737; 
}
body .gist .gist-meta a {
  color: #ffffff
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;a href="https://gist.github.com/ngschmidt/d0862985e382b052fd3f42bbc4082af3"&gt;GitHub Link&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Build and Consume Alpine Linux vSphere Images</title>
      <dc:creator>Nick Schmidt</dc:creator>
      <pubDate>Sun, 24 Dec 2023 09:00:00 +0000</pubDate>
      <link>https://dev.to/ngschmidt/build-and-consume-alpine-linux-vsphere-images-3g78</link>
      <guid>https://dev.to/ngschmidt/build-and-consume-alpine-linux-vsphere-images-3g78</guid>
      <description>&lt;h2&gt;
  
  
  Deploying Linux for the impatient
&lt;/h2&gt;

&lt;p&gt;If you've ever wanted to just "test something out really quick" in a live environment, Linux distributions have always been generally lightweight, but that's not the only implicit requirement for experimentation.&lt;/p&gt;

&lt;p&gt;A Linux IaaS distribution should be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reasonably secure (basic hardening applied, fewer packages == fewer vulnerabilities)&lt;/li&gt;
&lt;li&gt;Light on disk usage (shortening deployment times)&lt;/li&gt;
&lt;li&gt;Light on system resources, e.g. CPU/Memory&lt;/li&gt;
&lt;li&gt;Flexible (supports a package manager with a wide ecosystem of packages)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Package flexibility is usually the compromise made here - but when you're deploying programmable code, container images and virtual environments like Python's &lt;code&gt;venv&lt;/code&gt; should be able to bridge &lt;em&gt;some&lt;/em&gt; gaps.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.alpinelinux.org/about/"&gt;Alpine Linux&lt;/a&gt; focuses on these goals - but doesn't compromise on automation. Combined with a &lt;a href="https://blog.engyak.co/2023/09/vsphere-dayn/"&gt;dynamic inventory bootstrapping process&lt;/a&gt;, it's relatively straightforward to bring Alpine's &lt;a href="https://docs.ansible.com/ansible/latest/collections/community/general/apk_module.html"&gt;APK ansible module&lt;/a&gt; into play to build any extra software on a new machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Customizing and building the ISO
&lt;/h3&gt;

&lt;p&gt;First, let's upload the ISO to a datastore:&lt;/p&gt;

&lt;p&gt;&lt;a href="//1_addiso.png"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--fsi7fUhR--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://blog.engyak.co/2023/12/alpine/1_addiso.png" alt="Add ISO Image" width="800" height="205"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's create a new Virtual Machine. We'll attach the ISO to it&lt;/p&gt;

&lt;p&gt;&lt;a href="//2_createvm.png"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--M8VSU_P6--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://blog.engyak.co/2023/12/alpine/2_createvm.png" alt="Create VM from cluster" width="351" height="165"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="//3_names.png"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--0ihKKihy--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://blog.engyak.co/2023/12/alpine/3_names.png" alt="Set VM Name" width="364" height="232"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Target any storage and compute as preferred. SSD datastores will be faster, of course.&lt;/p&gt;

&lt;p&gt;vSphere 8.0 Update 2 doesn't have a preset for Alpine Linux, and the guest OS options are &lt;em&gt;important&lt;/em&gt; - it defines what paravirtualized hardware is available:&lt;/p&gt;

&lt;p&gt;&lt;a href="//4_guest.png"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--merUN1On--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://blog.engyak.co/2023/12/alpine/4_guest.png" alt="Guest OS Customization: Linux Other 5.x (64 Bit)" width="787" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ensure that VMXNET 3 and PVSCSI are both available. The "New Network" will become the default port-group assigned to the template.&lt;/p&gt;

&lt;p&gt;CPU/Memory are mostly irrelevant, as the deployment pipeline can customize afterwards - and this OS doesn't need much in terms of resources:&lt;/p&gt;

&lt;p&gt;&lt;a href="//5_hardware.png"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--Ndfla6CA--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://blog.engyak.co/2023/12/alpine/5_hardware.png" alt="Guest Hardware Customization" width="800" height="476"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Select the Alpine "Datastore ISO" and enable "Connect and Power On" for the assigned CD/DVD drive:&lt;/p&gt;

&lt;p&gt;&lt;a href="//6_iso.png"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--zfLfvgkb--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://blog.engyak.co/2023/12/alpine/6_iso.png" alt="ISO Configuration" width="547" height="225"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Start the machine - it'll boot to a command prompt &lt;em&gt;very quickly&lt;/em&gt;. Log in as root:&lt;/p&gt;

&lt;p&gt;&lt;a href="//7_alpine_start.png"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--OwNFW3a---/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://blog.engyak.co/2023/12/alpine/7_alpine_start.png" alt="Start Alpine" width="800" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://wiki.alpinelinux.org/wiki/Installation"&gt;installation guide&lt;/a&gt; indicates to use the &lt;code&gt;setup-alpine&lt;/code&gt; script, and follow the prompts:&lt;/p&gt;

&lt;p&gt;&lt;a href="//8_alpine_setup.png"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--MsBW4fK5--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://blog.engyak.co/2023/12/alpine/8_alpine_setup.png" alt="Alpine Setup" width="546" height="657"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The majority of setup here is extremely simple - because it's not installing a bunch of software. GUIs are also possible after the installation is complete - but it does defeat the point.&lt;/p&gt;

&lt;p&gt;Instead of rebooting as instructed, shut the virtual machine down and delete the disk drive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Note: The shutdown process isn't installed with Alpine, and the following command &lt;em&gt;does&lt;/em&gt; execute a graceful shutdown!&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;shutdown now
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="//9_remove_iso.png"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--izzhz73B--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://blog.engyak.co/2023/12/alpine/9_remove_iso.png" alt="Remove CD/DVD-ROM drive" width="800" height="652"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Start the machine up - we'll want to add some quality of life improvements to this machine like guest tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{ insert favorite editor here }} /etc/apk/repositories
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove the &lt;code&gt;#&lt;/code&gt; from the line ending in &lt;code&gt;/alpine/v{{ version }}/community&lt;/code&gt; and save.&lt;/p&gt;

&lt;p&gt;Per &lt;a href="https://wiki.alpinelinux.org/wiki/Open-vm-tools"&gt;Alpine's guide&lt;/a&gt;, install and enable open-vm-tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apk add open-vm-tools open-vm-tools-guestinfo open-vm-tools-deploypkg
rc-service open-vm-tools start
rc-update add open-vm-tools boot

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once running, ensure that guest power actions are available:&lt;/p&gt;

&lt;p&gt;&lt;a href="//10_test_guest.png"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--D8BlsNdV--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://blog.engyak.co/2023/12/alpine/10_test_guest.png" alt="Guest Power Actions" width="572" height="306"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Personally, I like testing, so instead of powering off the VM, I use the guest action to ensure everything is working. Either way, shut the VM down.&lt;/p&gt;

&lt;p&gt;Hit "Actions" on the VM, or right-click it, and select "Clone → Clone as Template to Library":&lt;/p&gt;

&lt;p&gt;&lt;a href="//11_clone_to_library.png"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--b2-SzyEN--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://blog.engyak.co/2023/12/alpine/11_clone_to_library.png" alt="Clone as Template to Library" width="549" height="313"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Select whatever storage backing and content libraries are preferable at this point. It won't take long to clone in. Delete the old VM whenever it makes sense, I usually do so after testing a deployment:&lt;/p&gt;

&lt;p&gt;&lt;a href="//12_deploy_test.png"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--u8cD4cea--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://blog.engyak.co/2023/12/alpine/12_deploy_test.png" alt="Deploy Test VM" width="399" height="107"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Note: Set "Power on VM after creation" - this will clone &lt;em&gt;extremely&lt;/em&gt; quickly and boot even faster.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Modifying the deployment pipeline
&lt;/h3&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@import url('https://cdn.rawgit.com/lonekorean/gist-syntax-themes/d49b91b3/stylesheets/idle-fingers.css');

@import url('https://fonts.googleapis.com/css?family=Open+Sans');
body {
  font: 16px 'Open Sans', sans-serif;
}
body .gist .gist-file {
  border-color: #555 #555 #444
}
body .gist .gist-data {
  border-color: #555
}
body .gist .gist-meta {
  color: #ffffff;
  background: #373737; 
}
body .gist .gist-meta a {
  color: #ffffff
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;The deployment pipeline code itself is available &lt;a href="https://gist.github.com/ngschmidt/88fe09a1c5733735a4232dd24c44f78e"&gt;here&lt;/a&gt;. I've made some modifications from previous versions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub Actions now supports the &lt;code&gt;choice&lt;/code&gt; type, which means we can select UUIDs. There isn't a way to build a "friendly name" mapping. We achieve this by creating a "lookup dictionary" with the friendly name as a key and a UUID as the value. This list will need to be populated via data collection (featured below).
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;First, we'll need to find out what the UUID of the template and cluster are. &lt;a href="https://gist.github.com/ngschmidt/0c7687cb62ba6f7bb98feb67ff936906"&gt;Here's an example&lt;/a&gt; to collect the required information. The UUIDs of system resources (and this template) are only available via the API. Use this information to form the &lt;code&gt;parameters.yml file created in the GitHub Action workflow&lt;/code&gt;, e.g. &lt;code&gt;datastore&lt;/code&gt;, &lt;code&gt;cluster&lt;/code&gt;, &lt;code&gt;folder&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Adjusting and running this workflow will allow an engineer to populate the previous workflow and expose vSphere assets to further deployment automation!&lt;/p&gt;

&lt;p&gt;For reference, this machine deployed in about 3 seconds on a shared SSD (iSCSI):&lt;/p&gt;

&lt;p&gt;&lt;a href="//13_deployment_benchmark.png"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--Xo508zu9--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://blog.engyak.co/2023/12/alpine/13_deployment_benchmark.png" alt="Deployment timeline" width="639" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The GitHub workflow takes &amp;gt;2 minutes to complete, but the workflow it's attached to has manual wait step:&lt;/p&gt;

&lt;p&gt;&lt;a href="//14_actions.png"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--fpAkq_ED--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://blog.engyak.co/2023/12/alpine/14_actions.png" alt="GitHub Actions" width="678" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Apollo 13's "Failure is not an option", and how non-engineers misinterpret it</title>
      <dc:creator>Nick Schmidt</dc:creator>
      <pubDate>Sat, 25 Nov 2023 09:00:00 +0000</pubDate>
      <link>https://dev.to/ngschmidt/apollo-13s-failure-is-not-an-option-and-how-non-engineers-misinterpret-it-4g2g</link>
      <guid>https://dev.to/ngschmidt/apollo-13s-failure-is-not-an-option-and-how-non-engineers-misinterpret-it-4g2g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Failure is not an option!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It might surprise you to know that this quote wasn't real - it &lt;em&gt;feels&lt;/em&gt; legendary, but was never said by Gene Kranz. &lt;a href="https://web.archive.org/web/20100123160551/http://www.spaceacts.com/notanoption.htm"&gt;It was written up for the film.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The aerospace engineering discipline isn't really something everybody gets to experience, so it makes sense that "spicing things up" for the movie would be generally accepted as reality.&lt;/p&gt;

&lt;p&gt;When you create a program (or release a new capability), it makes perfect sense to get all excited and release it as soon as you feel it's "done" - but this is just an example of how IT/Computer Science is relatively young compared to other engineering disciplines.&lt;/p&gt;

&lt;p&gt;With more traditional engineering disciplines, &lt;em&gt;testing&lt;/em&gt; is a key aspect to deployment and design. Everything is tested for safety. Concrete is thoroughly &lt;a href="https://www.astm.org/products-services/standards-and-publications/standards/cement-standards-and-concrete-standards.html"&gt;tested before integration in bridges and structures&lt;/a&gt;. &lt;em&gt;Most&lt;/em&gt; pickup trucks are tested &lt;a href="https://www.sae.org/standards/content/j2807_202002/"&gt;to their listed tow capacity&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This isn't a perfect ideal world, however. Bridges still fail, and in this case companies didn't follow the SAE J2807 standard until forced (Toyota: 2011, General Motors: 2015, Ford: 2015, Dodge: 2015).&lt;/p&gt;

&lt;h2&gt;
  
  
  Industry-wide changes take time
&lt;/h2&gt;

&lt;p&gt;Here's why: It's expensive to re-tool in the physical world. NASA just straight up didn't have the option, so they compensated by accounting for as many potential scenarios as possible, at the expense of cost. That's what "Failure is not an option" was intended to reflect. Everything is tested and planned ahead of time, and the mission systems didn't try anything truly new.&lt;/p&gt;

&lt;p&gt;Engineering is the practice of taking learned experiences and codifying them, ensuring that the same mistake doesn't happen twice. The safety codes and engineering artifacts we use in the physical world are "written in blood" - many structural engineering practices were learnt from a loss of life, it's why they're so important.&lt;/p&gt;

&lt;p&gt;I don't think anybody has died due to an email not getting through, but I'd counter that the same practices are &lt;em&gt;much&lt;/em&gt; easier to execute in IT and therefore should be followed. IT is a relatively young engineering-adjacent discipline, and the standards for performance are relatively low, albeit always increasing.&lt;/p&gt;

&lt;p&gt;Here's a rough estimate of each engineering discipline's age:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chemical Engineering (~1800s AD)&lt;/li&gt;
&lt;li&gt;Civil Engineering (BC, formalized in the 1700s AD)&lt;/li&gt;
&lt;li&gt;Electrical Engineering (1700s AD, formalized in the 1800s AD)&lt;/li&gt;
&lt;li&gt;Mechanical Engineering (BC, formalized in the 1800s AD)&lt;/li&gt;
&lt;li&gt;Software Engineering (1960s AD)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;More recent engineering disciplines fit in these families, and one could argue (correctly) that while they are younger, they benefit from the preceding disciplines and the broader body of knowledge. This is particularly true in the field of aerospace.&lt;/p&gt;

&lt;p&gt;Systems Engineering practitioners have collected a number of practices together to integrate new technologies and disciplines in the &lt;a href="https://sebokwiki.org/wiki/Guide_to_the_Systems_Engineering_Body_of_Knowledge_(SEBoK)"&gt;SEBoK&lt;/a&gt; - which essentially forms a "starter kit" of practices and protocols for developing new solutions. The SEBoK is an excellent (albeit overwhelming) place to procure methods for continuous improvement, either as a team or individually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't fear failure, understand it
&lt;/h2&gt;

&lt;p&gt;Across all of these disciplines, we see a common pattern around failure; the natural reaction to failure is to avoid it. Humans don't want to be associated with failure, and this reflex must be overridden to be a successful engineer.&lt;/p&gt;

&lt;p&gt;I'd like to provide an example of good failure analysis instead of harping on past failures - my concern here is that any controversy may get in the way of the idea I want to convey - which deviates from the practice of &lt;a href="https://www.sciencedirect.com/topics/engineering/failure-analysis"&gt;failure analysis&lt;/a&gt; somewhat.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.wsdot.wa.gov/tnbhistory/collapse.htm"&gt;Washington State DOT's analysis of the Tacoma Narrows bridge failure&lt;/a&gt; is an example of well-executed failure analysis.&lt;/p&gt;

&lt;p&gt;In this case, the structure was too rigid - "common sense" would tell us that if a bridge is extremely strong, it won't have any issues standing up to high winds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying failure analysis to IT
&lt;/h2&gt;

&lt;p&gt;It's important that we learn from these shortcomings and integrate solutions into future designs. Typically, this is where "system integration" comes into play - as a product is validated for release, all known tests are applied to it to ensure that failures don't recur. The NASA engineers supporting Apollo 13 didn't try &lt;em&gt;anything&lt;/em&gt; new on the mission system (Apollo 13 itself). NASA tested all solutions &lt;em&gt;thoroughly&lt;/em&gt; with the ground crew, astronauts, and QA engineers before rollout was ever considered an option.&lt;/p&gt;

&lt;p&gt;The Apollo program was extremely expensive compared to most of our IT budgets, but we're almost always testing &lt;em&gt;software&lt;/em&gt;. Failure Analysis practices are trivial with software debugging and mature unit testing, and eventually we're going to have to perform at the standards held by traditional engineering disciplines.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example - a maintenance window backfired
&lt;/h3&gt;

&lt;p&gt;We've all been here before - let's say that spanning tree did something unexpected during a maintenance window and caused unexpected downtime.&lt;/p&gt;

&lt;p&gt;The first and most effective aspect of failure analysis (at least for our careers) is to provide a compelling narrative. We need to invert the human reflexive reaction to failure and encourage interest over punitive behaviors. Writing a complete and compelling narrative both ensures that people will react more positively to the occurrence and provide confidence that due diligence will be performed to ensure it doesn't happen again.&lt;/p&gt;

&lt;p&gt;Sure, it'll always happen again with STP in some way, but other materials have common patterns and properties too. We didn't stop using aluminum because it isn't as strong as steel or as good of a conductor as copper; instead we learned its strengths and weaknesses, applying the solution judiciously. In this case, we need to prove that we will apply the solution more judiciously as well.&lt;/p&gt;

&lt;p&gt;Second, gather all possible data on the time of the outage. Don't try to filter it yet, and don't react slowly. Anything that can record system data is valuable here (telemetry in particular) - so automatic gathering is &lt;em&gt;extremely&lt;/em&gt; valuable.&lt;/p&gt;

&lt;p&gt;Third, find ways to locate precursors and the failure itself. This part should be automated and attached to any CI pipelines for the future, "set it and forget it" is the best way. As this practice evolves, a solution develops incredible mass and manually executing every failure analysis unit test after every change will quickly become tedious and slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why?
&lt;/h2&gt;

&lt;p&gt;The pressure to follow this pattern is only going to grow in the future. The previous decade's reliability standards were hilariously low compared to the quality of technology and service today - just look at the standards people hold us to. Instead of fearing this trend, let's analyze it and find ways to improve. It'll give us a competitive edge in the future.&lt;/p&gt;

&lt;p&gt;As with Apollo 13, our greatest failures drive our greatest successes.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
