<?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/"><channel><title>Opentofu on bismigel</title><link>https://bismigel.dev/tags/opentofu/</link><description>Recent content in Opentofu on bismigel</description><generator>Hugo</generator><language>en-us</language><copyright>bismigel</copyright><lastBuildDate>Sat, 24 Oct 2026 21:30:00 -0400</lastBuildDate><atom:link href="https://bismigel.dev/tags/opentofu/index.xml" rel="self" type="application/rss+xml"/><item><title>Media quality preferences: size caps, quality profiles and the disk rebalance that forced them</title><link>https://bismigel.dev/posts/2026-10-24-media-quality-preferences-and-rebalance/</link><pubDate>Sat, 24 Oct 2026 21:30:00 -0400</pubDate><guid>https://bismigel.dev/posts/2026-10-24-media-quality-preferences-and-rebalance/</guid><description/><content:encoded><![CDATA[<p>The media pool reported 71% used. One disk inside it was at 99%. Nothing on a dashboard disagreed with anything else, until LazyLibrarian started writing book imports into its own config volume and calling it a success.</p>
<p>The cause was not a full disk. It was a preference: the stack had no rule about what a movie is allowed to weigh, and Radarr ships with no limit for 2160p at all.</p>
<p>This is the whole story: the disk, the rebalance, and the two declarative layers that now hold the policy.</p>
<h1 id="the-failure-99-on-one-branch-71-on-the-pool">The failure: 99% on one branch, 71% on the pool</h1>
<p>The pool is mergerfs over four branches:</p>
<table>
	<thead>
			<tr>
					<th>Branch</th>
					<th>Where</th>
					<th>Size</th>
					<th>Role</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>media-sda</code></td>
					<td>local SATA</td>
					<td>234G</td>
					<td>library, pinned</td>
			</tr>
			<tr>
					<td><code>media-nvme</code></td>
					<td>local NVMe</td>
					<td>135G</td>
					<td>library, pinned</td>
			</tr>
			<tr>
					<td><code>remote82-sda</code></td>
					<td>NFS from the other node</td>
					<td>458G</td>
					<td>mostly idle</td>
			</tr>
			<tr>
					<td><code>remote82-nvme</code></td>
					<td>NFS from the other node</td>
					<td>340G</td>
					<td>idle</td>
			</tr>
	</tbody>
</table>
<p>mergerfs used <code>category.create=epmfs</code>: a new file goes to the branch that already holds its directory, and free space is only compared between branches that hold it. The library directories existed on <code>media-sda</code> only, so every import landed there. Both other local disks were empty.</p>
<p>When <code>media-sda</code> dropped under mergerfs&rsquo; 4G <code>minfreespace</code>, creates failed with <code>ENOSPC</code> even though the pool had 788G free. That is the trap: <code>df /mnt/media</code> shows the union and stays calm, while the one branch that accepts writes is full.</p>
<p>The symptom was indirect. LazyLibrarian imported a book, could not write to <code>/books</code>, and fell back to its config volume:</p>
<pre><code>WARNING: eBook dir ['/books'] not writeable, using /config:
[Errno 28] No space left on device: '/books/ll_temp'
</code></pre>
<p>The book was in the database. The file was in the wrong place. The UI said the download worked.</p>
<h1 id="rebalancing-without-losing-anything">Rebalancing without losing anything</h1>
<p>The first fix is movement, not deletion. mergerfs merges reads, so a folder can move to another branch and keep serving from the same path.</p>
<p>The numbers from that day:</p>
<table>
	<thead>
			<tr>
					<th>Moved</th>
					<th>Size</th>
					<th>From</th>
					<th>To</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>9-1-1</code></td>
					<td>46G</td>
					<td>media-sda</td>
					<td>remote82-sda</td>
			</tr>
			<tr>
					<td><code>INVINCIBLE (2021)</code></td>
					<td>23G</td>
					<td>media-sda</td>
					<td>remote82-sda</td>
			</tr>
			<tr>
					<td><code>The Sopranos</code></td>
					<td>19G</td>
					<td>media-sda</td>
					<td>remote82-sda</td>
			</tr>
			<tr>
					<td><code>Marty Supreme (2025)</code></td>
					<td>69G</td>
					<td>media-nvme</td>
					<td>remote82-sda</td>
			</tr>
	</tbody>
</table>
<p>Result:</p>
<table>
	<thead>
			<tr>
					<th>Branch</th>
					<th>Before</th>
					<th>After</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>media-sda</td>
					<td>84% (36G free)</td>
					<td>46% (122G free)</td>
			</tr>
			<tr>
					<td>media-nvme</td>
					<td>91% (12G free)</td>
					<td>38% (80G free)</td>
			</tr>
			<tr>
					<td>remote82-sda</td>
					<td>4%</td>
					<td>40%</td>
			</tr>
	</tbody>
