<?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:dc="http://purl.org/dc/elements/1.1/"><channel><title>Focused Systems</title><link>https://blog.focused.systems/</link><description>Exploring modern cloud workflows. DevOps, APIs, Git, serverless architectures, pipelines, and software integrations, alongside personal tech solutions.</description><language>en</language><generator>Hugo</generator><image><url>https://blog.focused.systems/images/social.png</url><title>Focused Systems</title><link>https://blog.focused.systems/</link></image><lastBuildDate>Tue, 14 Jul 2026 09:00:00 +0000</lastBuildDate><atom:link href="https://blog.focused.systems/index.xml" rel="self" type="application/rss+xml"/><item><title>Running asbmutil in Ubuntu CI to Drive Apple School &amp; Business Manager</title><link>https://blog.focused.systems/asbmutil-linux-ci/</link><guid isPermaLink="true">https://blog.focused.systems/asbmutil-linux-ci/</guid><pubDate>Tue, 14 Jul 2026 09:00:00 +0000</pubDate><dc:creator>Rod Christiansen</dc:creator><category>devops</category><category>macadmin</category><category>swift</category><category>ci/cd</category><category>intune</category><category>apple</category><description>asbmutil is a Swift CLI for macOS — but the release build is a static Linux binary, so I run it headless in an Ubuntu CI job to talk to Apple School and Business Manager with no Mac in the loop. Here's the whole pattern: get the binary, load credentials without a Keychain, script the commands — with our MicroMDM → Intune migration as the worked example.</description><content:encoded><![CDATA[<p>Here&rsquo;s something cool I&rsquo;ve been using recently: talking to Apple School and Business Manager (ASBM) from a headless Linux job. No Mac in the loop, no Keychain, no clicking through the portal — just API calls from a cloud function that pull the fleet&rsquo;s device-to-server assignments as JSON, flip them, and whatever else you need.</p>
<p>The tool is <a href="https://github.com/rodchristiansen/asbmutil"><code>asbmutil</code></a>, a CLI I wrote for the <a href="https://developer.apple.com/documentation/apple-school-and-business-manager-api">Apple School and Business Manager API</a>. It&rsquo;s a Swift program — SwiftUI app, credentials in the macOS Keychain, signed and notarized through Apple&rsquo;s toolchain. You&rsquo;d assume all that pins it to a Mac. Nope.</p>
<p>Every release ships a <strong>static Linux binary</strong> as a first-class sibling to the macOS build. The Mac binary and its SwiftUI app matter just as much — that&rsquo;s how you use <code>asbmutil</code> locally. The Linux sibling exists for the other half of the job: headless automation — an Azure Function, an AWS Lambda, a CI job, anything that runs Linux with no one watching. This post is about that Linux side — how I run it in Ubuntu, what the moving parts are, and the things it&rsquo;s good for.</p>
<h2 id="copy-paste-able-ci-samples">Copy-paste-able CI samples</h2>
<p>The whole pattern is in the repo as runnable samples, so you can lift it straight into your own CI: <strong><a href="https://github.com/rodchristiansen/asbmutil/tree/main/examples/ci"><code>examples/ci/</code></a></strong>.</p>
<ul>
<li><strong><a href="https://github.com/rodchristiansen/asbmutil/blob/main/examples/ci/setup-asbmutil.sh"><code>setup-asbmutil.sh</code></a></strong> — downloads the latest Linux release and loads credentials from three env vars (steps 1 and 2 below).</li>
<li><strong><a href="https://github.com/rodchristiansen/asbmutil/blob/main/examples/ci/github-actions/asbmutil-report.yml"><code>asbmutil-report.yml</code></a></strong> — a read-only GitHub Actions workflow that pulls the ASBM picture on a daily schedule and uploads it as an artifact: a standing &ldquo;is ASBM drifting from my inventory?&rdquo; heartbeat.</li>
<li><strong><a href="https://github.com/rodchristiansen/asbmutil/blob/main/examples/ci/github-actions/asbmutil-assign.yml"><code>asbmutil-assign.yml</code></a></strong> — reassigns a set of serials to a target MDM server: manual-trigger, dry-run by default, showing the current assignment before it changes anything.</li>
</ul>
<p>We run the real thing in Azure DevOps Pipelines, but the steps are identical on any runner — the GitHub Actions files are just the most copy-pasteable form. The rest of this post is the walkthrough behind them.</p>
<h2 id="how-the-linux-build-works">How the Linux build works</h2>
<p>Swift cross-compiles to Linux cleanly, and the whole build comes down to one flag:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">swift build -c release --product asbmutil --static-swift-stdlib
</span></span></code></pre></div><p><code>--static-swift-stdlib</code> folds the Swift runtime into the binary, so the result is a single self-contained <code>asbmutil</code> that runs on a bare Ubuntu image with no toolchain installed. The release workflow builds one on every tag and attaches it next to the signed macOS build as <code>asbmutil-&lt;version&gt;-linux-amd64.tar.gz</code>.</p>
<p>The only genuinely Mac-specific thing in the whole program is credential storage, and that&rsquo;s behind a compile-time fork. When the <code>Security</code> framework isn&rsquo;t importable — i.e. you&rsquo;re not on Apple&rsquo;s platforms — it swaps the Keychain for a file store:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="cp">#if</span> <span class="o">!</span><span class="cp">canImport</span><span class="p">(</span><span class="cp">Security</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="c1">// File-based credential storage for Linux (no macOS Keychain available).</span>
</span></span><span class="line"><span class="cl"><span class="c1">// Credentials are stored as JSON files in ~/.config/asbmutil/ with 0600 permissions.</span>
</span></span></code></pre></div><p>That fork is the only Linux-specific line in the whole thing. Everything else is plain Swift that compiles as-is, so from here it&rsquo;s just three steps.</p>
<h2 id="step-1--get-the-binary-into-ci">Step 1 — get the binary into CI</h2>
<p>The setup script asks the GitHub API for the latest release, grabs the asset whose name contains <code>linux</code>, and unpacks it onto <code>PATH</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">asset_url</span><span class="o">=</span><span class="k">$(</span>curl -fsSL https://api.github.com/repos/rodchristiansen/asbmutil/releases/latest <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="p">|</span> python3 -c <span class="s2">&#34;import sys,json; a=json.load(sys.stdin)[&#39;assets&#39;]; print(next(x[&#39;browser_download_url&#39;] for x in a if &#39;linux&#39; in x[&#39;name&#39;]))&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">curl -fsSL -o asbmutil.tar.gz <span class="s2">&#34;</span><span class="nv">$asset_url</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">tar xzf asbmutil.tar.gz -C bin
</span></span><span class="line"><span class="cl">chmod +x bin/asbmutil
</span></span></code></pre></div><p>That&rsquo;s it — no <code>apt install</code>, no Swift on the runner. On our actual deploy pipeline it rides into the function zip alongside <code>mdmctl</code> (for the MicroMDM side), two little binaries in the same <code>bin/</code> folder.</p>
<h2 id="step-2--credentials-without-a-keychain">Step 2 — credentials without a Keychain</h2>
<p>This is the step that bites you, and it bit me for an afternoon. On macOS you&rsquo;d run <code>asbmutil config set</code> once and forget it; the secrets go in the Keychain. On Linux there&rsquo;s no Keychain, so the file store expects its JSON in <code>~/.config/asbmutil/</code>. Fine — except the store has to <em>find</em> that directory, and on Linux with musl, <code>FileManager.homeDirectoryForCurrentUser</code> calls <code>getpwuid()</code>, which returns the <em>passwd</em> home (for a service account that&rsquo;s often <code>/nonexistent</code>) and flatly ignores <code>$HOME</code>. So the store reads <code>$HOME</code> explicitly first:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">homePath</span> <span class="p">=</span> <span class="n">ProcessInfo</span><span class="p">.</span><span class="n">processInfo</span><span class="p">.</span><span class="n">environment</span><span class="p">[</span><span class="s">&#34;HOME&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">??</span> <span class="n">FileManager</span><span class="p">.</span><span class="k">default</span><span class="p">.</span><span class="n">homeDirectoryForCurrentUser</span><span class="p">.</span><span class="n">path</span>
</span></span></code></pre></div><p>The practical upshot: make sure your CI job has <code>HOME</code> set (GitHub and Azure runners do by default), and then the cleanest way to load credentials is to let the tool write its own store. Keep the ASBM client id, key id, and PEM private key as CI secrets, write the PEM to a temp file, and run <code>config set</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$ASBM_PRIVATE_KEY_PEM</span><span class="s2">&#34;</span> &gt; key.pem
</span></span><span class="line"><span class="cl">asbmutil config <span class="nb">set</span> --client-id <span class="s2">&#34;</span><span class="nv">$ASBM_CLIENT_ID</span><span class="s2">&#34;</span> --key-id <span class="s2">&#34;</span><span class="nv">$ASBM_KEY_ID</span><span class="s2">&#34;</span> --pem-path key.pem
</span></span><span class="line"><span class="cl">rm key.pem
</span></span></code></pre></div><p>No secrets in the repo, nothing persisted on the runner beyond the job. (In our production path — a Python Azure Function — I skip <code>config set</code> and lay the store&rsquo;s JSON files down directly from app settings, <code>0600</code>, because the function is already handling secrets from Key Vault. Both roads end at the same <code>~/.config/asbmutil/</code>.)</p>
<h2 id="step-3--script-the-commands">Step 3 — script the commands</h2>
<p>What makes <code>asbmutil</code> pleasant to drive from any script is a boring discipline: <strong>clean JSON on stdout, every diagnostic on stderr.</strong> Piping into <code>jq</code> or <code>json.loads</code> never breaks on a progress line. A handful of read-only commands cover most of what you&rsquo;d want to watch — every MDM server and its id, the devices grouped by server, which server a given set of serials is on, and full per-device attributes (including AppleCare and assigned MDM):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">asbmutil list-mdm-servers
</span></span><span class="line"><span class="cl">asbmutil list-devices-servers --all
</span></span><span class="line"><span class="cl">asbmutil list-devices-servers --serials A,B,C
</span></span><span class="line"><span class="cl">asbmutil get-devices-info --serials A,B,C
</span></span></code></pre></div><p><code>list-devices-servers</code> is the one worth calling out. The naive &ldquo;which MDM is this Mac on?&rdquo; is one API call per device; for a few thousand Macs that&rsquo;s a rate-limit headache. This inverts it — it lists each server&rsquo;s devices (four or five calls total, regardless of fleet size) and intersects against the serials you asked about.</p>
<h2 id="the-worked-example-our-mdm-migration-reconciler">The worked example: our MDM migration reconciler</h2>
<p>Stack those three steps together and you can automate real work. We&rsquo;re in the middle of an MDM migration right now, and it&rsquo;s a good example.</p>
<p>I wrote a reconciler to keep the migration on track — a blob-triggered Azure Function. It reads the desired server (old vs. new) for each serial from an inventory CSV (we use Snipe), asks ASBM what&rsquo;s true right now, diffs the two, and fixes the gaps. That&rsquo;s four <code>asbmutil</code> verbs:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">([</span><span class="n">ASBMUTIL</span><span class="p">,</span> <span class="s2">&#34;list-devices-servers&#34;</span><span class="p">,</span> <span class="s2">&#34;--serials&#34;</span><span class="p">,</span> <span class="n">serials</span><span class="p">],</span> <span class="o">...</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">([</span><span class="n">ASBMUTIL</span><span class="p">,</span> <span class="s2">&#34;unassign&#34;</span><span class="p">,</span> <span class="s2">&#34;--serials&#34;</span><span class="p">,</span> <span class="n">movers</span><span class="p">,</span> <span class="s2">&#34;--mdm&#34;</span><span class="p">,</span> <span class="n">old_server</span><span class="p">],</span> <span class="o">...</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">([</span><span class="n">ASBMUTIL</span><span class="p">,</span> <span class="s2">&#34;assign&#34;</span><span class="p">,</span>   <span class="s2">&#34;--serials&#34;</span><span class="p">,</span> <span class="n">movers</span><span class="p">,</span> <span class="s2">&#34;--mdm&#34;</span><span class="p">,</span> <span class="n">new_server</span><span class="p">],</span> <span class="o">...</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">([</span><span class="n">ASBMUTIL</span><span class="p">,</span> <span class="s2">&#34;batch-status&#34;</span><span class="p">,</span> <span class="n">activity_id</span><span class="p">,</span> <span class="s2">&#34;--poll&#34;</span><span class="p">,</span> <span class="s2">&#34;--interval&#34;</span><span class="p">,</span> <span class="s2">&#34;10&#34;</span><span class="p">,</span> <span class="o">...</span><span class="p">],</span> <span class="o">...</span><span class="p">)</span>
</span></span></code></pre></div><p><strong>We change a device&rsquo;s MDM server in our inventory, and the function makes Apple School Manager match.</strong></p>
<p>Read current state, then — for the devices the diff says should move — unassign them from the old server and assign them to the new one, grouped by server so each side is one batch. The server names (<code>old_server</code>, <code>new_server</code>) come straight out of the diff, not hardcoded. Then poll the async batch to completion — one process, one OAuth token, instead of reimplementing the poll loop in Python.</p>
<p>Two things make this safe to run unattended. First, the diff is a four-way comparison — previous ASBM state vs current, previous inventory vs current — so it can tell <em>who moved a device</em>. Inventory changed and ASBM didn&rsquo;t? Migrate. ASBM changed on its own (an admin did something in the portal)? Leave it, report drift. Both changed and disagree? Conflict — don&rsquo;t touch it, flag a human. An automated reconciler that blindly enforces one side will happily undo a deliberate manual change every five minutes; the four-way diff is what stops that.</p>
<p>Second, every mutating call is wrapped so a <code>DRY_RUN</code> env flag turns it into a log line — so a full migration run can be rehearsed against real ASBM state without touching a single device.</p>
<h3 id="dont-take-the-apis-word-for-it">Don&rsquo;t take the API&rsquo;s word for it</h3>
<p>Two things about Apple&rsquo;s API will burn an unattended job if you let them.</p>
<p>First, the activity endpoint accepts <em>any</em> serial you send it. Pass one that isn&rsquo;t in your ASBM account — a reseller filed a shipment notice but never registered the device, or someone fat-fingered it — and the activity still comes back <code>IN_PROGRESS</code>, then <code>COMPLETED</code>, having done nothing. So <code>assign</code> and <code>unassign</code> pre-flight every serial against <code>GET /v1/orgDevices/{id}</code> and drop the 404s before submitting (pass <code>--skip-verify</code> to opt out).</p>
<p>Second, <code>COMPLETED</code> doesn&rsquo;t mean the device landed on the server you asked for. <code>--confirm</code> closes that gap: after the batch finishes it re-queries each device&rsquo;s assigned server, checks it against the intended end state, and exits non-zero if anything&rsquo;s off — so the function can treat a partial run as the failure it is.</p>
<p>And if you want proof from Apple&rsquo;s own records rather than your logs, the 2.0 API exposes an org audit log that <code>audit-events</code> queries by time range and type — pulling <code>DEVICE_ASSIGNED_TO_SERVER</code> for the migration window gives you an independent record straight from Apple&rsquo;s side. The catch: this one is <strong>Business Manager only</strong>. School Manager&rsquo;s API is still at 1.5 and doesn&rsquo;t expose the endpoint, so as a university on ASM I can&rsquo;t lean on it yet — the tool fails fast with a clear message instead of a raw 404, and on the School side I fall back to <code>--confirm</code> and a re-query of assigned servers. On Business Manager it looks like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">asbmutil audit-events --start 2026-07-01T00:00:00Z --end 2026-07-14T23:59:59Z --type DEVICE_ASSIGNED_TO_SERVER
</span></span></code></pre></div><h2 id="what-else-its-good-for">What else it&rsquo;s good for</h2>
<p>A clean, scriptable window into ASBM is useful for a lot that has nothing to do with switching MDMs:</p>
<ul>
<li><strong>A nightly ASBM ↔ inventory drift report</strong> — <code>list-devices-servers --all</code> diffed against your source of truth answers &ldquo;what&rsquo;s in ASBM that inventory doesn&rsquo;t know about, and vice versa.&rdquo; Catches devices bought but never enrolled.</li>
<li><strong>Fleet-wide AppleCare coverage</strong> — <code>list-devices --include-applecare</code> fans out per-device coverage lookups (Apple has no bulk endpoint, so it does a parallel pass plus a sequential retry for the ones Apple&rsquo;s HTTP/2 endpoint drops). Feed that to a dashboard and see what&rsquo;s about to fall out of warranty before a lab tech does.</li>
<li><strong>MAC addresses into inventory</strong> — recent API versions expose Wi-Fi, Bluetooth, and Ethernet MACs per device via <code>get-devices-info</code>, handy for network access control.</li>
<li><strong>Multi-tenant management</strong> — the profile system lets one binary drive several ASBM instances (<code>--profile school-east</code>, <code>--profile business-unit-3</code>). Manage more than one org and you&rsquo;re passing a flag, not juggling logins — which is exactly what you want in a CI job.</li>
<li><strong>Bulk pre-staging</strong> — assign a cart of freshly-bought Macs to the right MDM server <em>before</em> they&rsquo;re unboxed, straight from a CSV off the purchase order, so they enroll correctly on first boot with zero touch.</li>
<li><strong>Resumable audits of huge fleets</strong> — <code>list-devices --resume</code> checkpoints after each page, so a pull across tens of thousands of devices survives a transient timeout instead of starting over.</li>
</ul>
<p>It&rsquo;s pretty cool to have Apple School and Business Manager scriptable. Every device-to-server assignment, every server&rsquo;s roster, whole-fleet AppleCare, MAC addresses, audit history — all of it is JSON you can pipe, diff, schedule, and act on. Point a cloud function or a CI job at it and let reconciliations run on their own. This used to mean clicking through a web portal one device at a time. Scripting it instead still feels a little bit magic.</p>
<p><a href="https://github.com/rodchristiansen/asbmutil"><code>asbmutil</code></a> is open source and MIT, and the <a href="https://github.com/rodchristiansen/asbmutil/tree/main/examples/ci">CI samples</a> are ready to lift — if you run a fleet through ASBM, go automate the boring parts. PRs welcome.</p>
]]></content:encoded></item><item><title>ASBMUtil app</title><link>https://blog.focused.systems/asbmutil-app-launch/</link><guid isPermaLink="true">https://blog.focused.systems/asbmutil-app-launch/</guid><pubDate>Mon, 13 Apr 2026 09:29:26 +0000</pubDate><dc:creator>Rod Christiansen</dc:creator><category>macos</category><category>mdm</category><category>swift</category><category>devops</category><description>The Swift CLI for Apple School &amp; Business Manager API now has a native SwiftUI app. Same credentials store, same API client, same bulk operations — inside a native app.</description><content:encoded><![CDATA[<p><img src="https://github.com/rodchristiansen/asbmutil/raw/main/resources/main.png?raw=true" alt="ASBMUtil app"><p>When Apple finally released the API for Apple Business &amp; School Manager last year I was thrilled. I took it as an opportunity to try and create a Swift CLI for it which ended up being <a href="https://github.com/rodchristiansen/asbmutil">asbmutil</a>, a Swift binary that directly communicates with the <a href="https://developer.apple.com/documentation/apple-school-and-business-manager-api">Apple School &amp; Business Manager API</a>. I posted it in the MacAdmins Slack, didn't think much about it, and it ended up getting used by folks — along with <a href="https://github.com/rodchristiansen/asbmutil/issues?q=is%3Aissue%20state%3Aclosed">requests to make it better</a>.</p><p>Today I'm shipping <strong>ASBMUtil.app</strong>, a native SwiftUI front-end on top of the same engine. Same credentials store, same API client, same bulk operations — inside a native Liquid Glass Tahoe app.</p><figure class="kg-card kg-image-card"><img src="ASBMUtil-2-1.png" class="kg-image" alt="ASBMUtil app" loading="lazy" width="256" height="256"></figure><p>Not every Mac admin wants to live in the terminal. Even a well-built CLI carries its own friction — &quot;here's the command, make sure the profile is set, redirect jq somewhere, don't forget the <code>&ndash;mdm</code> flag…&quot;</p><p>Hence the GUI app. Same engine underneath, same credentials store, same API client.</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://github.com/rodchristiansen/asbmutil/raw/main/resources/bulk.png?raw=true" class="kg-image" alt="ASBMUtil app" loading="lazy" width="4052" height="2210"></figure><h2 id="whats-in-the-app">What's in the App</h2><p>The main view is a single native table with every managed device in your Apple Business &amp; School Manager account — Macs, iPads, iPhones, Apple TVs, all of it — right next to each other. Click a row and a sidebar slides in with the full device details: serial, order, status, MDM assignment, AppleCare coverage, MAC addresses, the works. Select a handful, right-click, reassign.</p><ul><li><strong>Device browser</strong> — every managed device in one paginated, sortable table with a details sidebar. AppleCare coverage enrichment is a toggle away.</li><li><strong>Powerful filters</strong> — filter by device status, by order number, by model family, by MDM server — stack filters to narrow to exactly the specific devices you want.</li><li><strong>MDM server list and assignments</strong> — see every server and what's assigned to it.</li><li><strong>Bulk assign / unassign</strong> — multi-select in the table, pick a destination server, go. Or import a CSV if that's how your desired state arrives.</li><li><strong>Export to CSV or JSON</strong> — selection or filtered results out through the macOS share sheet.</li><li><strong>Multi-profile switching</strong> — flip between Apple Business &amp; School Manager instances from the sidebar; manage credentials in settings.</li></ul><figure class="kg-card kg-gallery-card kg-width-wide"><div class="kg-gallery-container"><div class="kg-gallery-row"><div class="kg-gallery-image"><img src="https://github.com/rodchristiansen/asbmutil/raw/main/resources/profiles.png?raw=true" width="1344" height="782" loading="lazy" alt="ASBMUtil app"></div><div class="kg-gallery-image"><img src="https://github.com/rodchristiansen/asbmutil/raw/main/resources/export.png?raw=true" width="604" height="504" loading="lazy" alt="ASBMUtil app"></div></div></div></figure><h2 id="filters">Filters</h2><p>The filters match the web — same categories, same stacking behaviour — so if you already use the ABM filters, you already know how these work. The difference is that here they run against a native table with every device already loaded, so stacking is instant, the selection carries into bulk assign/unassign in one click, and the filtered set exports to CSV or JSON through the share sheet.</p><p>Filter by order number, device status, model family, or MDM server — and stack them, so <em>&quot;unassigned iPads from the last PO that haven't been routed to Intune yet&quot;</em> is three clicks.</p><figure class="kg-card kg-gallery-card kg-width-wide"><div class="kg-gallery-container"><div class="kg-gallery-row"><div class="kg-gallery-image"><img src="https://github.com/rodchristiansen/asbmutil/blob/main/resources/filter_orders.png?raw=true" width="934" height="796" loading="lazy" alt="ASBMUtil app"></div><div class="kg-gallery-image"><img src="https://github.com/rodchristiansen/asbmutil/blob/main/resources/filter_status.png?raw=true" width="936" height="792" loading="lazy" alt="ASBMUtil app"></div></div></div></figure><p>Once you've narrowed to the right cohort, export (CSV/JSON) or bulk-reassign works on the filtered selection — not on the whole fleet.</p><h2 id="what-you-get">What You Get</h2><ul><li><strong>Pure Swift 6 binary</strong> — no external runtime dependencies.</li><li><strong>Credentials stored in the macOS Keychain</strong> — data-protection class, no per-app ACL prompts.</li><li><strong>Multiple profile support</strong> — manage several AxM instances (school-district-east, business-unit-3, whatever) from a dropdown instead of <code>&ndash;profile</code> flags.</li><li><strong>Automatic OAuth 2 client-assertion handling</strong> — token lifecycle handled for you.</li><li><strong>Paginated device fetch</strong> — walks the full inventory without you worrying about page cursors.</li><li><strong>Bulk operations</strong> — CSV import <em>or</em> multi-select directly in the GUI table.</li><li><strong><code>StrictConcurrency</code> enabled</strong> — actor-isolated, race-free by design.</li><li><strong>Bulk device-to-server resolution</strong> — server-side device listing gets the whole fleet in 4–5 API calls regardless of size, instead of per-device lookups.</li><li><strong>Bulk AppleCare enrichment</strong> — <code>list-devices &ndash;include-applecare</code> runs a two-pass fan-out across the whole fleet, no CSV required.</li></ul><h2 id="recent-api-coverage">Recent API Coverage</h2><p>I've kept the tool caught up with the AxM API as Apple has shipped new fields:</p><ul><li><strong>API 1.5</strong> — MAC addresses now support multiple values (array format) for devices with multiple network interfaces.</li><li><strong>API 1.4</strong> — Wi-Fi, Bluetooth, and built-in Ethernet MAC addresses for macOS.</li><li><strong>API 1.3</strong> — AppleCare coverage lookup for devices (single-serial via <code>get-devices-info</code>, whole-fleet via <code>list-devices &ndash;include-applecare</code>).</li><li><strong>API 1.2</strong> — Wi-Fi and Bluetooth MAC addresses for iOS, iPadOS, tvOS, and visionOS.</li></ul><h2 id="running-in-the-cloud">Running in the Cloud</h2><p>Alongside the GUI app, the other side of the coin — fully cloud, headless operations — has gotten a lot of work too. Fully static Swift runtime via <code>&ndash;static-swift-stdlib</code>, URLSession fixes for musl, and keychain-less credential provisioning from env vars so the same codebase runs cleanly on Ubuntu. One Swift 6 binary, one data model, one API client — running unchanged inside <strong>Azure Functions, AWS Lambda, or any Linux container</strong>.</p><p>Cloud automations that the Linux binary offers:</p><ul><li><strong>Reconciliation (inventory → Apple)</strong> — a trigger fires off a desired-state list (a CSV, a DB query, an event), the function asks <code>asbmutil</code> what Apple Business &amp; School Manager currently shows, diffs the two, and issues <code>assign</code>/<code>unassign</code> calls to close the gap. Anything the inventory system considers authoritative — MDM server, department, ownership tags — can drive the comparison.</li><li><strong>Enrichment (Apple → inventory)</strong> — a timer pulls the full device list plus AppleCare coverage via <code>asbmutil</code>, hashes the normalised payload per serial, stores the fingerprint in blob storage, and only writes back to inventory when the Apple-side data has actually changed. Warranty months, coverage status, order number, MDM assignment — all flow in automatically, and the hash cache keeps API traffic and downstream write-load proportional to real change, not fleet size.</li></ul><p>Outcome: inventory and Apple Business &amp; School Manager stay in lockstep without anyone opening either UI. Warranty data flows in one direction, MDM assignments in the other, both driven by the same Swift binary you'd run on your Mac.</p><p>I plan to write in more detail how I'm running these and the code behind them in follow-up posts.</p><h2 id="how-to-use-all-three">How to Use All Three</h2><p>The CLI has its place. It's the thing I'll use to check which MDM a device is assigned to when I need quick operations — <code>asbmutil list-devices-servers &ndash;mdm &quot;Intune&quot;</code> piped through <code>jq</code> and <code>grep</code>, done in a second.</p><p>For bulk operations I reach for the GUI app — load a CSV, select in the table, hit the button. Being able to <em>see</em> what's assigned, what's unassigned, and peruse the fleet side-by-side catches mistakes before they ship and surfaces clean-up opportunities you'd miss on the command line.</p><p>And for the continuous, unattended work — nightly syncs, warranty enrichment, inventory reconciliation — the Linux binary inside a serverless function quietly does it in the background, no interaction, automated.</p><p>Same data model, same trust store, same API calls underneath.</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://github.com/rodchristiansen/asbmutil/raw/main/resources/servers.png?raw=true" class="kg-image" alt="ASBMUtil app" loading="lazy" width="4052" height="2210"></figure><h2 id="grab-it">Grab It</h2><p>Repo: <a href="https://github.com/rodchristiansen/asbmutil">github.com/rodchristiansen/asbmutil</a></p><p>Grab the latest <code>.pkg</code> or <code>.dmg</code> from the <a href="https://github.com/rodchristiansen/asbmutil/releases/latest">releases page</a>. The installer drops both the app into <code>/Applications</code> and the <code>asbmutil</code> CLI into <code>/usr/local/bin</code>.</p><p><strong>A note on signing:</strong> the released artifacts are unsigned and not notarized. On first launch Gatekeeper will complain — clear the quarantine attribute and you're good:</p><pre><code class="language-bash">xattr -dr com.apple.quarantine /Applications/ASBMUtil.app
xattr -dr com.apple.quarantine /usr/local/bin/asbmutil
</code></pre><p>Or build and sign it yourself from source — <code>make build</code> in the repo will do the whole signing + notarization dance if you drop your own Developer ID into <code>.env</code>. As Mac admins you know the drill.</p><p>Requirements: macOS 14+, and an AxM API account with a client ID, key ID, and PEM. If you already have the CLI configured, the app reads the same keychain profiles — nothing to migrate.</p><p>More posts coming on the specific things this tool makes trivial that ABM's web UI makes painful — starting with the bulk device-to-server resolver, which I think is the most interesting piece of the codebase. For now: v1 of the app exists, it works, and I'd love to hear where it breaks for you.</p></p>
]]></content:encoded></item><item><title>Modern Munki DevOps</title><link>https://blog.focused.systems/munki-devops/</link><guid isPermaLink="true">https://blog.focused.systems/munki-devops/</guid><pubDate>Thu, 12 Jun 2025 17:31:28 +0000</pubDate><dc:creator>Rod Christiansen</dc:creator><category>devops</category><category>git</category><category>ci/cd</category><category>munki</category><category>azure</category><category>macadmin</category><description>How we transformed our Munki workflow into a DevOps-native system using Git, CI/CD pipelines, and Azure. Companion post to my MacDevOps YVR 2025 talk.</description><content:encoded><![CDATA[<blockquote><strong>MacDevOps YVR 2025 Companion Post</strong></blockquote><p>A year ago, we were managing Macs the way most orgs still do: one shared Mac, VNC&#x2019;d into, running a local copy of Munki with no real version control or workflow isolation. Git was an afterthought. Deployments were manual. It worked&#x2014;until it didn&#x2019;t scale.</p><p>We&#x2019;ve since inverted the model. Git is the gate. CI is the deployer. Each admin works from their own machine. And the Munki repo is fully DevOps-native.</p><p>Here&#x2019;s how we rebuilt everything using Git, Azure DevOps, Service Bus, pipelines, local caching servers, and inventory automation&#x2014;all open source and cloud-integrated.</p><h2 id="from-manual-to-devops">From Manual to DevOps</h2><p>The legacy flow was:</p><ul><li>GitLab running on-prem</li><li>One shared Mac as the deploy point</li><li>One central repo, updated by many hands</li><li>No pipeline. No hooks. No approval gates.</li><li>Everyone stepped on everyone&#x2019;s toes</li></ul><p>Now we have:</p><ul><li>Azure DevOps Git repos and pipelines</li><li>Git hooks that upload/download packages automatically</li><li>Separate working copies per admin</li><li>Local caching servers that sync intelligently</li><li>A full CI/CD system that integrates with inventory and deploys via pull requests</li></ul><h2 id="architecture-overview">Architecture Overview</h2><p>We&#x2019;ve split this into two core flows:</p><h3 id="munki-devops-infrastructure">Munki DevOps Infrastructure</h3><ul><li>Admins commit to a shared Azure DevOps repo with <code>manifests/</code> and <code>pkgsinfo/</code></li><li>Git hooks (post-commit/merge) run <code>azcopy sync</code> to upload or download packages</li><li>A pipeline (<code>munki-push-production.yml</code>) builds catalogs and updates Azure Storage</li><li>Local caching servers (like PLUTO and PROTEUS) are notified via Azure Service Bus</li><li>A daemon listens for commits and runs <code>git pull</code> and syncs assets</li><li>CDN serves files globally or from on-prem caches</li><li>It&#x2019;s fast, redundant, and observable</li></ul><h3 id="inventory-orchestrated-enrollment">Inventory-Orchestrated Enrollment</h3><ul><li>A polling script checks Snipe-IT for inventory changes and builds new CSVs</li><li>CSVs are committed to Git &#x2192; triggers DevOps pipelines &#x2192; runs <code>enrollment-munki</code>, <code>enrollment-intune</code>, <code>enrollment-sharepoint</code>, and more</li><li>Each system (Munki, Intune, Fleet, TDX, Papercut) gets updated data</li><li>Pull requests are the approval gate</li><li>Everything is traceable, auditable, and CI-driven</li></ul><h2 id="resources">Resources</h2><ul><li><strong>Presentation Repo</strong>: <a href="https://github.com/rodchristiansen/munki-devops">github.com/rodchristiansen/munki-devops</a></li></ul><p>Want to talk shop or ask questions? Connect with me on <a href="https://bsky.app/profile/rodchristiansen.net">BlueSky</a> or on the <a href="https://github.com/rodchristiansen">GitHub</a>.</p>
]]></content:encoded></item><item><title>Passwordless Git SSO with Git Credential Manager</title><link>https://blog.focused.systems/git-sso-credential-manager/</link><guid isPermaLink="true">https://blog.focused.systems/git-sso-credential-manager/</guid><pubDate>Tue, 10 Jun 2025 03:53:17 +0000</pubDate><dc:creator>Rod Christiansen</dc:creator><description>End the PAT era—step‑by‑step macOS and Windows guide to enable SSO for Azure DevOps and GitHub using Git Credential Manager.</description><content:encoded><![CDATA[<p>The Git ecosystem is phasing out long-lived personal-access tokens. Cloning with Personal Access Tokens are being retired, and new policies now let administrators block or tightly restrict PAT creation. Best-practice docs point to short-lived identity-provider tokens&#x2014;refreshed automatically and governed by conditional-access rules&#x2014;as the preferred way forward.</p><p>Wiring Git Credential Manager (GCM) into your local global Git helpers you trade fragile over-scoped PATs for one-hour tokens that renew silently and leave no secrets on disk or in build logs.</p><h3 id="what-this-guide-helps-you-with">What this guide helps you with</h3><ul><li>Enable seamless Single&#x2011;Sign&#x2011;On on <strong>macOS</strong> and <strong>Windows</strong> for:<ul><li>Azure DevOps (<code>https://dev.azure.com/ORG/&#x2026;</code>)</li><li>GitHub.com or GitHub&#xA0;Enterprise (<code>https://github.com/&#x2026;</code>)</li></ul></li></ul><hr><h2 id="macos">macOS</h2><p>How to setup GCM for password&#x2011;free access on macOS:</p><h3 id="prerequisites">Prerequisites</h3><ul><li>Homebrew</li><li>Git &#x2265; the version bundled with Xcode&#x202F;15</li><li>Git Credential Manager (installed below)</li></ul><h3 id="install-upgrade-components">Install&#xA0;/&#xA0;upgrade components</h3><pre><code class="language-bash">brew install --cask git-credential-manager
brew upgrade git
</code></pre><h3 id="configure-global-helpers">Configure global helpers</h3><pre><code class="language-bash">git config --global --replace-all credential.helper manager
git config --global --add credential.helper osxkeychain
git config --global credential.msauthFlow devicecode
git config --global credential.guiPrompt false
</code></pre><h3 id="persist-settings-for-shells-gui-apps">Persist settings for shells &amp; GUI apps</h3><pre><code class="language-bash">echo &apos;export GCM_MSAUTH_FLOW=devicecode&apos; &gt;&gt; ~/.zprofile
echo &apos;export GCM_GUI_PROMPT=0&apos; &gt;&gt; ~/.zprofile
<p>launchctl setenv GCM_MSAUTH_FLOW devicecode
launchctl setenv GCM_GUI_PROMPT 0
</code></pre><ul><li><strong>VS Code:</strong> add <code>&quot;git.terminalAuthentication&quot;: false</code> to <em>settings.json</em></li><li><strong>Git Tower:</strong> <code>defaults write com.fournova.Tower5 UseCredentialManager -bool true</code></li></ul><h3 id="first-run">First run</h3><pre><code class="language-bash">git fetch   # single device‑code prompt, then silent
</code></pre><hr><h2 id="windows">Windows</h2><p>And here's how to setup on Windows, leveraging the Entra ID broker for silent SSO with Azure DevOps and device‑code for GitHub.</p><h3 id="prerequisites-1">Prerequisites</h3><ul><li>Git for Windows ≥ 2.45 (bundles GCM v2)</li><li>Device joined to Entra ID (native, hybrid, or AAD‑registered)</li></ul><h3 id="clean-up-old-helpers">Clean up old helpers</h3><pre><code class="language-powershell">git credential-manager unconfigure
git credential-manager configure
git config &ndash;global &ndash;unset-all credential.helper
git config &ndash;global &ndash;remove-section credential
</code></pre><h3 id="enable-broker-sso-azure-devops-and-device-code-github">Enable Broker SSO (Azure DevOps) and Device Code (GitHub)</h3><pre><code class="language-powershell">git config &ndash;global credential.helper manager-core
git config &ndash;global credential.microsoft.sso true
git config &ndash;global credential.msauthUseBroker true
git config &ndash;global credential.msauthFlow broker
git config &ndash;global credential.githubAuthModes devicecode
</code></pre><h3 id="strip-hard%E2%80%91coded-usernames-from-remotes">Strip hard‑coded usernames from remotes</h3><pre><code class="language-powershell">git remote set-url origin <a href="https://dev.azure.com/ORG/PROJECT/_git/REPO">https://dev.azure.com/ORG/PROJECT/_git/REPO</a>
git remote set-url origin <a href="https://github.com/ORG/REPO">https://github.com/ORG/REPO</a>
</code></pre><h3 id="first-run-1">First run</h3><pre><code class="language-powershell">git fetch   # one Windows dialog, then silent
</code></pre><hr><h2 id="bulk%E2%80%91fix-existing-repositories-optional">Bulk‑fix existing repositories (optional)</h2><p>Replace the sample paths below with the folder that contains multiple repositories.</p><p><strong>PowerShell (Windows)</strong></p><pre><code class="language-powershell">Get-ChildItem C:\Dev\Repos -Directory | ForEach-Object {
git -C $<em>.FullName remote set-url origin (git -C $</em>.FullName remote get-url origin -replace '://.<em>@', '://')
}
</code></pre><p><strong>zsh (macOS)</strong></p><pre><code class="language-zsh">for d in ~/Dev/Repos/</em>(.); do
url=$(git -C &quot;$d&quot; remote get-url origin | sed 's#://.*@#://#')
git -C &quot;$d&quot; remote set-url origin &quot;$url&quot;
done
</code></pre><hr><h2 id="troubleshooting">Troubleshooting</h2><p>Run <code>git-credential-manager diagnose</code> for a quick health check. Erase stale tokens with:</p><pre><code class="language-bash">git credential-manager erase <a href="https://dev.azure.com">https://dev.azure.com</a>
git credential-manager erase <a href="https://github.com">https://github.com</a>
</code></pre><p>Need verbose output? Temporarily set:</p><pre><code class="language-bash">export GIT_TRACE=1
export GCM_TRACE=1
git fetch
</code></pre><p>If GCM prompts twice on macOS, <em>login.keychain-db</em> may be read‑only. Unlock and purge stale entries, then retry:</p><pre><code class="language-bash">security unlock-keychain ~/Library/Keychains/login.keychain-db
git credential-manager erase <a href="https://dev.azure.com">https://dev.azure.com</a>
git credential-manager erase <a href="https://github.com">https://github.com</a>
</code></pre><hr><p>By switching from long-lived PATs to short-lived tokens through Git Credential Manager, you lock down your supply chain while making everyday Git activity faster and quieter:</p><ul><li>MFA is enforced automatically and refreshed in the background.</li><li>Tokens rotate every hour, slashing the window for theft or replay.</li><li>No secrets leak into scripts, CI logs, or dotfiles—nothing to scrub later.</li></ul><p>Bake these GCM settings into your workstation images and onboarding scripts once, and every clone, fetch, and push runs hands-free from that point on. Stronger security, zero extra clicks, and no browser pop-ups—that’s a win on every front.</p></p>
]]></content:encoded></item><item><title>GitOps Your Ghost Publishing</title><link>https://blog.focused.systems/ghost-gitops-publishing/</link><guid isPermaLink="true">https://blog.focused.systems/ghost-gitops-publishing/</guid><pubDate>Fri, 16 May 2025 03:20:41 +0000</pubDate><dc:creator>Rod Christiansen</dc:creator><category>gitops</category><category>ghost</category><category>blogging</category><category>devtools</category><description>A Git-first workflow for publishing to Ghost that mirrors how you ship code versioned, stateless, and CI-friendly.</description><content:encoded><![CDATA[<p>Alright, let&#x2019;s get meta. This blog is about DevOps and Git&#x2014;so I&#x2019;m kicking it off by showing you how I <em>ship</em> this blog. With <code>ghostpost</code>, a tool I built that lets me publish to Ghost the same way I manage code: in Git.</p><h2 id="publishing-like-a-dev-meet-ghostpost">Publishing Like a Dev: Meet GhostPost</h2><p>You use <a href="https://ghost.org/">Ghost</a> because it&#x2019;s modern, open, and built for professional creators. It gives you newsletters, subscriptions, and a full publishing stack that doesn&#x2019;t sell your soul to adtech.</p><p>You use <a href="https://git-scm.com/">Git</a> because you want version history, branches, and working with the all mighty plain text.</p><p><code>ghostpost</code> is a GitOps-style CLI for managing Ghost posts.</p><p>Each post lives as a Markdown file in your repo. The front-matter stores all metadata&#x2014;including the Ghost <code>post_id</code>.</p><p>You edit locally. You commit. <code>ghostpost</code> publishes.</p><h2 id="why-i-built-this">Why I Built This</h2><p>I wanted a writing workflow that matched how one might ship code.</p><ul><li>No fragile CMS UI edits</li><li>No &quot;oops I deleted the draft&quot;</li><li>No back-and-forth between browser and source doc</li></ul><p>The Ghost GUI becomes just a preview tool. <em>Nothing gets edited in it.</em></p><p>Not necessarily a new idea&#x2014;<a href="https://www.how-hard-can-it.be/post2ghost/">post2ghost</a> laid out the same &quot;Articles as Code&quot; concept where content belongs in Git. <strong>The CMS should be a rendering layer, not an editing platform.</strong></p><p>That post nailed the philosophy:</p><ul><li>Keep Markdown files under version control</li><li>Write in your editor of choice</li><li>Automate publishing with API calls</li></ul><p>It was still a bit DIY and python based which is a dependency headache -- I love python, but not for cli tools...</p><p><code>ghostpost</code> takes that same idea and wraps it in a clean CLI. One command and simple.</p><h2 id="how-it-works">How It Works</h2><p>You write a post in Markdown, with front-matter like this:</p><pre><code class="language-markdown">---
title: Your title here
slug: your-slug
custom_excerpt: Short summary here
tags: [DevOps, Ghost]
feature_image: images/cover.jpg
status: draft
---
<p>Your content in Markdown.
</code></pre><p>Then you run:</p><pre><code>ghostpost publish -f /path/to/post.md &ndash;editor
</code></pre><p>The tool takes care of:</p><ul><li>Creating the post if it’s new</li><li>Updating the post if it already exists</li><li>Uploading and rewriting image paths to proper Ghost URLs</li></ul><p>There’s no runtime. No daemon. No need to open the CMS.</p><h2 id="what-you-get">What You Get</h2><ul><li><strong>Real version control</strong> Posts live in Git. You get <code>git log</code>, pull requests, inline diffs, CI checks.</li><li><strong>Stateless deploys</strong> Posts can be published from anywhere. Just point to the Markdown file. Or automatically with CI/CD.</li><li><strong>Front-matter is the truth</strong> Titles, tags, status, authors, descriptions—everything is stored right inside the Markdown file.</li><li><strong>Smart images</strong> Use local paths. <code>ghostpost</code> uploads and rewrites them for you.</li><li><strong>CI-ready</strong> Validate structure. Block merges on bad metadata. Push on deploy.</li></ul><p>Here’s a basic example using GitHub Actions:</p><pre><code class="language-yaml"># .github/workflows/publish.yml
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: ghostpost publish -f posts/hello-from-git.md &ndash;editor
</code></pre><h2 id="why-not-just-use-the-ghost-ui">Why Not Just Use the Ghost UI?</h2><p>Because once you’ve versioned your posts, previewed with Markdown, and deployed with a single command—there’s no going back.</p><p>No clunky editors. No missing history. No surprises.</p><h2 id="get-started">Get Started</h2><p>Check out the repo and the readme at <a href="https://github.com/rodchristiansen/ghost-gitops-publishing"><a href="https://github.com/rodchristiansen/ghost-gitops-publishing">https://github.com/rodchristiansen/ghost-gitops-publishing</a></a></p><p>Install the binary, connect it to your Ghost API, and start treating your writing like the rest of your infrastructure: reproducible, testable, and versioned.</p></p>
]]></content:encoded></item><item><title>Welcome to Focused Systems</title><link>https://blog.focused.systems/welcome/</link><guid isPermaLink="true">https://blog.focused.systems/welcome/</guid><pubDate>Sun, 11 May 2025 06:36:35 +0000</pubDate><dc:creator>Rod Christiansen</dc:creator><category>devops</category><category>Endpoint Management</category><category>ci/cd</category><category>macos</category><category>Windows</category><description>Exploring modern cloud workflows</description><content:encoded><![CDATA[<p>I&#x2019;ve been managing Macs since before MDM mattered&#x2014;when deployment meant NetBoot imaging, and local software deployment repos with a couple of on-prem Mac minis, login hooks, and ad-hoc ARD commands fired off through a GUI.</p><p>It worked&#x2014;until it didn&apos;t.</p><p>We moved from running local scripts on a shared Mac to each admin working from their own local repo, committing changes through Git, syncing packages with <code>az</code> git hooks. We used to push changes first, then capture them in Git after the fact. Now, Git is the gate. Commits and PRs drive the entire process. Pipelines run automatically, and the cloud becomes the source of truth. Our local caching servers listen for updates&#x2014;pulling down only when there are changes&#x2014;fully inverting the workflow into something distributed, reliable, and scalable. All of it version-controlled. All of it traceable. All of it running on infrastructure provisioned with Terraform.</p><p>Every config, every profile, every software assignment that matters tracked in Git. It&#x2019;s CI/CD deployed, reproducible by design, and logged automatically for traceability.</p><h2 id="what-this-blog-is-about">What This Blog Is About</h2><p>This blog is about the journey&#x2014;the migrations, the patterns that emerged, the decisions that held up, and the ones that didn&#x2019;t. And about the new tools and ideas I&apos;ll be building and using along the way.</p><p>If you work in endpoint management with DevOps, you&#x2019;ll find deep dives into:</p><ul><li>CI/CD pipelines that manage device tools, states, and configuration</li><li>Every cloud resource under Infrastructure as Code with Terraform</li><li>Inventory systems that drive deployment logic</li><li>And Git at the center of it all</li></ul><p>As I now manage both macOS and Windows endpoints, I&#x2019;ll be writing about how I&apos;m creating a cohesive, mirrored management system&#x2014;where both platforms are driven by open source tools, DevOps, and Git, and where admins speak the same language on both sides.</p><p><strong>Focused Systems. Grounded in what actually works and scales. Very opinionated.</strong></p><p>Let&#x2019;s get to it.</p>
]]></content:encoded></item></channel></rss>