<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wc="https://wilhelm.codes/ns/feed/1.0"><channel><title>Wilhelm Codes · Automation</title><link>https://wilhelm.codes/tags/automation/</link><description>Coder and platform engineer. Slinger of nibbles, bits and bytes. Over 25 years of professional experience as a software engineer. Love making glowing rectangles go "beep-boop".</description><generator>Hugo</generator><language>en-US</language><managingEditor>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</managingEditor><webMaster>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</webMaster><lastBuildDate>Fri, 25 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://wilhelm.codes/tags/automation/index.xml" rel="self" type="application/rss+xml"/><item><title>I Didn't Need a DAG. I Needed Cron.</title><link>https://wilhelm.codes/blog/i-didnt-need-a-dag-i-needed-cron/</link><pubDate>Fri, 25 Sep 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/i-didnt-need-a-dag-i-needed-cron/</guid><category>homelab</category><category>bash</category><category>ansible</category><category>automation</category><wc:kind>post</wc:kind><description>Hey, nerds! Stop me if you&amp;rsquo;ve heard this one before.
Every homelab eventually hits the same wall. You have a pile of guests, a pile of small chores those guests need done on a schedule and absolutely no desire to SSH into each one by hand. Disk usage, uptime checks, arbitrary commands, pending updates, failed systemd units, the list goes on. This is all boring stuff that quietly rots away if nobody looks at it.
So I went shopping for an automation platform. I came back with nothing and then built one out of SSH, bash, cron and other bits and bobs already running in the homelab. It&amp;rsquo;s called NetRunner, because I&amp;rsquo;m physically incapable of naming anything in this network without a cyberpunk reference.</description><content:encoded><![CDATA[<p>Hey, nerds! Stop me if you&rsquo;ve heard this one before.</p>
<p>Every homelab eventually hits the same wall. You have a pile of guests, a pile of small chores those guests need done on a schedule and absolutely no desire to SSH into each one by hand. Disk usage, uptime checks, arbitrary commands, pending updates, failed systemd units, the list goes on. This is all boring stuff that quietly rots away if nobody looks at it.</p>
<p>So I went shopping for an automation platform. I came back with nothing and then built one out of SSH, bash, cron and other bits and bobs already running in the homelab. It&rsquo;s called <strong>NetRunner</strong>, because I&rsquo;m physically incapable of naming anything in this network without a cyberpunk reference.</p>
<p>Before we begin, I should take a moment to define what a <em>DAG</em> actually is. A <strong>D</strong>irected <strong>A</strong>cyclic <strong>G</strong>raph is a fancy name for a to-do list with dependencies. Each node in the graph is a task and each arrow says &ldquo;this one waits for that one&rdquo;. <em>Directed</em> means the arrows only point one way. <em>Acyclic</em> means you can never follow them back to where you started, so you can&rsquo;t accidentally shoot yourself in the foot with a loop of tasks all waiting on each other forever.</p>
<p>The order comes from the arrows, not the order you wrote things down in. Say you take a backup, then verify it and copy it offsite, then clean up. Verify and copy both wait on the backup but not on each other, so they run side by side. The clean-up waits for both.</p>
<h2 id="lets-go-shopping">
  <a class="heading-link" href="#lets-go-shopping">Let&rsquo;s go shopping!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I gave the usual suspects a fair go. <a href="https://kestra.io">Kestra</a>, <a href="https://www.windmill.dev">Windmill</a> and <a href="https://semaphoreui.com">Semaphore</a> are all genuinely good software built by people who clearly know what they&rsquo;re doing. They&rsquo;re also built for teams. Teams that need a web UI, a worker model, RBAC and the plugin ecosystems. I&rsquo;m just a weird little guy with a Proxmox cluster and a mild addiction to YAML.</p>
<p><a href="https://dagu.cloud">Dagu</a> came the closest. It&rsquo;s a single binary written in my <a href="https://go.dev/">favourite language</a>, has jobs defined as YAML and no database to babysit. I liked it a lot and I <em>really</em> tried to force myself to use it. Then I sat down to write my first workflow and realised that not a single one of my jobs was actually a graph. They were all &ldquo;run this thing on those boxes over yonder and read me the riot act if something failed&rdquo;.</p>
<p>Then, as I usually do in these moments, I took a step away from the desk and had a think. What is the bare minimum required to get this job done well?</p>
<p>The obvious answer was every piece of a central control node was already sitting in the network. Terraform already provisions the guests. Ansible already configures them. Forgejo already holds the code. Proxmox already knows every guest, its tags, its address AND comes packaged with an API. Apprise was also running in the homelab as a notification pipeline to a dedicated set of Slack channels. The only thing missing was a little bit of glue and a box to sit in the middle to press the button.</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>Boring almost always beats clever.</p>
  </blockquote>
  <figcaption>
    <cite>Evergreen.</cite>
  </figcaption>
</figure>

<h2 id="big-brain-time">
  <a class="heading-link" href="#big-brain-time">Big brain time!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>NetRunner is one unprivileged LXC container with one core and a gig of memory. It has no web UI, no database and, with the exception of SSH, no open ports. It doesn&rsquo;t need Python or Ansible to run because the whole runtime is Bash, OpenSSH, <code>curl</code>, <code>jq</code>, <code>flock</code> and <code>timeout</code>. If you&rsquo;ve administered a Linux box at any point in the last thirty years, you already know how every part of it works.</p>
<p>Here&rsquo;s an obligatory Mermaid chart with the basic workflow. We&rsquo;ll get to what <code>nr</code> is in a bit.</p>
<figure class="diagram"><svg id="d-6dc2aae292a3" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="flowchart" style="max-width: 1687.48px; background-color: transparent;" viewBox="0 0 1687.484375 334.5" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#d-6dc2aae292a3{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;fill:#32d74b;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#d-6dc2aae292a3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#d-6dc2aae292a3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#d-6dc2aae292a3 .error-icon{fill:#202225;}#d-6dc2aae292a3 .error-text{fill:#e3e3de;stroke:#e3e3de;}#d-6dc2aae292a3 .edge-thickness-normal{stroke-width:1px;}#d-6dc2aae292a3 .edge-thickness-thick{stroke-width:3.5px;}#d-6dc2aae292a3 .edge-pattern-solid{stroke-dasharray:0;}#d-6dc2aae292a3 .edge-thickness-invisible{stroke-width:0;fill:none;}#d-6dc2aae292a3 .edge-pattern-dashed{stroke-dasharray:3;}#d-6dc2aae292a3 .edge-pattern-dotted{stroke-dasharray:2;}#d-6dc2aae292a3 .marker{fill:#8b8b84;stroke:#8b8b84;}#d-6dc2aae292a3 .marker.cross{stroke:#8b8b84;}#d-6dc2aae292a3 svg{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;}#d-6dc2aae292a3 p{margin:0;}#d-6dc2aae292a3 .label{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;color:#32d74b;}#d-6dc2aae292a3 .cluster-label text{fill:#e3e3de;}#d-6dc2aae292a3 .cluster-label span{color:#e3e3de;}#d-6dc2aae292a3 .cluster-label span p{background-color:transparent;}#d-6dc2aae292a3 .label text,#d-6dc2aae292a3 span{fill:#32d74b;color:#32d74b;}#d-6dc2aae292a3 .node rect,#d-6dc2aae292a3 .node circle,#d-6dc2aae292a3 .node ellipse,#d-6dc2aae292a3 .node polygon,#d-6dc2aae292a3 .node path{fill:#1e1f22;stroke:#3c3f43;stroke-width:1px;}#d-6dc2aae292a3 .rough-node .label text,#d-6dc2aae292a3 .node .label text,#d-6dc2aae292a3 .image-shape .label,#d-6dc2aae292a3 .icon-shape .label{text-anchor:middle;}#d-6dc2aae292a3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#d-6dc2aae292a3 .rough-node .label,#d-6dc2aae292a3 .node .label,#d-6dc2aae292a3 .image-shape .label,#d-6dc2aae292a3 .icon-shape .label{text-align:center;}#d-6dc2aae292a3 .node.clickable{cursor:pointer;}#d-6dc2aae292a3 .root .anchor path{fill:#8b8b84!important;stroke-width:0;stroke:#8b8b84;}#d-6dc2aae292a3 .arrowheadPath{fill:#dfddda;}#d-6dc2aae292a3 .edgePaths .path{stroke:#8b8b84;stroke-width:1px;}#d-6dc2aae292a3 .flowchart-link{stroke:#8b8b84;fill:none;}#d-6dc2aae292a3 .edgeLabel{background-color:#1e1f22;text-align:center;}#d-6dc2aae292a3 .edgeLabel p{background-color:#1e1f22;}#d-6dc2aae292a3 .edgeLabel rect{opacity:0.5;background-color:#1e1f22;fill:#1e1f22;}#d-6dc2aae292a3 .labelBkg{background-color:rgba(30, 31, 34, 0.5);}#d-6dc2aae292a3 .cluster rect{fill:#202225;stroke:#2c2e31;stroke-width:1px;}#d-6dc2aae292a3 .cluster text{fill:#e3e3de;}#d-6dc2aae292a3 .cluster span{color:#e3e3de;}#d-6dc2aae292a3 .node .collapsed-indicator{fill:#2c2e31;stroke:none;opacity:0.6;}#d-6dc2aae292a3 .node .collapsed-separator{stroke:#2c2e31;stroke-width:0.75px;}#d-6dc2aae292a3 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:12px;background:#202225;border:1px solid hsl(216, 0%, 23.5294117647%);border-radius:2px;pointer-events:none;z-index:100;}#d-6dc2aae292a3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#32d74b;}#d-6dc2aae292a3 rect.text{fill:none;stroke-width:0;}#d-6dc2aae292a3 .icon-shape,#d-6dc2aae292a3 .image-shape{background-color:#1e1f22;text-align:center;}#d-6dc2aae292a3 .icon-shape p,#d-6dc2aae292a3 .image-shape p{background-color:#1e1f22;padding:2px;}#d-6dc2aae292a3 .icon-shape .label rect,#d-6dc2aae292a3 .image-shape .label rect{opacity:0.5;background-color:#1e1f22;fill:#1e1f22;}#d-6dc2aae292a3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#d-6dc2aae292a3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#d-6dc2aae292a3 .node .neo-node{stroke:#3c3f43;}#d-6dc2aae292a3 [data-look="neo"].node rect,#d-6dc2aae292a3 [data-look="neo"].cluster rect,#d-6dc2aae292a3 [data-look="neo"].node polygon{stroke:url(#d-6dc2aae292a3-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-6dc2aae292a3 [data-look="neo"].swimlane.cluster rect{filter:none;}#d-6dc2aae292a3 [data-look="neo"].node path{stroke:url(#d-6dc2aae292a3-gradient);stroke-width:1px;}#d-6dc2aae292a3 [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-6dc2aae292a3 [data-look="neo"].node .neo-line path{stroke:#3c3f43;filter:none;}#d-6dc2aae292a3 [data-look="neo"].node circle{stroke:url(#d-6dc2aae292a3-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-6dc2aae292a3 [data-look="neo"].node circle .state-start{fill:#000000;}#d-6dc2aae292a3 [data-look="neo"].icon-shape .icon{fill:url(#d-6dc2aae292a3-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-6dc2aae292a3 [data-look="neo"].icon-shape .icon-neo path{stroke:url(#d-6dc2aae292a3-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-6dc2aae292a3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><marker id="d-6dc2aae292a3_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-6dc2aae292a3_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-6dc2aae292a3_flowchart-v2-pointEnd-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto"><path d="M 0 0 L 11.5 7 L 0 14 z" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-6dc2aae292a3_flowchart-v2-pointStart-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto"><polygon points="0,7 11.5,14 11.5,0" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-6dc2aae292a3_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-6dc2aae292a3_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-6dc2aae292a3_flowchart-v2-circleEnd-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-6dc2aae292a3_flowchart-v2-circleStart-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-6dc2aae292a3_flowchart-v2-crossEnd" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="d-6dc2aae292a3_flowchart-v2-crossStart" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="d-6dc2aae292a3_flowchart-v2-crossEnd-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5;"/></marker><marker id="d-6dc2aae292a3_flowchart-v2-crossStart-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5; stroke-dasharray: 1, 0;"/></marker><g class="root"><g class="clusters"><g class="cluster" id="d-6dc2aae292a3-controller" data-look="classic"><rect style="" x="539.875" y="94" width="675.59375" height="146.5"/><g class="cluster-label" transform="translate(797.59375, 94)"><foreignObject width="160.15625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel"><p>NetRunner container</p></span></div></foreignObject></g></g></g><g class="edgePaths"><path d="M245.016,179.75L249.182,179.75C253.349,179.75,261.682,179.75,269.349,179.75C277.016,179.75,284.016,179.75,287.516,179.75L291.016,179.75" id="d-6dc2aae292a3-L_forge_ansible_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_forge_ansible_0" data-points="W3sieCI6MjQ1LjAxNTYyNSwieSI6MTc5Ljc1fSx7IngiOjI3MC4wMTU2MjUsInkiOjE3OS43NX0seyJ4IjoyOTUuMDE1NjI1LCJ5IjoxNzkuNzV9XQ==" data-look="classic" marker-end="url(#d-6dc2aae292a3_flowchart-v2-pointEnd)"/><path d="M489.875,179.75L494.042,179.75C498.208,179.75,506.542,179.75,514.875,179.75C523.208,179.75,531.542,179.75,539.208,179.75C546.875,179.75,553.875,179.75,557.375,179.75L560.875,179.75" id="d-6dc2aae292a3-L_ansible_cron_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_ansible_cron_0" data-points="W3sieCI6NDg5Ljg3NSwieSI6MTc5Ljc1fSx7IngiOjUxNC44NzUsInkiOjE3OS43NX0seyJ4Ijo1MzkuODc1LCJ5IjoxNzkuNzV9LHsieCI6NTY0Ljg3NSwieSI6MTc5Ljc1fV0=" data-look="classic" marker-end="url(#d-6dc2aae292a3_flowchart-v2-pointEnd)"/><path d="M658.594,179.75L662.76,179.75C666.927,179.75,675.26,179.75,683.629,179.75C691.997,179.75,700.401,179.75,704.603,179.75L708.805,179.75" id="d-6dc2aae292a3-L_cron_job_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_cron_job_0" data-points="W3sieCI6NjU4LjU5Mzc1LCJ5IjoxNzkuNzV9LHsieCI6NjgzLjU5Mzc1LCJ5IjoxNzkuNzV9LHsieCI6NzEyLjgwNDY4NzUsInkiOjE3OS43NX1d" data-look="classic" marker-end="url(#d-6dc2aae292a3_flowchart-v2-pointEnd)"/><path d="M857.102,172.083L868.995,170.819C880.888,169.555,904.674,167.028,933.414,164.74C962.153,162.453,995.846,160.406,1012.692,159.382L1029.539,158.358" id="d-6dc2aae292a3-L_job_exec_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_job_exec_0" data-points="W3sieCI6ODU3LjEwMTU2MjUsInkiOjE3Mi4wODMwNzQ3NDU0OTUxNH0seyJ4Ijo5MjguNDYwOTM3NSwieSI6MTY0LjV9LHsieCI6MTAzMy41MzEyNSwieSI6MTU4LjExNTc3ODk4MDM0NzQ3fV0=" data-look="classic" marker-end="url(#d-6dc2aae292a3_flowchart-v2-pointEnd)"/><path d="M821.275,59L839.139,71.542C857.004,84.083,892.732,109.167,927.447,123.782C962.161,138.397,995.861,142.543,1012.711,144.616L1029.561,146.69" id="d-6dc2aae292a3-L_pve_exec_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_pve_exec_0" data-points="W3sieCI6ODIxLjI3NTIwMTYxMjkwMzIsInkiOjU5fSx7IngiOjkyOC40NjA5Mzc1LCJ5IjoxMzQuMjV9LHsieCI6MTAzMy41MzEyNSwieSI6MTQ3LjE3ODA0NzU2NDc5NjM0fV0=" data-look="classic" marker-end="url(#d-6dc2aae292a3_flowchart-v2-pointEnd)"/><path d="M1152.547,154.5L1163.034,154.5C1173.521,154.5,1194.495,154.5,1223.197,154.5C1251.898,154.5,1288.328,154.5,1324.091,154.5C1359.854,154.5,1394.951,154.5,1412.499,154.5L1430.047,154.5" id="d-6dc2aae292a3-L_exec_guests_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_exec_guests_0" data-points="W3sieCI6MTE1Mi41NDY4NzUsInkiOjE1NC41fSx7IngiOjEyMTUuNDY4NzUsInkiOjE1NC41fSx7IngiOjEzMjQuNzU3ODEyNSwieSI6MTU0LjV9LHsieCI6MTQzNC4wNDY4NzUsInkiOjE1NC41fV0=" data-look="classic" marker-end="url(#d-6dc2aae292a3_flowchart-v2-pointEnd)"/><path d="M857.102,187.417L868.995,188.681C880.888,189.945,904.674,192.472,936.838,206.792C969.002,221.111,1009.543,247.223,1029.814,260.278L1050.084,273.334" id="d-6dc2aae292a3-L_job_slack_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_job_slack_0" data-points="W3sieCI6ODU3LjEwMTU2MjUsInkiOjE4Ny40MTY5MjUyNTQ1MDQ4Nn0seyJ4Ijo5MjguNDYwOTM3NSwieSI6MTk1fSx7IngiOjEwNTMuNDQ3MTU1MDcwNzU0NywieSI6Mjc1LjV9XQ==" data-look="classic" marker-end="url(#d-6dc2aae292a3_flowchart-v2-pointEnd)"/></g><g class="edgeLabels"><g class="edgeLabel"><g class="label" data-id="L_forge_ansible_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_ansible_cron_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_cron_job_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_job_exec_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_pve_exec_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1324.7578125, 154.5)"><g class="label" data-id="L_exec_guests_0" transform="translate(-84.2890625, -10.5)"><foreignObject width="168.578125" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>SSH, script on stdin</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(960.78874, 215.8214)"><g class="label" data-id="L_job_slack_0" transform="translate(-42.1484375, -10.5)"><foreignObject width="84.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>on failure</p></span></div></foreignObject></g></g></g><g class="nodes"><g class="node default" id="d-6dc2aae292a3-flowchart-forge-0" data-look="classic" transform="translate(126.5078125, 179.75)"><rect class="basic label-container" style="" x="-118.5078125" y="-25.5" width="237.015625" height="51"/><g class="label" style="" transform="translate(-88.5078125, -10.5)"><rect/><foreignObject width="177.015625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Forgejo: jobs as code</p></span></div></foreignObject></g></g><g class="node default" id="d-6dc2aae292a3-flowchart-ansible-1" data-look="classic" transform="translate(392.4453125, 179.75)"><rect class="basic label-container" style="" x="-97.4296875" y="-25.5" width="194.859375" height="51"/><g class="label" style="" transform="translate(-67.4296875, -10.5)"><rect/><foreignObject width="134.859375" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Ansible playbook</p></span></div></foreignObject></g></g><g class="node default" id="d-6dc2aae292a3-flowchart-cron-3" data-look="classic" transform="translate(611.734375, 179.75)"><rect class="basic label-container" style="" x="-46.859375" y="-25.5" width="93.71875" height="51"/><g class="label" style="" transform="translate(-16.859375, -10.5)"><rect/><foreignObject width="33.71875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>cron</p></span></div></foreignObject></g></g><g class="node default" id="d-6dc2aae292a3-flowchart-job-5" data-look="classic" transform="translate(784.953125, 179.75)"><rect class="basic label-container" style="" x="-72.1484375" y="-25.5" width="144.296875" height="51"/><g class="label" style="" transform="translate(-42.1484375, -10.5)"><rect/><foreignObject width="84.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>nr job run</p></span></div></foreignObject></g></g><g class="node default" id="d-6dc2aae292a3-flowchart-exec-7" data-look="classic" transform="translate(1093.0390625, 154.5)"><rect class="basic label-container" style="" x="-59.5078125" y="-25.5" width="119.015625" height="51"/><g class="label" style="" transform="translate(-29.5078125, -10.5)"><rect/><foreignObject width="59.015625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>nr exec</p></span></div></foreignObject></g></g><g class="node default" id="d-6dc2aae292a3-flowchart-pve-8" data-look="classic" transform="translate(784.953125, 33.5)"><rect class="basic label-container" style="" x="-76.359375" y="-25.5" width="152.71875" height="51"/><g class="label" style="" transform="translate(-46.359375, -10.5)"><rect/><foreignObject width="92.71875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Proxmox API</p></span></div></foreignObject></g></g><g class="node default" id="d-6dc2aae292a3-flowchart-guests-11" data-look="classic" transform="translate(1556.765625, 154.5)"><rect class="basic label-container" style="" x="-122.71875" y="-25.5" width="245.4375" height="51"/><g class="label" style="" transform="translate(-92.71875, -10.5)"><rect/><foreignObject width="185.4375" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>LXC containers and VMs</p></span></div></foreignObject></g></g><g class="node default" id="d-6dc2aae292a3-flowchart-slack-13" data-look="classic" transform="translate(1093.0390625, 301)"><rect class="basic label-container" style="" x="-97.4296875" y="-25.5" width="194.859375" height="51"/><g class="label" style="" transform="translate(-67.4296875, -10.5)"><rect/><foreignObject width="134.859375" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>apprise to Slack</p></span></div></foreignObject></g></g></g></g></g><defs><filter id="d-6dc2aae292a3-drop-shadow" height="130%" width="130%"><feDropShadow dx="4" dy="4" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"/></filter></defs><defs><filter id="d-6dc2aae292a3-drop-shadow-small" height="150%" width="150%"><feDropShadow dx="2" dy="2" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"/></filter></defs><linearGradient id="d-6dc2aae292a3-gradient" gradientUnits="objectBoundingBox" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#3c3f43" stop-opacity="1"/><stop offset="100%" stop-color="hsl(216, 0%, 23.5294117647%)" stop-opacity="1"/></linearGradient></svg></figure><p>The self-imposed design constraints were as follows:</p>
<ul>
<li>A job is a script that runs on the controller and <em>only</em> on the controller.</li>
<li>The controller sends commands to the guests over SSH, collects their output and makes its decisions locally.</li>
<li>The guests run no scheduler, no agent and no daemon. They keep no copy of any job.</li>
</ul>
<p>That last point matters because the homelab runs on an immutable infrastructure policy, so a guest should look the same after a job as it did before. NetRunner pipes each command or script into the guest on standard input and the guest runs it with <code>bash -s</code>. Nothing lands on the guest&rsquo;s disk, so nothing needs cleaning up afterwards.</p>
<p>Obviously, there could be exceptions to that rule, but if I aim to write something ephemeral to a guest&rsquo;s disk, it goes in <code>/tmp</code>. If I need to write something permanent, I codify it in Ansible or Terraform. It&rsquo;s more of a guideline than a hard rule.</p>
<h2 id="one-key-to-rule-them-all-with-great-care">
  <a class="heading-link" href="#one-key-to-rule-them-all-with-great-care">One key to rule them all&hellip; with great care.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Each guest gets a dedicated <code>netrunner</code> user. The LXC containers get it from a Proxmox hookscript on every start, which Terraform manages with the <a href="https://registry.terraform.io/providers/bpg/proxmox/latest">proxmox</a> provider. New VMs get it from cloud-init on first boot.</p>
<p>That user&rsquo;s <code>authorized_keys</code> holds exactly one line:</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">from=&#34;10.0.0.xx&#34;,no-agent-forwarding,no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAA... netrunner</span></span></code></pre></div>
</div>
<p>The key is an <code>ED25519</code> pair that Terraform generates. The private half lives on the controller and nowhere else. The <code>from=</code> option is doing the real work here. Steal that private key, copy it to your laptop and try it from anywhere other than the controller&rsquo;s static address and every guest will politely tell you to get fucked. The key is only useful from one IP on the network.</p>
<p>I&rsquo;m not going to pretend this is bulletproof, because it isn&rsquo;t. The <code>netrunner</code> user has passwordless sudo, since most of the chores I care about need root. That means the controller is effectively root on every guest. Own the container and you own the lab. It&rsquo;s the same trade every configuration management tool makes and I&rsquo;ve made it with my eyes open.</p>
<p>The container is small, hardened, exposes nothing but SSH and has one job.</p>
<h2 id="finding-the-guests">
  <a class="heading-link" href="#finding-the-guests">Finding the guests.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Hard-coding an inventory is how you end up running jobs against a guest you decommissioned weeks ago. So, to save me the trouble, every five minutes, <code>nr refresh</code> asks the Proxmox API for the running guests and caches the name, VM ID, node, type, tags and address of each one on disk.</p>
<p>The fun bit is that when you execute a job it never names a host. It selects guests with filters:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">nr <span class="nb">exec</span> -t music -- uptime
</span></span><span class="line"><span class="cl">nr <span class="nb">exec</span> -T lxc -P <span class="m">8</span> -- <span class="s1">&#39;apt list --upgradable 2&gt;/dev/null | tail -n +2 | wc -l&#39;</span>
</span></span><span class="line"><span class="cl">nr <span class="nb">exec</span> -a web -a dns -e <span class="nv">THRESHOLD</span><span class="o">=</span><span class="m">70</span> -s filesystems.sh</span></span></code></pre></div>
</div>
<p>Filter by Proxmox tag, by guest type or by a regex on the name. The tags come from Terraform. So when I tag a guest in its Terraform stack, I&rsquo;m also quietly subscribing it to every job that selects that tag. A new guest needs zero changes on the controller. The hookscript or cloud-init gives it the account, the next refresh picks it up and it starts getting nagged on schedule like everybody else. This is the type of set-and-forget project I love to build.</p>
<h2 id="the-nr-helper">
  <a class="heading-link" href="#the-nr-helper">The <code>nr</code> helper.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Everything on the controller goes through one command: <code>nr</code>. It&rsquo;s a bash CLI built with <a href="https://bashly.dev">Bashly</a>, which takes a YAML description of your commands, flags and help text and spits out a single, surprisingly well-designed bash script. If you&rsquo;ve been reading this blog for a while ( you haven&rsquo;t ) you&rsquo;d know that <a href="https://wilhelm.codes/blog/filtering-docker-containers-with-jq/">I&rsquo;ve</a> <a href="https://wilhelm.codes/blog/liberating-custom-slack-emojis/">been</a> <a href="https://wilhelm.codes/blog/why-cant-i-hold-all-these-slack-emojis/">using</a> this utility for <em>years</em>.</p>
<p>Yes, there&rsquo;s YAML in the bash. There&rsquo;s always YAML. I&rsquo;ve made my peace with it and so should you.</p>
<table>
	<thead>
			<tr>
					<th>Command</th>
					<th>What it does</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>nr exec</code></td>
					<td>Runs a command or a script on every matching guest and collects the results.</td>
			</tr>
			<tr>
					<td><code>nr guests</code></td>
					<td>Lists the cached guests, with the same filters.</td>
			</tr>
			<tr>
					<td><code>nr tags</code></td>
					<td>Lists the Proxmox tags and the guests behind each one.</td>
			</tr>
			<tr>
					<td><code>nr ssh</code></td>
					<td>Opens a session to one guest by name.</td>
			</tr>
			<tr>
					<td><code>nr notify</code></td>
					<td>Sends a notification through apprise.</td>
			</tr>
			<tr>
					<td><code>nr job run</code></td>
					<td>Runs one job, exactly as cron would.</td>
			</tr>
			<tr>
					<td><code>nr refresh</code></td>
					<td>Rebuilds the guest cache and prunes old runs.</td>
			</tr>
	</tbody>
</table>
<p><code>nr exec</code> is the workhorse. It fans out to four guests at a time by default and writes <code>&lt;guest&gt;.out</code>, <code>&lt;guest&gt;.err</code> and <code>&lt;guest&gt;.rc</code> for each one, plus a <code>summary.json</code> that ties them together. A job script is then just ordinary bash and <code>jq</code> over a directory of files. No SDK, no bespoke expression language and definitely no plugin API I have to learn and quickly forget.</p>
<p>Secrets are covered as best I can for this kind of setup. <code>nr exec --pass SECRET_SQUIRREL_TOKEN</code> writes the variable into the stdin stream as an <code>export</code> line. This means a passed secret never shows up on a command line on the guest or its shell history.</p>
<h3 id="its-also-just-a-nice-way-to-poke-at-the-lab">
  <a class="heading-link" href="#its-also-just-a-nice-way-to-poke-at-the-lab">It&rsquo;s also just a nice way to poke at the lab.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>The scheduled jobs are the reason NetRunner exists, but the thing I didn&rsquo;t expect is how often I now reach for <code>nr</code> from a shell with no job in sight. The controller already knows every guest, so it&rsquo;s become the place I go when I have a question about the fleet and can&rsquo;t be bothered remembering an IP.</p>
<p>Some things I&rsquo;ve typed within minutes of finally getting this running, more or less verbatim:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Which guests are running and what are they tagged with?</span>
</span></span><span class="line"><span class="cl">nr tags
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Kernel versions across every LXC processing eight at a time.</span>
</span></span><span class="line"><span class="cl">nr <span class="nb">exec</span> -T lxc -P <span class="m">8</span> -- uname -r
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Which VMs have a reboot pending?</span>
</span></span><span class="line"><span class="cl">nr <span class="nb">exec</span> -T qemu -- <span class="s1">&#39;test -f /var/run/reboot-required &amp;&amp; echo yes || echo no&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Is docker happy on the media guests? Skip the music server.</span>
</span></span><span class="line"><span class="cl">nr <span class="nb">exec</span> -t media -x navidrome -- systemctl is-active docker
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Just get me a shell on that thing.</span>
</span></span><span class="line"><span class="cl">nr ssh koito</span></span></code></pre></div>
</div>
<p>Obviously, I can easily eyeball a few of these from the Proxmox web UI, but we&rsquo;re not in a browser are we?</p>
<p>Every output line is prefixed with the guest name and each one is displayed as a single block, so a fleet-wide <code>df -h</code> is readable instead of a wall of text. At the end you get a list of anything that failed and the path to the results directory in case you want to go digging.</p>
<p>All results are stored locally on the controller as plain&rsquo;ole files. This means composing stuff with the rest of the toolbox is fairly trivial. <code>--json</code> writes the <code>summary.json</code> to standard output and <code>--quiet</code> suppresses the guest output when you only care about the result:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Name every VM guest where the command failed.</span>
</span></span><span class="line"><span class="cl">nr <span class="nb">exec</span> -j -T qemu -- hostname <span class="p">|</span> jq -r <span class="s1">&#39;.[] | select(.rc != 0) | .name&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Collect every os-release into a known directory, then read one.</span>
</span></span><span class="line"><span class="cl">nr <span class="nb">exec</span> -q -o /tmp/os -- <span class="s1">&#39;cat /etc/os-release&#39;</span>
</span></span><span class="line"><span class="cl">cat /tmp/os/koito.out</span></span></code></pre></div>
</div>
<p>And because <code>nr notify</code> reads standard input, a one-off report is a pipe away:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">nr <span class="nb">exec</span> -t music -- uptime <span class="p">|</span> nr notify -T <span class="s2">&#34;Uptime of the music guests&#34;</span></span></span></code></pre></div>
</div>
<p>Here it is in the <code>#netrunner</code> channel moments later:
<figure class="img-single">
    <button type="button" class="zoom" aria-label="Zoom image: A preview of the resulting Slack notification.">
      <img
  src="https://wilhelm.codes/blog/i-didnt-need-a-dag-i-needed-cron/uptime_hu_3289f3b6ec50cbb3.webp"
  srcset="/blog/i-didnt-need-a-dag-i-needed-cron/uptime_hu_3289f3b6ec50cbb3.webp 736w, /blog/i-didnt-need-a-dag-i-needed-cron/uptime_hu_7e07e2eaf4b9ba8e.webp 1104w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="256"
  alt="A preview of the resulting Slack notification."
  loading="lazy"
  decoding="async"
  data-full="/blog/i-didnt-need-a-dag-i-needed-cron/uptime_hu_fd24bf2df1ed2552.webp"
  data-credit="A preview of the resulting Slack notification."
  
/>

    </button>
    
      <figcaption>
        
          A preview of the resulting Slack notification.
        
      </figcaption>
    
  </figure></p>
<p>Those ad-hoc runs land in <code>/var/lib/netrunner/runs/adhoc/</code> and get pruned after a fortnight, same as everything else. This is also exactly how I test a new job before it gets a schedule. Run the commands by hand until they do the right thing, then move them into a script and add the YAML. There&rsquo;s no &ldquo;run locally&rdquo; mode to emulate because the shell <em>is</em> the local mode.</p>
<h2 id="jobs-are-code-obviously">
  <a class="heading-link" href="#jobs-are-code-obviously">Jobs are code. Obviously.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Every job lives in the forge next to everything else. A job is two things: a script in <code>jobs/bin/</code> and an entry in <code>jobs/jobs.yaml</code>.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">YAML</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">disk-usage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">description</span><span class="p">:</span><span class="w"> </span><span class="l">Fail when a filesystem on a guest is at or above 85%.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">schedule</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0 * * * *&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">script</span><span class="p">:</span><span class="w"> </span><span class="l">disk-usage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="m">600</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">exclude</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">truenas</span></span></span></code></pre></div>
</div>
<div class="callout callout-info">
  You might notice I&rsquo;m excluding TrueNAS here. It&rsquo;s not a standard guest, but a full-blown dedicated distribution that manages itself well enough. No need for my grubby scripts to taint all my legally-acquired ISOs.
</div>

<p>The job script for something like &ldquo;tell me which guests have failed systemd units&rdquo; is about twenty lines:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">results</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">NR_RUN_DIR</span><span class="si">}</span><span class="s2">/units&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># nr exec exits 1 when a guest fails. The script reads each result itself.</span>
</span></span><span class="line"><span class="cl">nr <span class="nb">exec</span> -q -P <span class="m">8</span> -o <span class="s2">&#34;</span><span class="si">${</span><span class="nv">results</span><span class="si">}</span><span class="s2">&#34;</span> -- <span class="s1">&#39;systemctl --failed --no-legend --plain | wc -l&#39;</span> <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">status</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="nv">IFS</span><span class="o">=</span><span class="s1">$&#39;\t&#39;</span> <span class="nb">read</span> -r name rc out<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">rc</span><span class="si">}</span><span class="s2">&#34;</span> !<span class="o">=</span> <span class="s2">&#34;0&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;UNREACHABLE </span><span class="si">${</span><span class="nv">name</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">status</span><span class="o">=</span><span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">elif</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="k">$(</span>cat <span class="s2">&#34;</span><span class="si">${</span><span class="nv">out</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span> -gt <span class="m">0</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;FAILED UNITS </span><span class="si">${</span><span class="nv">name</span><span class="si">}</span><span class="s2">: </span><span class="k">$(</span>cat <span class="s2">&#34;</span><span class="si">${</span><span class="nv">out</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">status</span><span class="o">=</span><span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span> &lt; &lt;<span class="o">(</span>jq -r <span class="s1">&#39;.[] | [.name, (.rc // &#34;skip&#34; | tostring), .out] | @tsv&#39;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">results</span><span class="si">}</span><span class="s2">/summary.json&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">exit</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">status</span><span class="si">}</span><span class="s2">&#34;</span></span></span></code></pre></div>
</div>
<p>Don&rsquo;t judge me, all Bash scripting looks ugly. Anyway, as is standard in Linux, you print whatever you want and exit non-zero when something&rsquo;s wrong. That&rsquo;s effectively the API in its entirety.</p>
<h2 id="ansible-does-the-boring-part">
  <a class="heading-link" href="#ansible-does-the-boring-part">Ansible does the boring part.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I never touch the controller by hand. The playbook builds <code>nr</code> with Bashly, checks the result with <code>bash -n</code> before it swaps the old binary out and then reads <code>jobs.yaml</code>. It validates every job before it changes anything through an assertion and refuses to continue if any aspect of a job fails:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">YAML</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nn">...</span><span class="w"> </span><span class="l">more yaml up here ...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">tasks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">assert each job is valid</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ansible.builtin.assert</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">that</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="l">item.name is defined and item.name is match(&#39;^[a-z0-9][a-z0-9-]*$&#39;)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="l">item.schedule is defined and (item.schedule | split | length) == 5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="l">item.script is defined</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="l">(netrunner_jobs_src ~ &#39;/bin/&#39; ~ item.script) is file</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="l">(item.env | default({}) | dict2items | map(attribute=&#39;key&#39;) | reject(&#39;match&#39;, &#39;^[A-Za-z_][A-Za-z0-9_]*$&#39;) | list | length) == 0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="l">item.notify | default(&#39;failure&#39;) in [&#39;failure&#39;, &#39;always&#39;, &#39;never&#39;]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="l">item.exclude | default([]) is sequence and item.exclude | default([]) is not string</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="l">(item.exclude | default([]) | reject(&#39;match&#39;, &#39;^[A-Za-z0-9][A-Za-z0-9.-]*$&#39;) | list | length) == 0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">fail_msg</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Job {{ item.name | default(&#39;(no name)&#39;) }} in jobs/jobs.yaml is not valid. See the field list at the top of that file.&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">quiet</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">loop</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;{{ jobs }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">loop_control</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">label</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;{{ item.name | default(&#39;(no name)&#39;) }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">... more yaml down here ...</span></span></span></code></pre></div>
</div>
<p>You&rsquo;ll notice <code>{{ jobs }}</code> near the bottom. That&rsquo;s a variable defined by yet another YAML file containing a list of jobs formatted like the example at the start of prior section.</p>
<p>If the jobs pass, it writes two files on the controller. The first is <code>jobs.json</code>, with secrets resolved from my password manager at deploy time. The second is <code>/etc/cron.d/netrunner</code>, with one line per enabled job. Each line runs <code>nr job run &lt;name&gt;</code> as the <code>netrunner</code> user.</p>
<p>It&rsquo;s a stupid-simple template using that same <code>{{ jobs }}</code> variable passed through. This is what we use to create the resulting cron table:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Jinja</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-jinja" data-lang="jinja"><span class="line"><span class="cl"><span class="x">SHELL=/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="x">PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
</span></span></span><span class="line"><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="cl"><span class="x"># Keep the guest cache fresh for the helpers and the jobs.
</span></span></span><span class="line"><span class="cl"><span class="x">*/5 * * * * </span><span class="cp">{{</span> <span class="nv">netrunner_user</span> <span class="cp">}}</span><span class="x"> systemd-cat -t netrunner-refresh /usr/local/bin/nr refresh --quiet
</span></span></span><span class="line"><span class="cl"><span class="cp">{%</span> <span class="k">for</span> <span class="nv">job</span> <span class="k">in</span> <span class="nv">jobs</span> <span class="k">if</span> <span class="nv">job.enabled</span> <span class="o">|</span> <span class="nf">default</span><span class="o">(</span><span class="kp">true</span><span class="o">)</span> <span class="o">|</span> <span class="nf">bool</span> <span class="cp">%}</span><span class="x">
</span></span></span><span class="line"><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="cl"><span class="x"># </span><span class="cp">{{</span> <span class="nv">job.name</span> <span class="cp">}}</span><span class="x">: </span><span class="cp">{{</span> <span class="nv">job.description</span> <span class="o">|</span> <span class="nf">default</span><span class="o">(</span><span class="s1">&#39;no description&#39;</span><span class="o">)</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="cl"><span class="cp">{{</span> <span class="nv">job.schedule</span> <span class="cp">}}</span><span class="x"> </span><span class="cp">{{</span> <span class="nv">netrunner_user</span> <span class="cp">}}</span><span class="x"> systemd-cat -t netrunner-</span><span class="cp">{{</span> <span class="nv">job.name</span> <span class="cp">}}</span><span class="x"> /usr/local/bin/nr job run </span><span class="cp">{{</span> <span class="nv">job.name</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="cl"><span class="cp">{%</span> <span class="k">endfor</span> <span class="cp">%}</span></span></span></code></pre></div>
</div>
<p>So the entire workflow for a new automation is:</p>
<ol>
<li>Write a script.</li>
<li>Add a few lines of YAML.</li>
<li>Push to the forge.</li>
<li>Run the playbook.</li>
</ol>
<p>Want to pause a job? Set <code>enabled: false</code> and run the playbook. Want to drop a job? Delete it and run the playbook, which also removes the script from the container. If I want to see who fat-fingered the wrong thing, I have the git log to stare at. <code>git blame</code> tells me exactly who broke the thing aaaaand, oh wait, that&rsquo;s right, it was me.</p>
<h2 id="cron-but-with-a-seatbelt">
  <a class="heading-link" href="#cron-but-with-a-seatbelt">Cron, but with a seatbelt.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Raw cron has two classic ways to ruin your weekend. Jobs pile up on top of each other when one runs long and jobs hang forever when a guest goes quiet. <code>nr job run</code> handles both with tools that ship with every Linux box on the planet.</p>
<p><code>flock</code> makes sure a second run of a job won&rsquo;t start while the first one is still going. <code>timeout</code> kills a run once it goes over its budget, which defaults to an hour. Each run gets its own directory under <code>/var/lib/netrunner/runs/</code> with its log, its exit code and the raw results from every guest. Output goes to the journal tagged <code>netrunner-&lt;name&gt;</code>, so reading a job&rsquo;s history is just <code>journalctl</code>:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">journalctl -t netrunner-disk-usage --since today</span></span></code></pre></div>
</div>
<p>Again, all tools that have existed forever, with the addition of things that already do real work in the homelab.</p>
<p>When a job fails, <code>nr</code> grabs the last forty lines of output and posts them to apprise, which drops them into a <code>#netrunner</code> Slack channel. That&rsquo;s the entirety of the alerting stack and it&rsquo;s currently more than adequate for my needs.</p>
<h2 id="there-actually-are-some-ugly-bits-to-this">
  <a class="heading-link" href="#there-actually-are-some-ugly-bits-to-this">There actually are some ugly bits to this.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Here&rsquo;s what NetRunner doesn&rsquo;t do, in the interest of not pretending otherwise and I&rsquo;m almost certain it&rsquo;s the part that most everyone reading this has been looking forward to:</p>
<ul>
<li><strong>There&rsquo;s no failover.</strong> A single LXC means a single controller. If it dies, the jobs stop and the guests carry on regardless. The container has no backup either, because it doesn&rsquo;t need one. The jobs live in the forge, the key lives in Terraform state and one <code>terraform apply</code> plus one playbook run rebuilds the whole thing. If it goes down for whatever reason, I have a reachability check from <a href="https://uptime.kuma.pet/">Uptime Kuma</a> that&rsquo;ll alert me in Slack as well.</li>
<li><strong>It doesn&rsquo;t verify host keys.</strong> Guests get recreated and DHCP addresses tend to wander, so strict host key checking would mostly produce noise. The cost is that a hostile box on the LAN that claims a guest&rsquo;s address could receive a job, secrets and all. It doesn&rsquo;t even need the key. The controller is the one doing the authenticating, not the guest. I accept that on my own network. I wouldn&rsquo;t anywhere else. I sure as shit wouldn&rsquo;t at work.</li>
<li><strong>The cache can be five minutes stale.</strong> If an address moves between guests inside that window, a job can land on the wrong box. It&rsquo;s a small window and it&rsquo;s documented. I&rsquo;d get an alert for this as well, so no biggie. Well, it could potentially be a very BIG biggie depending on what I&rsquo;m running&hellip;</li>
<li><strong>There&rsquo;s no UI.</strong> I consider this a feature, but I appreciate not everyone will.</li>
</ul>
<p>None of these would survive a production review at work. That&rsquo;s fine. This isn&rsquo;t work, and every one of these trade-offs is written down in the homelab&rsquo;s wiki next to the reason I made it.</p>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The platforms I looked at are not bad software. They solve problems that I simply don&rsquo;t have: many users, complex dependency graphs, audit requirements and a web UI for people who don&rsquo;t live in a terminal. I have a handful of guests and a list of chores.</p>
<p>NetRunner is some well structured bash, one SSH key, one cron file and a playbook. There&rsquo;s nothing to upgrade except bash and OpenSSH, and those were never going anywhere. Every part of it is something I already knew how to debug at two in the morning, which turns out to be the only feature that really matters.</p>
<p>I guess the whole point of this post is you don&rsquo;t always need to resort to using a super shiny off-the-shelf solution when you&rsquo;ll only use 5% of its functionality. Especially, when you already have everything you need, but the glue. The rule of <a href="https://martinfowler.com/bliki/Yagni.html">YAGNI</a> ( <strong>Y</strong>ou <strong>A</strong>ren&rsquo;t <strong>G</strong>onna <strong>N</strong>eed <strong>I</strong>t ) applies and prevails. At any rate, this was a fun weekend project!</p>
<p>The <code>nr</code> code is unique to my homelab and I don&rsquo;t plan on publishing it as it literally will not run anywhere else, but I&rsquo;m flexible. If there&rsquo;s genuine interest, I&rsquo;ll post the source as well as the playbook so you can tinker with it youself.</p>
<p>Wake up, samurai. We have guests to patch.</p>]]></content:encoded></item></channel></rss>