</table>
<p>Total pool content did not change: 318G before and after.</p>
<p>Three rules keep this safe:</p>
<ol>
<li>Never delete before the copy is verified. Compare the file list and the size of every file, then delete the source.</li>
<li>Use a tab separator when reading that list. Show names contain spaces, and <code>read -r path size</code> silently splits <code>Season 4</code> into two fields.</li>
<li>Expect a new file in the destination. Sonarr imported an episode during one copy. That is fine, the check only verifies the source list.</li>
</ol>
<p>A trap I walked into: I first tried to move a 69G movie onto <code>media-sda</code>. That was the wrong direction, it filled the disk I was trying to protect. I stopped it, removed the partial copy and sent it to <code>remote82-sda</code> instead.</p>
<h1 id="the-real-cause-no-preference-at-all">The real cause: no preference at all</h1>
<p>Moving data bought weeks. It did not explain how a single film reached 68G.</p>
<p>Radarr&rsquo;s stock quality definitions have <code>maxSize: null</code> for every 2160p class. No limit means a <code>Remux-2160p</code> is an acceptable release. One of them was 21% of the entire library.</p>
<p>The resolution was not the problem. The bitrate was. Two constraints decide what is worth storing here:</p>
<ul>
<li>remote streaming is capped at 12 Mbps, so anything above about 5.4 GB per hour cannot be delivered off the LAN at all;</li>
<li>a 2160p WEB-DL measures 125-310 MB/min, and a 2160p remux measures 450-750.</li>
</ul>
<p>So 4K is fine as WEB-DL. The remux class has no consumer in this house.</p>
<h1 id="layer-one-recyclarr-owns-quality-profiles">Layer one: recyclarr owns quality profiles</h1>
<p>Profiles decide which qualities may be downloaded. recyclarr reconciles them from a file in the GitOps repo, nightly.</p>
<p>Three profiles, all upgrade-enabled:</p>
<table>
	<thead>
			<tr>
					<th>Profile</th>
					<th>Allowed</th>
					<th>Cutoff</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>TV-1080p</code></td>
					<td>WEBDL / WEBRip / Bluray / HDTV 1080p</td>
					<td>WEBDL-1080p</td>
			</tr>
			<tr>
					<td><code>Movies-1080p</code></td>
					<td>same four</td>
					<td>WEBDL-1080p</td>
			</tr>
			<tr>
					<td><code>Movies-2160p</code> (opt-in per movie)</td>
					<td>WEBDL / WEBRip 2160p</td>
					<td>WEBDL-2160p</td>
			</tr>
	</tbody>
</table>
<p>No profile allows <code>Remux</code> or <code>Bluray-2160p</code>. 4K stays possible, but only in its sane form and only when assigned to a specific movie.</p>
<p>AVI is not a target. It is a 1992 container, usually with Xvid video: no modern subtitles, no reliable seeking index, and Jellyfin cannot direct-play it, so every playback transcodes for nothing. The library is already 100% MKV and it should stay that way. 1080p HEVC in MKV is the default; H.264 only when a client forces it.</p>
<p>Three things went wrong on the way:</p>
<ol>
<li><code>--dry-run</code> does not exist in the pinned image. The nightly job failed on every schedule until the log said <code>Unknown option 'dry-run'</code> and it became <code>--preview</code>.</li>
<li>The config I wrote was version 8 shaped. <code>recyclarr config create</code> pulls templates from the templates repository&rsquo;s master branch, which is already v8, while the image is 7.4.0. It answered <code>Property 'trash_id' not found</code>.</li>
<li>TRaSH presets are not a shortcut for size limits. Their <code>quality_definition</code> now sets <code>Max = 1000 (Unlimited)</code> for every quality. Applying it would have removed the useful 1080p cap and kept 2160p unlimited, which is the accident itself.</li>
</ol>
<h1 id="layer-two-opentofu-owns-the-size-caps">Layer two: OpenTofu owns the size caps</h1>
<p>recyclarr cannot express numbers, so sizes do not belong to it. OpenTofu has providers for both apps, and <code>quality_definition</code> is a resource.</p>
<p>Caps in MB per minute:</p>
<table>
	<thead>
			<tr>
					<th>Class</th>
					<th>Cap</th>
					<th>Roughly</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>720p</td>
					<td>22</td>
					<td>1.3 GB/h (stock)</td>
			</tr>
			<tr>
					<td>1080p</td>
					<td>35</td>
					<td>2.1 GB/h</td>
			</tr>
			<tr>
					<td>Remux-1080p / Bluray-1080p Remux</td>
					<td>60</td>
					<td>3.6 GB/h</td>
			</tr>
			<tr>
					<td>2160p</td>
					<td>250</td>
					<td>15 GB/h</td>
			</tr>
			<tr>
					<td>Remux-2160p / Bluray-2160p Remux</td>
					<td>400</td>
					<td>24 GB/h</td>
			</tr>
	</tbody>
