<?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/tags/apple/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></channel></rss>