<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Ansible on Lessons learned</title><link>https://eghuro.cz/tags/ansible/</link><description>Recent content in Ansible on Lessons learned</description><generator>Hugo</generator><language>en-us</language><copyright>© Alexandr Mansurov · Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)</copyright><lastBuildDate>Sat, 17 Oct 2026 21:51:00 +0200</lastBuildDate><atom:link href="https://eghuro.cz/tags/ansible/index.xml" rel="self" type="application/rss+xml"/><item><title>The rollback that saved the host, and killed it 24 hours later</title><link>https://eghuro.cz/posts/systemd-run-rollback-killed-dhclient/</link><pubDate>Sat, 17 Oct 2026 21:51:00 +0200</pubDate><guid>https://eghuro.cz/posts/systemd-run-rollback-killed-dhclient/</guid><description>&lt;p&gt;The rollback timer fired, the remote host got its address back, and the journal said all was well:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;dhclient[1796120]: bound to 192.168.0.2 -- renewal in 34318 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemd[1]: lxc-uplink-rollback.service: Deactivated successfully.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Exactly 24 hours later, to the second, the host dropped off the network and took SSH, the port forwards, and the VPN with it.&lt;/p&gt;&#10;&lt;p&gt;When a transient &lt;code&gt;systemd-run&lt;/code&gt; unit finishes, systemd kills everything its command left running. If your rollback, or any other command run that way, starts a daemon, the daemon dies with the unit. Here it was &lt;code&gt;dhclient&lt;/code&gt;, and the damage only showed when the DHCP lease expired.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Debian 13, systemd 257, ifupdown with ISC dhclient 4.4.3-P1&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;allow-hotplug enp0s25&lt;/code&gt; / &lt;code&gt;iface enp0s25 inet dhcp&lt;/code&gt;, with a 24-hour lease from the router&lt;/li&gt;&#10;&lt;li&gt;The only way in is SSH through a port forward to the host&amp;rsquo;s address; the console is days away&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="the-dead-mans-switch-worked"&gt;The dead man&amp;rsquo;s switch worked &lt;/h2&gt;&#10;&lt;p&gt;Before a risky network change, an Ansible play arms a timer that undoes it, applies the change, and disarms the timer only if a fresh SSH login succeeds:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Arm rollback timer&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ansible.builtin.command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;&amp;gt;-&lt;/span&gt;&lt;span class="sd"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; systemd-run --collect --unit=lxc-uplink-rollback --on-active=5min&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; /bin/sh -c &amp;#39;ifdown -a --exclude=lo; ip link del br0;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; cp /etc/network/interfaces.rollback /etc/network/interfaces;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; ifup -a; ifup -a --allow=hotplug&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# ... apply the change ...&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Drop the cached SSH connection&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ansible.builtin.meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;reset_connection&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Prove a fresh SSH login still works&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ansible.builtin.wait_for_connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;420&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Disarm rollback timer&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ansible.builtin.command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;systemctl stop lxc-uplink-rollback.timer&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Without &lt;code&gt;meta: reset_connection&lt;/code&gt;, Ansible reuses its open SSH connection and a broken change looks fine.&lt;/p&gt;&#10;&lt;p&gt;The change moved the NIC into a bridge, &lt;code&gt;br0&lt;/code&gt;. The bridge got the old address back, but forwarded SSH from the internet stopped reaching the host. (I never found out why, and it isn&amp;rsquo;t the point here.) Five minutes later the timer restored the old config, the host came back, and I moved on.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;Look at the rollback unit again. Its main process is &lt;code&gt;/bin/sh&lt;/code&gt;. &lt;code&gt;ifup&lt;/code&gt; starts &lt;code&gt;dhclient&lt;/code&gt;, which forks into the background, and then &lt;code&gt;sh&lt;/code&gt; exits. The unit is now done, and a finished service gets its cgroup cleaned up: by default (&lt;code&gt;KillMode=control-group&lt;/code&gt;), systemd kills every process still in it. That includes the &lt;code&gt;dhclient&lt;/code&gt; that had just been &amp;ldquo;bound&amp;rdquo;.&lt;/p&gt;&#10;&lt;p&gt;A killed &lt;code&gt;dhclient&lt;/code&gt; doesn&amp;rsquo;t release the lease, so the address stays on the interface with a lifetime of 86400 seconds. Nothing renews it. When the lifetime runs out, the kernel removes the address, and avahi is the first to notice:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Oct 17 21:08:42 avahi-daemon[936]: Withdrawing address record for 192.168.0.2 on enp0s25.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The clue was in the journal on day one. When the rollback started, its &lt;code&gt;dhclient&lt;/code&gt; logged:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;dhclient[1796009]: Removed stale PID file&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That stale PID file belonged to the &lt;code&gt;dhclient&lt;/code&gt; on &lt;code&gt;br0&lt;/code&gt;. Ansible had started it five minutes earlier from another &lt;code&gt;systemd-run&lt;/code&gt; unit, and systemd had killed it the same way, but the rollback replaced it before anyone noticed.&lt;/p&gt;&#10;&lt;p&gt;At boot, &lt;code&gt;ifup@enp0s25.service&lt;/code&gt; starts the same &lt;code&gt;dhclient&lt;/code&gt;. That unit stays &lt;code&gt;active (exited)&lt;/code&gt;, so its cgroup is never cleaned up, which is how the boot-time &lt;code&gt;dhclient&lt;/code&gt; had kept renewing for about six weeks.&lt;/p&gt;&#10;&lt;p&gt;You can see the behavior in two lines:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo systemd-run --unit&lt;span class="o"&gt;=&lt;/span&gt;demo /bin/sh -c &lt;span class="s1"&gt;&amp;#39;sleep 600 &amp;amp;&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sleep 1&lt;span class="p"&gt;;&lt;/span&gt; pgrep -a sleep &lt;span class="c1"&gt;# nothing: systemd killed it when sh exited&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;To get back in, I went through a VM that had its own macvlan leg on the same NIC and ran &lt;code&gt;sudo dhclient enp0s25&lt;/code&gt;. With &lt;code&gt;ssh -J&lt;/code&gt;, &lt;code&gt;-i&lt;/code&gt; applies only to the last hop, so give the jump host its key through &lt;code&gt;ProxyCommand&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh -i ~/.ssh/key -o &lt;span class="nv"&gt;IdentitiesOnly&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;yes &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -o &lt;span class="nv"&gt;ProxyCommand&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;ssh -i ~/.ssh/key -o IdentitiesOnly=yes -W %h:%p user@jump.example.com&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; user@target.example.com&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The real fix was to stop depending on a long-running DHCP client. The router reserves the address for the host anyway, so the host now has a static stanza. Ansible applies it without restarting networking, because that would cut off the run and the only way in:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ip address replace 192.168.0.2/24 dev enp0s25 valid_lft forever preferred_lft forever&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ip route replace default via 192.168.0.1 dev enp0s25&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pkill --signal KILL -f &lt;span class="s1"&gt;&amp;#39;(^|/)dhclient .*enp0s25$&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;SIGKILL&lt;/code&gt; is deliberate: a &lt;code&gt;dhclient&lt;/code&gt; that stops cleanly removes the address it holds, the one this very run is connected through.&lt;/p&gt;&#10;&lt;p&gt;If the host has to stay on DHCP, keep daemons out of transient units. Start the daemon through the unit that owns it at boot (here &lt;code&gt;ifup@enp0s25.service&lt;/code&gt;), or let the transient unit leave its children alone with &lt;code&gt;systemd-run -p KillMode=process&lt;/code&gt;. I didn&amp;rsquo;t retry the bridge, so I haven&amp;rsquo;t tested either variant in this rollback.&lt;/p&gt;&#10;&lt;p&gt;To check which unit a daemon really belongs to:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cat /proc/&lt;span class="k"&gt;$(&lt;/span&gt;pgrep -o dhclient&lt;span class="k"&gt;)&lt;/span&gt;/cgroup&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;Deactivated successfully&lt;/code&gt; only says the unit finished; whatever it started may have died with it. A killed DHCP client fails a whole lease later, long after you have stopped looking.&lt;/p&gt;&#10;&lt;p&gt;Search your playbooks and scripts for &lt;code&gt;systemd-run&lt;/code&gt; calls that run &lt;code&gt;ifup&lt;/code&gt;, &lt;code&gt;dhclient&lt;/code&gt; or anything else that forks into the background. Then check which cgroup that process ends up in.&lt;/p&gt;&#10;</description></item><item><title>"Ansible requires blocking IO on stdin/stdout/stderr" when an AI agent runs Ansible</title><link>https://eghuro.cz/posts/ansible-requires-blocking-io-agent-shell/</link><pubDate>Tue, 13 Oct 2026 22:34:00 +0200</pubDate><guid>https://eghuro.cz/posts/ansible-requires-blocking-io-agent-shell/</guid><description>&lt;p&gt;The first time my coding agent ran Ansible, every command failed before Ansible had read a single file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ERROR: Ansible requires blocking IO on stdin/stdout/stderr. Non-blocking file handles detected: &amp;lt;stdout&amp;gt;, &amp;lt;stderr&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;--syntax-check&lt;/code&gt; too, and even &lt;code&gt;ansible --version&lt;/code&gt;. If you see this from an agent, a CI job, or a wrapper script, Ansible isn&amp;rsquo;t broken. The process that started it handed it non-blocking file handles, and a few shell redirections replace them.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;ansible-core 2.20 (Ansible 13.3.0 from Homebrew) on macOS&lt;/li&gt;&#10;&lt;li&gt;A coding agent (Claude Code in VS Code) that runs commands in its own shell and captures their output&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The check has been in every Ansible CLI entry point since ansible-core 2.14 (2022).&lt;/p&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;O_NONBLOCK is a flag on the open file, not on the process, so every child that inherits the file descriptor inherits the flag too. The agent&amp;rsquo;s harness connects the shell&amp;rsquo;s stdout and stderr to non-blocking pipes, and the shell passes them on unchanged to &lt;code&gt;ansible-playbook&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;At startup, Ansible calls &lt;code&gt;os.get_blocking()&lt;/code&gt; on each of the three handles and exits if any of them is non-blocking. The list at the end of the message names the handles that failed. That makes it a good diagnostic: the first failure in my session listed only &lt;code&gt;&amp;lt;stderr&amp;gt;&lt;/code&gt;, because the command was &lt;code&gt;ansible --version | head -3&lt;/code&gt;. The pipe to &lt;code&gt;head&lt;/code&gt; had already given stdout a fresh, blocking handle, while stderr still pointed at the agent&amp;rsquo;s pipe.&lt;/p&gt;&#10;&lt;p&gt;Run this in the shell you suspect to see which handles are affected:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 -c &lt;span class="s1"&gt;&amp;#39;import os; print([os.get_blocking(fd) for fd in (0, 1, 2)])&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;False&lt;/code&gt; in any position means Ansible will refuse to start there.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Give Ansible three handles the shell creates itself:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ansible-playbook site.yml &amp;lt;/dev/null 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; cat&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;| cat&lt;/code&gt; gives stdout a new pipe, which is blocking.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt; sends stderr into that same pipe. &lt;code&gt;| cat&lt;/code&gt; alone fixes only stdout.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;&amp;lt;/dev/null&lt;/code&gt; replaces stdin. My agent&amp;rsquo;s stdin was never flagged, but Ansible checks it too, and an agent can&amp;rsquo;t answer a prompt anyway.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The pipe has a side effect: the command&amp;rsquo;s exit status is now &lt;code&gt;cat&lt;/code&gt;&amp;rsquo;s, which is 0. A failed playbook looks like a success unless you turn on &lt;code&gt;pipefail&lt;/code&gt; (bash and zsh both have it):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -o pipefail&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ansible-playbook site.yml &amp;lt;/dev/null 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; cat&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;To check the fix without touching your real shell, you can make all three handles non-blocking for a single command and try it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;NB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;import os,sys; [os.set_blocking(fd, False) for fd in (0,1,2)]; os.execvp(sys.argv[1], sys.argv[1:])&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;python3 -c &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$NB&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; ansible --version&lt;span class="o"&gt;)&lt;/span&gt; 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &amp;lt;/dev/zero &lt;span class="p"&gt;|&lt;/span&gt; cat&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ERROR: ... Non-blocking file handles detected: &amp;lt;stdin&amp;gt;, &amp;lt;stdout&amp;gt;, &amp;lt;stderr&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;python3 -c &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$NB&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; sh -c &lt;span class="s1"&gt;&amp;#39;ansible --version &amp;lt;/dev/null 2&amp;gt;&amp;amp;1 | cat&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &amp;lt;/dev/zero &lt;span class="p"&gt;|&lt;/span&gt; cat&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ansible [core 2.20.1] ...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The outer &lt;code&gt;| cat&lt;/code&gt; and &lt;code&gt;&amp;lt;/dev/zero&lt;/code&gt; keep the flag on throwaway handles, so your terminal stays blocking.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-ansible-insists"&gt;Why Ansible insists &lt;/h2&gt;&#10;&lt;p&gt;The &lt;a&#10; href="https://github.com/ansible/ansible/pull/77639"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;pull request that added the check&lt;/a&gt;&#10; explains it. On a non-blocking stdout, a large write can fail partway with &lt;code&gt;[Errno 11] write could not complete without blocking&lt;/code&gt;, and Ansible&amp;rsquo;s output gets cut off, or the run dies with an &amp;ldquo;Unexpected Exception&amp;rdquo;. The check turns those intermittent failures into one clear error at startup. The PR names another way to end up there: &lt;code&gt;ssh&lt;/code&gt; switches its handles to non-blocking while it runs and doesn&amp;rsquo;t always switch them back when it exits.&lt;/p&gt;&#10;&lt;p&gt;So the same error can turn up wherever a parent process hands down non-blocking pipes, CI runners included, or in a terminal after an &lt;code&gt;ssh&lt;/code&gt; that didn&amp;rsquo;t clean up.&lt;/p&gt;&#10;&#10;&lt;h2 id="make-the-agent-remember-it"&gt;Make the agent remember it &lt;/h2&gt;&#10;&lt;p&gt;The agent worked this out within two minutes, and later that night it wrote the fix into the project&amp;rsquo;s skill for provisioning VMs:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Run Ansible from Claude Code with &lt;span class="sb"&gt;`&amp;lt;/dev/null`&lt;/span&gt; and pipe its output through &lt;span class="sb"&gt;`cat`&lt;/span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The next morning, a session about a different service ran &lt;code&gt;ansible-playbook --syntax-check&lt;/code&gt; without the redirections and hit the same error. It never loaded the VM skill, so it never saw the line. A rule about how to run a tool belongs in the file the agent reads in every session, not in the instructions for one task.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;&amp;ldquo;Ansible requires blocking IO&amp;rdquo; means the process that started Ansible handed it non-blocking file handles. &lt;code&gt;&amp;lt;/dev/null 2&amp;gt;&amp;amp;1 | cat&lt;/code&gt; gives it blocking ones, and &lt;code&gt;set -o pipefail&lt;/code&gt; keeps a failed run from looking like a success.&lt;/p&gt;&#10;&lt;p&gt;If your agent runs Ansible, put that line in the instructions file it reads every session (&lt;code&gt;CLAUDE.md&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt;).&lt;/p&gt;&#10;</description></item><item><title>unattended-upgrades on Debian that installs nothing: Ubuntu-style Allowed-Origins</title><link>https://eghuro.cz/posts/unattended-upgrades-debian-allowed-origins/</link><pubDate>Sat, 26 Sep 2026 19:35:00 +0200</pubDate><guid>https://eghuro.cz/posts/unattended-upgrades-debian-allowed-origins/</guid><description>&lt;p&gt;A Debian 13 server had automatic security updates. Ansible had installed unattended-upgrades, enabled it in &lt;code&gt;20auto-upgrades&lt;/code&gt;, and &lt;code&gt;apt-daily-upgrade.timer&lt;/code&gt; had run that morning. Then a routine check:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ apt list --upgradable 2&amp;gt;/dev/null | grep -c security&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;61&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Nothing had failed, and nothing had warned. unattended-upgrades had never found anything it was allowed to install.&lt;/p&gt;&#10;&lt;p&gt;If your &lt;code&gt;50unattended-upgrades&lt;/code&gt; on Debian has &lt;code&gt;Allowed-Origins&lt;/code&gt; with &lt;code&gt;${distro_codename}-security&lt;/code&gt;, yours does the same.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-config"&gt;The config &lt;/h2&gt;&#10;&lt;p&gt;This is what the Ansible role wrote to &lt;code&gt;/etc/apt/apt.conf.d/50unattended-upgrades&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Unattended-Upgrade::Allowed-Origins {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;${distro_id}:${distro_codename}&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;${distro_id}:${distro_codename}-security&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;${distro_id}ESMApps:${distro_codename}-apps-security&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;${distro_id}ESM:${distro_codename}-infra-security&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;};&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The ESM lines give it away: Extended Security Maintenance is Ubuntu Pro. This is Ubuntu&amp;rsquo;s stock file, copied into a Debian role by hand or by an LLM. On Debian it expands to &amp;ldquo;Debian:trixie&amp;rdquo; and &amp;ldquo;Debian:trixie-security&amp;rdquo;, which looks right and matches nothing.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-it-matches-nothing"&gt;Why it matches nothing &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;Allowed-Origins&lt;/code&gt; is the legacy syntax. unattended-upgrades (2.12 on Debian 13) converts each entry to the newer pattern format in &lt;code&gt;get_allowed_origins_legacy()&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;allowed_origins&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;o=&lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt;,a=&lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;substitute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distro_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;substitute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distro_codename&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The part after the colon becomes &lt;code&gt;a=&lt;/code&gt;, and the matcher compares &lt;code&gt;a&lt;/code&gt; with the archive, which is the &lt;code&gt;Suite:&lt;/code&gt; field of the Release file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;what&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;a&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;suite&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;archive&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fnmatch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fnmatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On Ubuntu, suite and codename are the same string:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;# security.ubuntu.com/ubuntu/dists/noble-security/InRelease&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Suite: noble-security&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Codename: noble&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On Debian, they are not:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;# security.debian.org/debian-security/dists/trixie-security/InRelease&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Origin: Debian&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Label: Debian-Security&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Suite: stable-security&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Codename: trixie-security&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;apt-cache policy&lt;/code&gt; shows both, as &lt;code&gt;a=&lt;/code&gt; and &lt;code&gt;n=&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;release v=13,o=Debian,a=stable-security,n=trixie-security,l=Debian-Security,c=main,b=amd64&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So &lt;code&gt;a=trixie-security&lt;/code&gt; matches nothing, and neither does &lt;code&gt;a=trixie&lt;/code&gt;, because the main archive&amp;rsquo;s suite is &lt;code&gt;stable&lt;/code&gt;. Run through unattended-upgrades&amp;rsquo; own matcher against a trixie host&amp;rsquo;s package lists, the legacy entries come up empty, and Debian&amp;rsquo;s patterns each hit their archive:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;#39;o=Debian,a=trixie&amp;#39; -&amp;gt; []&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;#39;o=Debian,a=trixie-security&amp;#39; -&amp;gt; []&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;#39;origin=Debian,codename=${distro_codename},label=Debian&amp;#39; -&amp;gt; [&amp;#39;stable/trixie&amp;#39;]&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;#39;origin=Debian,codename=${distro_codename}-security,label=Debian-Security&amp;#39; -&amp;gt; [&amp;#39;stable-security/trixie-security&amp;#39;]&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With no allowed origin, there is nothing to upgrade, and that&amp;rsquo;s not an error. Each run logs &lt;code&gt;No packages found that can be upgraded unattended and no pending auto-removals&lt;/code&gt; and exits cleanly.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;Use &lt;code&gt;Origins-Pattern&lt;/code&gt; with &lt;code&gt;codename=&lt;/code&gt;, as Debian&amp;rsquo;s own file does:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Unattended-Upgrade::Origins-Pattern {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;origin=Debian,codename=${distro_codename},label=Debian&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;origin=Debian,codename=${distro_codename}-updates,label=Debian&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;origin=Debian,codename=${distro_codename},label=Debian-Security&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;origin=Debian,codename=${distro_codename}-security,label=Debian-Security&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;};&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Debian&amp;rsquo;s stock file has the &lt;code&gt;-updates&lt;/code&gt; line commented out. Keep it or drop it as you like.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;a=stable-security&lt;/code&gt; would match too, but don&amp;rsquo;t use it. On release day, &lt;code&gt;stable&lt;/code&gt; moves to the next Debian release, and a suite-based pattern would follow it. A codename stays with the release you installed.&lt;/p&gt;&#10;&lt;p&gt;Then check what unattended-upgrades actually picks:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo unattended-upgrade --dry-run -d&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;Allowed origins are:&lt;/code&gt; line shows the patterns after conversion. Below it, the security packages from &lt;code&gt;apt list --upgradable&lt;/code&gt; should show up as upgrade candidates. If the run ends with &amp;ldquo;No packages found&amp;rdquo; while &lt;code&gt;apt list --upgradable&lt;/code&gt; lists &lt;code&gt;stable-security&lt;/code&gt; packages, the origins are still wrong.&lt;/p&gt;&#10;&lt;p&gt;With &lt;code&gt;Automatic-Reboot &amp;quot;false&amp;quot;&lt;/code&gt;, a new kernel or libc still waits for you once updates flow again, so check for &lt;code&gt;/var/run/reboot-required&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;On Debian, &lt;code&gt;Allowed-Origins &amp;quot;${distro_id}:${distro_codename}-security&amp;quot;&lt;/code&gt; is a no-op that looks like a security policy, because it matches the suite (&lt;code&gt;stable-security&lt;/code&gt;), not the codename. Use &lt;code&gt;Origins-Pattern&lt;/code&gt; with &lt;code&gt;codename=&lt;/code&gt;, and confirm it with &lt;code&gt;--dry-run -d&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Run &lt;code&gt;grep -rn Allowed-Origins /etc/apt/apt.conf.d/&lt;/code&gt; on your Debian hosts, and run the same grep over your configuration management repos, where the copy is most likely to live.&lt;/p&gt;&#10;</description></item></channel></rss>