</table>
<p>Those numbers come from measurement, not from taste: a 60-minute 1080p episode is about 30 MB/min, the 34G 2160p WEB-DL replacement is about 309, and the 68G remux is about 620.</p>
<p>My first draft said 20/55 MB/min. That was wrong. 55 would have rejected every real 4K WEB-DL, so the cap would have looked strict while quietly breaking 4K. The mistake is in the runbook now, with a note not to repeat it.</p>
<p>The rest of that layer is small but earns its place: root folders imported (not created, that would duplicate them) and a Telegram notification per app, failure signals only. Health issues, restores, app updates and manual interaction. Grab and import events stay off, because a TV library would otherwise send a message per episode.</p>
<h1 id="what-the-ownership-split-looks-like">What the ownership split looks like</h1>
<table>
	<thead>
			<tr>
					<th>Owner</th>
					<th>Objects</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>ArgoCD</td>
					<td>deployment, PVC, service, ingress, image pin, network policy</td>
			</tr>
			<tr>
					<td>recyclarr</td>
					<td>quality profiles, custom format scores</td>
			</tr>
			<tr>
					<td>OpenTofu</td>
					<td>quality definition sizes, root folders, notifications</td>
			</tr>
			<tr>
					<td>nothing</td>
					<td>history, seeds, cookies (those live in the R2 backups)</td>
			</tr>
	</tbody>
</table>
<p>One object, one owner. This matters because two reconcilers touching the same object produce a loop: recyclarr would write a profile, Tofu would see drift and revert it, and both would be right.</p>
<p>Download clients and Prowlarr indexers are still imperative, in the setup script. Moving them needs a full config translation per object and duplicates are dangerous there, so they stay for a later slice.</p>
<h1 id="what-it-bought">What it bought</h1>
<ul>
<li>the 68G remux class cannot return through any profile;</li>
<li>2160p has a size limit for the first time;</li>
<li>all three branches sit between 38% and 46%, with 122G free on the busiest one;</li>
<li>a chapter of configuration that lived only in a SQLite file is now reviewable in a pull request.</li>
</ul>
<p>What I would do differently: set the caps before the rebalance. Moving 157G by hand was a symptom. The preference was the disease.</p>
]]></content:encoded></item><item><title>Moving the Cloudflare edge from a shell script to OpenTofu</title><link>https://bismigel.dev/posts/2026-10-20-cloudflare-opentofu-migration/</link><pubDate>Tue, 20 Oct 2026 18:00:00 -0400</pubDate><guid>https://bismigel.dev/posts/2026-10-20-cloudflare-opentofu-migration/</guid><description/><content:encoded><![CDATA[<p>For a year the Cloudflare edge was a shell script. It created tunnel routes, DNS records, Access applications, zone settings and R2 lifecycle rules. A daily timer ran it on the platform node so manual dashboard edits would not survive.</p>
<p>That worked, but it was still a script mutating live infrastructure. OpenTofu made the edge reviewable: one plan, one state, one place to see what changed.</p>
<p>This is how the migration went.</p>
<h1 id="what-was-already-in-the-edge">What was already in the edge</h1>
<p>The account has three zones:</p>
<ul>
<li><code>bismigel.dev</code>, with 21 DNS records;</li>
<li><code>ciceley.cc</code>, with two records;</li>
<li><code>miguelarbis.dev</code>, with three records.</li>
</ul>
<p>There is one remote-managed Cloudflare Tunnel, seven Access applications, four security zone settings and one R2 lifecycle policy. The old script owned all of it.</p>
<h1 id="importing-one-resource-at-a-time">Importing one resource at a time</h1>
<p>I did not import everything at once. I started with one DNS record and checked that <code>tofu plan</code> was a no-op. Then I expanded to the rest of the zone, the tunnel ingress configuration, the Access applications, zone settings and finally R2.</p>
<p>The final state covers:</p>
<ul>
<li>26 DNS records across three zones;</li>
<li>the tunnel ingress configuration;</li>
<li>seven Access applications with their app-scoped policy references;</li>
<li><code>ssl</code>, <code>min_tls_version</code>, <code>always_use_https</code> and <code>security_header</code>;</li>
<li>the R2 lifecycle rules for <code>homelab-backups</code>.</li>
</ul>
<p><code>tofu plan</code> is clean.</p>
<h1 id="the-parts-that-did-not-import-cleanly">The parts that did not import cleanly</h1>
<h2 id="access-api-timeouts">Access API timeouts</h2>
<p>The first Access import attempts returned <code>504</code> from the account-level API. The Cloudflare status page showed a partial outage while the Access component itself showed operational. I did not rotate the token or change the provider. I waited and retried.</p>
<p>When the API recovered, all seven applications imported:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Successfully imported cloudflare_zero_trust_access_application.books
</span></span><span class="line"><span class="cl">...</span></span></code></pre></div>
</div>
<p>The policies are inline, app-scoped policies. The application resource imports their references, so there is no separate reusable policy resource.</p>
<h2 id="r2-lifecycle-has-no-import">R2 lifecycle has no import</h2>
<p>The provider documentation is explicit:</p>
<blockquote>
<p>This resource does not currently support <code>terraform import</code>.</p>
</blockquote>
<p>Instead of leaving R2 in the shell script, I created the <code>cloudflare_r2_bucket_lifecycle</code> resource over the existing rules. OpenTofu adopted the four rules in place. The plan was clean afterward.</p>
<p>There is a sharp edge: OpenTofu cannot destroy this resource. Removing it from configuration and applying would drop it from state, not delete the rules. That is documented in the repo.</p>
<h1 id="state-on-r2-with-native-locking">State on R2, with native locking</h1>
<p>Local state was the next risk. The state now lives in the existing R2 bucket:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">hcl</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">terraform</span> {
</span></span><span class="line"><span class="cl">  <span class="k">backend</span> <span class="s2">&#34;s3&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">    bucket</span>                      <span class="o">=</span> <span class="s2">&#34;homelab-backups&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    key</span>                         <span class="o">=</span> <span class="s2">&#34;opentofu/cloudflare/terraform.tfstate&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    region</span>                      <span class="o">=</span> <span class="s2">&#34;auto&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    endpoints</span>                   <span class="o">=</span><span class="n"> { s3</span> <span class="o">=</span> <span class="s2">&#34;https://&lt;account&gt;.r2.cloudflarestorage.com&#34;</span> }
</span></span><span class="line"><span class="cl"><span class="n">    use_path_style</span>              <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="cl"><span class="n">    skip_credentials_validation</span> <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="cl"><span class="n">    skip_region_validation</span>      <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="cl"><span class="n">    skip_requesting_account_id</span>  <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="cl"><span class="n">    skip_s3_checksum</span>            <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="cl"><span class="n">    use_lockfile</span>                <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>R2 speaks the S3 API. No AWS account is involved. The credentials are R2 access keys from the existing backup environment, not values in the repo.</p>
<p>The lock works. I started two plans at once. The second one failed with:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">StatusCode: 412, api error PreconditionFailed</span></span></code></pre></div>
</div>
<p>That is the S3 native lockfile doing its job. The encrypted state backup remains in <code>r2:homelab-backups/opentofu/</code> as a recovery copy.</p>
<h1 id="retiring-the-daily-writer">Retiring the daily writer</h1>
<p>Once <code>tofu plan</code> was clean, the script had to stop writing. I removed the daily Systemd timer from the NixOS module and kept the service for a manual rollback.</p>
<p>That deployment exposed an unrelated build problem: <code>sops-install-secrets</code> had moved to a Go 1.26 module requirement while the pinned nixpkgs still built Go 1.25.7. The deployment failed before activation. I pinned <code>sops-nix</code> to the last Go 1.25-compatible revision and built the package with the matching vendor hash. The host deployed, and <code>cloudflare-sync.timer</code> no longer exists.</p>
<h1 id="what-i-would-do-differently">What I would do differently</h1>
<ul>
<li>Import the read-only resources first. DNS and tunnel were easy; Access was the one with an API outage.</li>
<li>Move state before adding more resources. The migration is simple while the state is small.</li>
<li>Test the lock deliberately. A backend without a working lock is just remote storage.</li>
<li>Do not treat a clean plan as proof that every resource is covered. The plan is only clean for resources that are in configuration. The old script could still write things OpenTofu did not know about.</li>
</ul>
<h1 id="links">Links</h1>
<ul>
<li>OpenTofu S3 backend: <a href="https://opentofu.org/docs/language/settings/backends/s3/" target="_blank" rel="noopener noreferrer">https://opentofu.org/docs/language/settings/backends/s3/</a>
</li>
<li>Cloudflare R2 lifecycle resource: <a href="https://search.opentofu.org/provider/cloudflare/cloudflare/latest/docs/resources/r2_bucket_lifecycle" target="_blank" rel="noopener noreferrer">https://search.opentofu.org/provider/cloudflare/cloudflare/latest/docs/resources/r2_bucket_lifecycle</a>
</li>
<li>Cloudflare provider: <a href="https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs" target="_blank" rel="noopener noreferrer">https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs</a>
</li>
</ul>
]]></content:encoded></item></channel></rss>