<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[A Python library for configuring and deploying servers]]></title><description><![CDATA[A Python library for configuring and deploying servers]]></description><link>https://reemote.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 13 Nov 2026 22:12:09 GMT</lastBuildDate><atom:link href="https://reemote.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Transpiling Ansible to Python: it's all about the library]]></title><description><![CDATA[Ask an AI to translate an Ansible role into Python and you get back a pile of subprocess and paramiko calls that reinvents configuration management…]]></description><link>https://reemote.hashnode.dev/transpiling-ansible-to-python-it-s-all-about-the-library</link><guid isPermaLink="true">https://reemote.hashnode.dev/transpiling-ansible-to-python-it-s-all-about-the-library</guid><category><![CDATA[Python]]></category><category><![CDATA[ansible]]></category><dc:creator><![CDATA[kimjarvis]]></dc:creator><pubDate>Fri, 13 Nov 2026 19:56:47 GMT</pubDate><content:encoded><![CDATA[<h1>Transpiling Ansible to Python: it's all about the library</h1>
<p><em>Ask an AI to translate an Ansible role into Python and you get back a pile of</em> <code>subprocess</code> <em>and</em> <code>paramiko</code> <em>calls that reinvents configuration management — badly, and differently every time. The model is not the bottleneck. The bottleneck is that, until now, there was no Python library shaped like Ansible for the model to translate</em> <em><strong>into</strong></em><em>. Reemote is that library. Once it is the target, which AI you use barely matters.</em></p>
<h2>Paste a role, get spaghetti</h2>
<p>Try it. Take an ordinary Ansible role — install a package, drop a config file, enable a service, open a firewall port — and ask a capable model to "rewrite this in Python."</p>
<p>You will get something that runs. It will <code>import subprocess</code>. It will shell out to <code>apt-get install -y</code>, check the return code, then <code>systemctl enable</code>, then maybe wrap the lot in a <code>try/except</code>. There will be no idempotence, or a hand-rolled version of it that greps <code>dpkg -l</code>. There will be no concurrency. The config file gets written with <code>open(..., "w")</code> on your laptop and pushed over with <code>scp</code> in a way that only works on the machine you tested it on.</p>
<p>Ask again tomorrow, or ask a different model, and you get a <em>different</em> pile of spaghetti. Neither is wrong the way a compiler is wrong; both are just ad hoc. Now spend more tokens — a longer prompt, a bigger context window, a reasoning model. The output gets longer and better commented. It does not get more <em>consistent</em>, and it does not converge on an architecture. That is the tell, and it is the whole subject of this article.</p>
<h2>AI is a pattern matcher, and it needs a pattern</h2>
<p>The "it's just autocomplete" dismissal of large language models is right for the wrong reasons. A model generates by matching patterns it has seen. Translation tasks go well when there is a <strong>target pattern</strong> to match against — a destination idiom that appears often enough in the training data that the model can lean on it instead of inventing one.</p>
<p>Compile C to assembly and the model has millions of examples of what good assembly looks like. Convert a JSON blob to a Python dataclass and the shape is obvious. But "translate an Ansible role to Python" has no canonical target. There is no widely-repeated idiom for <em>configuration management in Python</em>, because — as the next section establishes — nobody shipped the library that would have created one. So the model falls back to the nearest patterns it does have: <code>subprocess</code>, <code>os.system</code>, <code>paramiko</code>, Fabric-style SSH one-liners. Every generation re-derives a different arrangement of those primitives, because there is nothing to converge toward.</p>
<p>No amount of token spend fixes a missing pattern. You cannot prompt a model into an idiom that does not exist in the world yet.</p>
<h2>There is no transpiler — and no framework to transpile into</h2>
<p>It is worth being precise about the landscape, because "just use AI" sits on top of a real gap.</p>
<p>There is <strong>no official, automated transpiler</strong> that turns a YAML playbook into native Python. That is not for lack of trying; it is structural. Ansible playbooks are declarative orchestrations leaning on a state engine, an SSH/WinRM abstraction, dynamic variable handling, and an idempotence model. Python is procedural. The two do not line up statement-for-statement, so there is nothing to "compile."</p>
<p>What does exist falls into three buckets, and none of them is a target you would want an AI to translate <em>into</em>:</p>
<ul>
<li><strong>Programmatic control of Ansible itself</strong> — <code>ansible-runner</code>, or Ansible's internal Python API (<code>ansible.playbook.Play</code>, <code>DataLoader</code>). These let a Python program <em>launch</em> playbooks and consume their events. They do not get you off Ansible; they wrap it. The YAML, the state engine, and the CLI-shaped result all remain.</li>
<li><strong>General Python automation frameworks</strong> — Fabric, or Pulumi's Python SDK. Fabric is SSH command execution: it hands you <code>run("apt-get install ...")</code>, which is exactly the low-level primitive the model was already reaching for, with no facts, no operations, no idempotence. Pulumi is a different paradigm again — cloud resource provisioning, not host configuration management.</li>
<li><strong>Raw building blocks</strong> — <code>paramiko</code> for SSH, <code>requests</code> for APIs, <code>subprocess</code> for everything else. This is the layer the model invents on top of when you give it nothing better.</li>
</ul>
<p>Notice what all three lack: a <strong>configuration-management library</strong> with the same conceptual shape as Ansible — modules, facts, operations, idempotence — that you can <code>import</code> and call from Python. That library is the missing pattern. Until it exists, "transpile Ansible to Python" has no destination, and the AI has nothing to aim at.</p>
<h2>The library matters more than the model</h2>
<p>Here is the whole argument, stated plainly: <strong>for Ansible-to-Python migration, the choice of Python library dominates the choice of AI.</strong></p>
<p>Give a model nothing and it invents an architecture every time. Give it a rich, consistent, well-documented library whose vocabulary lines up one-to-one with Ansible's, and the task collapses from "design a configuration-management system" to "map each task to the operation that already does this." That second task is exactly what models are good at, and it is bounded enough that a human can <em>review</em> the output.</p>
<p>The best current choice for that library is Reemote — not because it is magic, but because it is the thing that was missing: configuration management as a Python library, with the same conceptual model as Ansible, so there is finally an idiom for the model to converge on.</p>
<h2>What Reemote is</h2>
<p>Reemote is a Python package (<code>pip install reemote</code>) that does configuration management as a <strong>library</strong> rather than a CLI. There is no <code>reemote</code> binary. You write a deploy — a plain Python generator — and <code>await</code> it:</p>
<pre><code class="language-python">plans, report, hosts = await run(inventory, deploy)
</code></pre>
<p>Four words cover the model:</p>
<ul>
<li><strong>Inventory</strong> — a list of dicts naming your targets and how to reach them (over SSH, or through a local docker connector).</li>
<li><strong>Fact</strong> — a read of remote state: the hostname, the installed packages, a file's contents, a service's status. Facts are typed, sorted, JSON-printable data structures, never scraped strings.</li>
<li><strong>Operation</strong> — a request to converge the server toward a described state. An operation reads facts, decides whether anything must actually change, and emits commands <em>only</em> when it must. That is idempotence, built into every operation rather than bolted on.</li>
<li><strong>Action</strong> — an immediate, non-deploy escape hatch (<code>Shell</code>, <code>Script</code>, <code>WaitForConnection</code>) for one-off commands.</li>
</ul>
<p>A deploy yields operation requests in order:</p>
<pre><code class="language-python">def deploy(host):
    yield op(apt.packages, name="Install nginx", packages=("nginx",), sudo=True)
    yield op(files.install, name="Write landing page",
             content=PAGE, dest="/var/www/html/index.html", mode="0644", sudo=True)
    yield op(systemd.service, name="Run nginx",
             service="nginx", running=True, enabled=True, sudo=True)
</code></pre>
<p>That is the same three-step shape as the role at the top of this article — package, file, service — expressed as ordinary Python you can import, loop over, and unit-test.</p>
<p>The runner executes every deploy <strong>twice</strong>: once to plan against live facts, once to apply. Both passes must yield the identical request sequence or it raises a <code>DeterminismError</code>. <code>dry=True</code> stops after the plan and gives you a real preview against live state — Ansible's <code>--check</code>, but as a return value you can assert on instead of a wall of coloured diff output.</p>
<h2>Why Reemote is the right transpilation target</h2>
<p>The properties that make Reemote a good library are the same ones that make it a good <em>destination</em> for an AI migration. Here they are, each tied to what it removes from the translation problem.</p>
<h3>Async end to end, on asyncssh</h3>
<p>The transport is <a href="https://asyncssh.readthedocs.io/">asyncssh</a>, and the entire execution path — connecting, gathering facts, sending commands, closing — is coroutines. You <code>await</code> a deploy and hosts progress in parallel; network waits overlap across hosts while each host's commands stay in order. Your deploy script remains a plain synchronous generator, so you never write <code>async</code> inside an operation.</p>
<p>For a migration this matters because the model does not have to <em>invent</em> concurrency. Ansible gave you parallelism through <code>forks</code>; a naive Python translation loses it and loops over hosts one at a time. In Reemote the parallelism is a property of the runtime, not of the generated code — the AI produces the same simple generator either way, and the framework does the hard part.</p>
<h3>A library returning structured results, not a CLI</h3>
<p>Ansible is a CLI: the result of a run is terminal output, and if your program needs to know what changed you regex-scrape stdout and hope. <code>run(...)</code> instead returns <code>(plans, report, hosts)</code>:</p>
<ul>
<li><code>plans</code> — <code>{host_label: [PlannedOperation, ...]}</code>, the proposed work per host.</li>
<li><code>report</code> — an <code>ApplyReport</code> with one <code>LogEntry</code> per applied operation, each carrying <code>changed</code>, <code>reason</code>, and the exact commands that were sent. <code>None</code> when <code>dry=True</code>.</li>
<li><code>hosts</code> — the materialised host objects.</li>
</ul>
<p>This is a target a model can hit reliably, because "read <code>report.entries[i].changed</code>" is an ordinary data-access pattern it has seen a million times — versus "parse Ansible's callback output," which it has not, and which changes between versions. Handlers become plain Python: read the first run's <code>report</code>, freeze the hosts that changed, and feed that set into a second deploy that restarts the service. No <code>notify</code> keyword, no handler-ordering rules to memorise.</p>
<h3>Comprehensive modules — a one-to-one vocabulary with Ansible</h3>
<p>This is the single biggest reason Reemote translates well. A model can only map <code>apt_repository</code> onto something if that something exists. Reemote ships twenty modules, and each one implements the <em>whole</em> surface of its domain as operations and facts — not just the common verbs.</p>
<p>Take <code>apt</code>. Ansible spreads package management across <code>apt</code>, <code>apt_key</code>, <code>apt_repository</code>, <code>apt_mark</code>, and a dozen parameters. Reemote's <code>apt</code> module covers the same ground with matching operations: <code>packages</code> (install/upgrade/remove by name, version-pinnable), <code>repo</code> and <code>sources_file</code> (legacy <code>.list</code> and modern deb822 sources), <code>ppa</code>, <code>key</code> (GPG keyrings), <code>deb</code> (a standalone <code>.deb</code>), <code>mark</code> (hold/unhold), <code>update</code>, <code>upgrade</code>, <code>dist_upgrade</code>, and <code>auto_remove</code>. Every operation reads a matching fact — what is installed, what is held, which sources are configured, which keys are trusted, what an upgrade would move, what an autoremove would clean — so nothing is faked with a shell one-liner and the read side is as complete as the write side.</p>
<p>That completeness is what makes translation near-mechanical:</p>
<table>
<thead>
<tr>
<th>Ansible</th>
<th>Reemote</th>
</tr>
</thead>
<tbody><tr>
<td><code>apt</code> / <code>package</code></td>
<td><code>apt.packages</code> / <code>dnf.packages</code> / <code>apk.packages</code></td>
</tr>
<tr>
<td><code>apt_key</code></td>
<td><code>apt.key</code></td>
</tr>
<tr>
<td><code>apt_repository</code></td>
<td><code>apt.repo</code>, <code>apt.sources_file</code>, <code>apt.ppa</code></td>
</tr>
<tr>
<td><code>apt</code> with <code>update_cache</code></td>
<td><code>apt.update</code></td>
</tr>
<tr>
<td>hold / <code>apt_mark</code></td>
<td><code>apt.mark</code></td>
</tr>
<tr>
<td><code>copy</code> (inline content) / <code>template</code></td>
<td><code>files.install</code> (pre-rendered content)</td>
</tr>
<tr>
<td><code>lineinfile</code> / <code>blockinfile</code></td>
<td><code>files.edit</code> / <code>files.transform</code></td>
</tr>
<tr>
<td><code>file</code> (mode/owner/state)</td>
<td><code>files.file</code>, <code>files.directory</code>, <code>files.link</code></td>
</tr>
<tr>
<td><code>get_url</code></td>
<td><code>files.download</code></td>
</tr>
<tr>
<td><code>stat</code></td>
<td>the file metadata, existence, and hash facts</td>
</tr>
<tr>
<td><code>service</code> / <code>systemd</code></td>
<td><code>systemd.service</code></td>
</tr>
<tr>
<td><code>notify</code> + handler</td>
<td>two deploys + <code>changed_hosts</code> / <code>restart_handler</code></td>
</tr>
</tbody></table>
<p>The twenty modules span package managers (<code>apt</code>, <code>dnf</code>, <code>apk</code>, <code>snap</code>, <code>flatpak</code>), files and version control (<code>files</code>, <code>git</code>), services (<code>systemd</code>, <code>openrc</code>), firewalls (<code>firewalld</code>, <code>iptables</code>, <code>ufw</code>), system configuration (<code>server</code>, <code>sysctl</code>, <code>selinux</code>, <code>hardware</code>), keys (<code>gpg</code>), databases (<code>mysql</code>, <code>postgresql</code>), and containers (<code>lxd</code>). When the destination vocabulary is this complete, the model spends its effort choosing the right operation instead of reimplementing a missing one.</p>
<h3>Pure functional deploys</h3>
<p>Every request argument is hashable, a deploy is a pure function of its host, and the runner executes it twice — plan then apply — under a determinism guard that raises if the two passes disagree. There is no hidden mutable state, no reliance on the order a <code>for</code> loop happened to run in, no "did this already change?" flag threaded through globals.</p>
<p>For AI-generated code this is a safety net. Model output is plausible but unreviewed; a pure, deterministic deploy is <em>testable</em>. You can call the generator, inspect the requests it yields, and assert on them with <code>pytest</code> — no server required. And the determinism guard catches the classic model mistakes (a <code>datetime.now()</code>, a <code>random</code>, a lambda built inline) at plan time rather than in production. The framework enforces the discipline the generated code might otherwise lack.</p>
<h3>No awareness of the controller file system</h3>
<p>Ansible couples a playbook to the controller's disk. <code>copy: src=app.conf</code>, <code>template: src=config.j2</code>, and <code>script: src=deploy.sh</code> all resolve a path <em>relative to the role's conventional directories</em> (<code>files/</code>, <code>templates/</code>) through a search-path rule. The task's meaning depends on where files happen to live on the machine running Ansible — an implicit, invisible dependency the model has to guess at and reproduce.</p>
<p>Reemote has no such coupling. A deploy never takes a controller path:</p>
<ul>
<li><code>files.install</code> takes <code>content</code> as a plain string — the text is right there in the code.</li>
<li><code>files.put</code> and <code>files.get</code> take an <strong>IO-like stream</strong> you opened yourself; a path string is deliberately <em>not</em> accepted.</li>
<li><code>files.copy</code> and <code>files.move</code> are remote-to-remote — <code>src</code> is a path on the target, not on the controller.</li>
</ul>
<p>The result is that a Reemote deploy is self-contained and pure: given the same host and the same arguments it produces the same requests, with nothing implicit read off the controller's layout. For a transpiler that removes an entire class of guesswork — the AI never has to replicate Ansible's role-directory search magic or invent a file layout. It renders the content (from a string, a Pydantic model, or a Jinja2 template pre-rendered control-side) and hands it to an operation.</p>
<h2>What this means for your migration</h2>
<p>Point the model at Reemote's module reference as context, feed it the role, and ask it to map each task to an operation. Because the vocabulary lines up, the output is bounded and reviewable: a generator of <code>op(...)</code> requests, not an architecture. You read it the way you would read any pull request — does this operation match that task, are the privilege flags right, is the content correct.</p>
<p>The constructs that do <em>not</em> map one-to-one — handlers, <code>host_vars</code>, Jinja2 templates, <code>lineinfile</code> edits, <code>get_url</code> downloads — each have a documented Reemote shape, and the migration guide works through them construct by construct with before/after pairs. That guide is the human companion to this argument: once you accept that the library is the right target, it shows you the exact mappings. See the <a href="../migrating.md">Ansible role migration guide</a>.</p>
<p>The model still matters, of course — a stronger model makes fewer mistakes. But it is a second-order effect. The first-order effect is whether there is a framework to translate into. Now there is.</p>
<h2>What Reemote is not</h2>
<p>Honest boundaries, because overselling helps nobody:</p>
<ul>
<li><strong>Not a deployment CLI.</strong> There is no <code>reemote</code> command. You write the script.</li>
<li><strong>Not an inventory format.</strong> Inventory is a Python list of dicts — load it from JSON, YAML, a database, or write it inline.</li>
<li><strong>Not a role ecosystem.</strong> Twenty modules ship in the box, but there is no Ansible Galaxy equivalent to pull third-party roles from.</li>
<li><strong>Not an OS translation layer.</strong> Python does not make <code>nginx</code> installable by the same name on Alpine and Debian. You still pick the right module for the target — the AI still has to know <code>apk</code> from <code>apt</code>.</li>
<li><strong>Not an automatic transpiler.</strong> Reemote is the <em>target</em>, not the translator. The AI does the mapping and you review it. The library's job is to make that mapping bounded, idiomatic, and testable — which is precisely the part that was impossible before.</li>
</ul>
<h2>Try it</h2>
<ul>
<li>Documentation and full guide: <a href="https://reemote.org/">https://reemote.org/</a></li>
<li>Ansible role migration guide: <a href="https://reemote.org/migrating/">https://reemote.org/migrating/</a></li>
<li>Source: <a href="https://github.com/kimjarvis/reemote">https://github.com/kimjarvis/reemote</a></li>
<li>PyPI: <code>pip install reemote</code></li>
<li>License: EPL-2.0</li>
</ul>
<p>If you have tried to get an AI to translate an Ansible role and come away with unreviewable spaghetti, the fix was never a bigger prompt. It was a library worth translating into. Give Reemote a minute.</p>
]]></content:encoded></item><item><title><![CDATA[Deploying websites with Reemote — from a local container to production]]></title><description><![CDATA[Reemote turns Python code into shell commands and runs them on many machines over SSH, in parallel. Think Ansible, but there is no CLI and no YAML: reemote…]]></description><link>https://reemote.hashnode.dev/deploying-websites-with-reemote-from-a-local-container-to-production</link><guid isPermaLink="true">https://reemote.hashnode.dev/deploying-websites-with-reemote-from-a-local-container-to-production</guid><category><![CDATA[Python]]></category><category><![CDATA[ansible]]></category><dc:creator><![CDATA[kimjarvis]]></dc:creator><pubDate>Fri, 13 Nov 2026 16:08:11 GMT</pubDate><content:encoded><![CDATA[<p><em>Reemote turns Python code into shell commands and runs them on many machines over SSH, in parallel. Think Ansible, but there is no CLI and no YAML: reemote is a</em> <em><strong>library</strong></em> <em>you</em> <code>import</code><em>,</em> <code>await</code><em>, and test. This walkthrough deploys two websites — a landing page and a Python web app — first on a local target, then on two remote servers, using only Python.</em></p>
<p>We will stand up nginx, serve a static landing page, run a small Flask app behind it on a Unix socket, and finish with HTTPS from Let's Encrypt. Everything is ordinary Python: <code>for</code> loops, <code>if</code> statements, imports, types, and <code>pytest</code>.</p>
<p>This is a written walkthrough you can follow along at home. Every output block labelled <strong>verified</strong> was produced by running the code shown against a local <code>ubuntu:24.04</code> container through reemote's docker connector; the production-only steps (systemd, Certbot) are marked as such because a plain container cannot run them, and the article says so rather than implying otherwise.</p>
<h2>What is reemote?</h2>
<p>Reemote is a configuration-management library. You describe the state a machine should be in — packages installed, files present, services running — and reemote works out the shell commands that get it there, then runs them across your fleet concurrently.</p>
<p>A file full of those descriptions is a <strong>deploy</strong>. Here is the whole idea in a few lines:</p>
<pre><code class="language-python">from reemote import apt, files, op, server

MOTD = "Managed by reemote\n"

def deploy(host):
    for user in ("admin", "alice", "bob"):
        yield op(server.user, user=user)

    yield op(files.install, dest="/etc/motd", content=MOTD, sudo=True)

    if host.extra.get("install_postgres"):
        yield op(apt.packages, packages=("postgresql",), sudo=True)
</code></pre>
<p>A deploy is a <strong>generator</strong>: it <code>yield</code>s one <code>op(...)</code> request per action. <code>op</code> records the call — it does not run it. The runner drives the generator twice (once to plan, once to apply), which is why every argument must be hashable: <code>packages=("postgresql",)</code> is a tuple, not a list. <code>host.extra</code> is arbitrary per-host data from the inventory, so the <code>if</code> is just Python.</p>
<h2>Why use reemote?</h2>
<p>Reemote is <strong>declarative</strong> — you describe the target state, and reemote makes it so — and <strong>idempotent</strong> — nothing happens if the machine is already in that state. Those are the properties that make a deploy safe to re-run.</p>
<p>Unlike a YAML-driven tool, a deploy is Python code, so you get everything Python gives you:</p>
<ul>
<li><strong>Your editor knows your code.</strong> <code>apt.packages</code>, <code>files.install</code>, and <code>systemd.service</code> are functions: go-to-definition, autocomplete, rename, and a type check that catches a typo before it reaches a production box.</li>
<li><strong>No second templating language.</strong> There is no <code>{{ hostvars[...] }}</code> stringly-typed escape hatch. A value is a Python expression, evaluated by the interpreter you already trust, and config text is rendered control-side with plain Python before you hand it to <code>files.install</code>.</li>
<li><strong>Composition is</strong> <code>import</code><strong>.</strong> Roles are generator functions you call, loop over, and <code>yield from</code> — not a directory layout loaded by convention.</li>
<li><strong>The result is data, not stdout.</strong> <code>run(...)</code> returns structured objects; you read <code>report.log[i].changed</code> instead of regex-scraping a terminal.</li>
<li><strong>Testing is</strong> <code>pytest</code><strong>.</strong> A deploy is a pure generator; you can assert on the requests it yields without a server in sight.</li>
<li><code>await</code> <strong>is the concurrency model.</strong> Hosts progress in parallel because the transport is <a href="https://asyncssh.readthedocs.io/">asyncssh</a>, not because a CLI forked workers you have to configure.</li>
</ul>
<p>In fairness, reemote ships fewer built-in modules than a decade-old tool with a galaxy of roles. That is partly offset by how little code a custom operation takes — an async generator that reads a fact and yields a command, as the Certbot step later in this article shows.</p>
<h2>When to use reemote?</h2>
<p>Reemote's use cases follow from what it does: configure machines over SSH (or inside a container). It is a good fit for <strong>boring, cloud-agnostic architectures</strong>, from a fleet of servers with different roles all the way down to one-computer programming, where it still makes a setup reproducible and prevents drift.</p>
<p>It is easy enough to learn that it works well for self-hosting and home servers, and even when you do not want full configuration management, the <code>Shell</code> action gives you a convenient way to run ad-hoc commands across a fleet.</p>
<p>Simplicity has edges. There is no built-in pull mode, a fully automated pipeline is your own code around <code>await run(...)</code>, and two people deploying at once need coordination. Reemote does <strong>not</strong> provision cloud resources — that is Terraform's job; reemote configures the machines once they exist. Staying "commands over SSH" is a deliberate scope, not a gap.</p>
<h2>Install reemote</h2>
<p>Reemote runs on your <strong>controller</strong> — the machine running the Python — and needs <strong>Python 3.13 or newer</strong>. You never install it on the target servers; they only need SSH (or, for this walkthrough, a container runtime).</p>
<p>Install from a git checkout. That is the recommended route here because the clone also gives you the runnable <code>examples/</code> tree this article walks through:</p>
<pre><code class="language-bash">git clone https://github.com/kimjarvis/reemote.git
cd reemote
uv sync
source .venv/bin/activate
</code></pre>
<p><code>uv sync</code> creates <code>.venv</code>, installs reemote in editable mode and resolves its dependencies — <code>asyncssh</code>, <code>distro</code>, <code>pydantic</code> — so the SSH transport needs no separate step. <a href="https://docs.astral.sh/uv/">uv</a> is what we use; <code>venv</code> and <code>pip</code> work just as well. If you only want the library and not the examples, <code>uv pip install reemote</code> from PyPI is enough.</p>
<p>Every stage below has a complete, runnable script under <code>examples/tutorials/website/</code>, so you can read the article and run the code side by side:</p>
<table>
<thead>
<tr>
<th>Article section</th>
<th>Example</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td>Operations and facts · Where are the commands?</td>
<td><code>website_0.py</code></td>
<td>The two-phase model: read a fact, create a file</td>
</tr>
<tr>
<td>Inventory + nginx setup</td>
<td><code>website_1.py</code></td>
<td>A <code>Shell</code> action, then install and configure nginx</td>
</tr>
<tr>
<td>Static site + code reuse</td>
<td><code>website_2.py</code></td>
<td>Compose the base with a conditional landing-page site</td>
</tr>
<tr>
<td>Web app + secrets</td>
<td><code>website_3.py</code></td>
<td>Service accounts, a custom venv operation, rendered secrets</td>
</tr>
<tr>
<td>HTTPS + prod</td>
<td><code>website_4.py</code></td>
<td>A custom Certbot fact and operation, then the whole fleet</td>
</tr>
</tbody></table>
<p><code>website_0.py</code> is the smallest complete program — the "basic part" the rest build on. Run it, and the others, with <code>uv run</code> from the checkout root:</p>
<pre><code class="language-bash">uv run python examples/tutorials/website/website_0.py
</code></pre>
<p>The examples target a local container named <code>beta</code>, so start it once — the walkthrough uses <code>podman</code>, and <code>docker</code> works identically:</p>
<pre><code class="language-bash">podman run -d --name beta ubuntu:24.04 tail -f /dev/null
</code></pre>
<p>Pointing the docker connector at a <strong>container name</strong> rather than an image leaves it running between runs, so its state persists like a VM — which is what makes idempotence visible. <code>website_4.py</code> is the exception: it targets two production SSH hosts and Certbot, so it builds and dry-runs offline but is meant to be pointed at your own servers.</p>
<h2>The demo</h2>
<p>At the end we will have two websites — a landing page and a Python web app — deployed in two stages.</p>
<p>First a single local target, <code>beta</code>, running both behind one nginx:</p>
<pre><code class="language-text">.------- beta --------.- app.beta --------.
|                    nginx                 |
|    static website   :   Python web app   |
'------------------------------------------'
</code></pre>
<p>...then two separate production servers, one per site:</p>
<pre><code class="language-text">.-- web1 (prod) ---.  .-- web2 (prod) ----.
|      nginx       |  |      nginx        |
|  static website  |  |  Python web app   |
'------------------'  '-------------------'
</code></pre>
<p>Here is a sneak peek at the finished top-level deploy. It composes a shared nginx base with two conditional sub-deploys, then handles reloads as a second pass:</p>
<pre><code class="language-python">def deploy(host):
    yield from nginx_base(host)                       # apt + disable default site

    if host.extra.get("home_domain"):                 # static landing page
        yield from sub_deploy(home_site, name_prefix="home")(host)

    if host.extra.get("app_domain"):                  # Flask app behind gunicorn
        yield from sub_deploy(app_site, name_prefix="app")(host)
</code></pre>
<p>The <code>if</code>s read <code>host.extra</code> — static inventory data — so both phases of the run resolve them identically. That distinction (static data versus live facts) is the single most important rule in reemote, and the next two sections are about it.</p>
<h2>Operations and facts</h2>
<p>Two concepts do all the work.</p>
<h3>Operations change the machine</h3>
<p>An <strong>operation</strong> ensures some state: a file exists, a package is installed, a service is running. Most are declarative and idempotent. Here is a deploy with one operation that says "I want <code>/tmp/hello.txt</code> to exist":</p>
<pre><code class="language-python"># create_file.py
import asyncio
from reemote import files, format_log, format_plan, op, run

INVENTORY = [{
    "host": "beta",
    "connection": {"connector": "docker", "identifier": "beta", "docker_cmd": "podman"},
}]

def deploy(host):
    yield op(files.file, name="create hello.txt", path="/tmp/hello.txt", present=True)

async def main():
    plans, report, _ = await run(INVENTORY, deploy)
    print("== plan ==")
    print(format_plan(plans))
    if report is not None:
        print("== log ==")
        print(format_log(report))

asyncio.run(main())
</code></pre>
<p><code>beta</code> here is a local container, so the walkthrough needs no SSH key and no VM. Start it once:</p>
<pre><code class="language-bash">podman run -d --name beta ubuntu:24.04 tail -f /dev/null
</code></pre>
<p>Pointing the docker connector at a <strong>container name</strong> (rather than an image) leaves it running between runs, so state persists exactly like a VM. Run the script:</p>
<pre><code class="language-python">python create_file.py
</code></pre>
<p>Verified output — the plan says what will change, the log says what did and shows the command:</p>
<pre><code class="language-text">== plan ==
beta: create hello.txt         [changed: /tmp/hello.txt missing, will create]
== log ==
beta: create hello.txt         [changed: /tmp/hello.txt missing, will create]
    $ touch /tmp/hello.txt
</code></pre>
<p>Because <code>files.file</code> is idempotent, running the identical script again does nothing:</p>
<pre><code class="language-text">== plan ==
beta: create hello.txt         [no change: file /tmp/hello.txt already exists]
== log ==
beta: create hello.txt         [no change: file /tmp/hello.txt already exists]
</code></pre>
<p>There is no "press enter to execute" prompt — reemote is a library, not an interactive CLI. To look before you leap, pass <code>dry=True</code>: it runs the <strong>prepare</strong> phase only (facts are gathered for real, nothing mutates) and returns <code>report=None</code>. Preview with <code>dry=True</code>, then call <code>run</code> again without it to apply.</p>
<h3>Facts read the machine</h3>
<p>A <strong>fact</strong> gathers data about a target. Read one directly across hosts with <code>gather_fact</code>:</p>
<pre><code class="language-python">from reemote import Host, gather_fact, server

hosts = [Host.from_inventory(entry) for entry in INVENTORY]
names = await gather_fact(hosts, server.LinuxName)
</code></pre>
<p>Verified output:</p>
<pre><code class="language-text">gather_fact LinuxName -&gt; {'beta': 'Ubuntu'}
</code></pre>
<p>The natural next step — pick a package name from the distribution — is where reemote differs most from a tool that lets you branch on a fact mid-deploy. <strong>You cannot.</strong> A deploy runs twice (prepare, then apply); if its body branched on a <em>live fact</em>, the two passes could diverge, so the runner raises a <code>DeterminismError</code> rather than silently applying a plan it did not show you. Static inventory data (<code>host.name</code>, <code>host.group</code>, <code>host.tags</code>, <code>host.extra</code>) is identical in both phases and safe to branch on; facts are not.</p>
<p>So fact-driven choice moves <strong>out</strong> of the deploy, into a gather → snapshot → factory shape:</p>
<pre><code class="language-python">from reemote import apt, gather_fact, op, run, server

# 1. Gather the fact up front, across every host, and freeze it.
names = await gather_fact(hosts, server.LinuxName)

# 2. Build a per-host dispatch table from the snapshot (keyed by host name).
PACKAGE = {"Ubuntu": "apache2", "Debian": "apache2"}

def build_install(name):
    package = PACKAGE.get(name, "httpd")
    def install(host):
        yield op(apt.packages, name="install apache", packages=(package,), sudo=True)
    return install

# 3. Run each host against a deploy chosen from its frozen value — never live.
for entry in INVENTORY:
    await run([entry], build_install(names[entry["host"]]))
</code></pre>
<p>The branch happens in ordinary Python <em>before</em> the deploy exists, so each deploy body is branch-free and deterministic. It is a little more code than an inline <code>if</code>, and in exchange the plan reemote shows you is a plan it will actually honour.</p>
<h2>Where are the commands?</h2>
<p>An operation is an <strong>async generator</strong> that takes an <code>OpContext</code>, reads facts to decide whether anything must change, and yields command descriptors only when it does. This is a simplified <code>files.file</code>:</p>
<pre><code class="language-python">from reemote import OpContext, StringCommand

async def file(ctx: OpContext, path: str, present: bool = True):
    exists = await ctx.facts.get(FileExists, path)

    if not exists and present:
        ctx.changed = True
        ctx.reason = f"{path} missing, will create"
        yield StringCommand(f"touch {path}")

    elif exists and not present:
        ctx.changed = True
        ctx.reason = f"{path} present, will remove"
        yield StringCommand(f"rm -f {path}")
</code></pre>
<p>The shape is the same for every operation: read state with a fact, diff it against the desired state, set <code>ctx.changed</code>/<code>ctx.reason</code>, and yield the commands that close the gap. Yield nothing and the operation reports "no change".</p>
<p>Here is a deploy that says "I want <code>/tmp/hello.txt</code> to <strong>not</strong> exist":</p>
<pre><code class="language-python">def deploy(host):
    yield op(files.file, name="delete hello.txt", path="/tmp/hello.txt", present=False)
</code></pre>
<p><code>format_log</code> renders the command descriptors that were actually sent, which is where the shell text finally appears. Verified output (against the container that still has the file from earlier):</p>
<pre><code class="language-text">== plan ==
beta: delete hello.txt         [changed: /tmp/hello.txt present, will remove]
== log ==
beta: delete hello.txt         [changed: /tmp/hello.txt present, will remove]
    $ rm -f /tmp/hello.txt
</code></pre>
<p>The <strong>plan</strong> comes from the prepare phase: reemote runs each operation to see what it <em>would</em> yield, gathering facts (read-only commands) but sending nothing that mutates. The <strong>log</strong> comes from the apply phase: the same operations run again, and this time their commands are sent. Apply re-creates the fact cache before every step, so a later operation sees the effect of an earlier one — install a package in step 1 and step 2's package fact reads the newly-installed set.</p>
<h2>Inventory + nginx setup</h2>
<p>So far the target has been a local container, but we want <code>beta</code> to behave like production. The inventory is just a Python list of dicts — no separate file format to learn. Each entry names a host, its connection, and any extra data:</p>
<pre><code class="language-python">BETA = [{
    "host": "beta",
    "connection": {"connector": "docker", "identifier": "beta", "docker_cmd": "podman"},
}]

# Production is the same shape over the default ssh connector — the
# "connector" key can be omitted, since ssh is the default:
PROD = [{
    "host": "web1",
    "connection": {"address": "web1.example.com", "username": "deploy",
                   "key_path": "~/.ssh/id_ed25519"},
    "home_domain": "www.example.com", "stage": "prod", "nginx_https": True,
}]
</code></pre>
<p>Because the inventory is Python, a group does not have to be a literal — build it from a database query or your cloud's SDK (<code>[i.hostname for i in ...]</code>). The keys a <code>Host</code> does not recognise (<code>home_domain</code>, <code>stage</code>, <code>nginx_https</code>) are preserved verbatim in <code>host.extra</code>, which is how per-host data reaches the deploy.</p>
<p>To run an arbitrary command across the fleet — the ad-hoc "what version is this?" move — use the <code>Shell</code> <strong>action</strong>. Actions run immediately, outside the deploy model, and hand you the result as data:</p>
<pre><code class="language-python">from reemote import Host, Shell, gather_action

hosts = [Host.from_inventory(e) for e in BETA]
results = await gather_action(hosts, Shell, command='. /etc/os-release &amp;&amp; echo "$PRETTY_NAME"')
</code></pre>
<p>Verified output:</p>
<pre><code class="language-text">Shell beta: exit=0 stdout='Ubuntu 24.04.5 LTS'
</code></pre>
<p>An action's exit status is data, not an exception — even 127 (command not found) comes back in <code>exit_status</code> so the caller decides what it means. Actions cannot run <em>inside</em> a deploy; <code>gather_action</code> raises the moment it is called from a generator.</p>
<h3>Point it at a real server</h3>
<p>The <code>beta</code> container needs no keys and no network, but the same deploys run against a real machine unchanged — only the inventory entry differs. To add a server you can SSH into, give its entry a <code>connection</code> with an <code>address</code>, a <code>username</code> and a <code>key_path</code> instead of the docker connector:</p>
<pre><code class="language-python">REAL = [{
    "host": "web1",                                  # any name you like
    "connection": {
        "address": "web1.example.com",               # or an IP
        "username": "deploy",                        # the login user
        "key_path": "~/.ssh/id_ed25519",             # its private key
    },
    # Any other keys land in host.extra and drive the conditional deploys:
    "home_domain": "www.example.com",
    "stage": "prod",
}]
</code></pre>
<p><code>connection</code> keeps asyncssh's own option names, so <code>port</code>, <code>password</code>, <code>passphrase</code>, <code>proxy_command</code> and the rest pass straight through; <code>address</code>, <code>username</code> and <code>key_path</code> are friendly aliases for the target, the login name and a single identity key. Add more dictionaries to the list to add more hosts — <code>run(REAL, deploy)</code> fans the same deploy across all of them in parallel.</p>
<p>Two things change when you leave the container:</p>
<ul>
<li><strong>Elevation.</strong> The <code>beta</code> examples run as the container's <code>root</code>, so they omit <code>sudo</code>. A real login user is not root, so pass <code>sudo=True</code> on each request that writes a system path — exactly as <code>website_4.py</code> and the prod snippets here do. Reemote renders <code>sudo &lt;command&gt;</code> and never prompts: grant the login user passwordless <code>sudo</code>, and set any run-as user or password in the entry's top-level <code>privilege</code> block (<code>sudo_user</code>, <code>sudo_password</code>), never in the operation.</li>
<li><strong>Reachability.</strong> Host-key checking is on by default; for a first connection to a new host either pre-seed <code>~/.ssh/known_hosts</code> or pass <code>"known_hosts": None</code> in <code>connection</code> to skip the check.</li>
</ul>
<p>Swap <code>INVENTORY</code> for <code>REAL</code> in any example and it deploys to your server instead of <code>beta</code> — the generator functions are identical, because a connector only overrides the transport, never the deploy.</p>
<h3>Installing nginx</h3>
<p>Both websites run behind nginx, so install it first. This is the shared base every host gets:</p>
<pre><code class="language-python">from reemote import apt, files, op

def nginx_base(host):
    yield op(apt.update, name="apt update")
    yield op(apt.packages, name="install nginx", packages=("nginx",))
    yield op(files.link, name="disable default site",
             path="/etc/nginx/sites-enabled/default", present=False)
</code></pre>
<p>Run it against <code>beta</code> and the plan and log line up. Verified output, first run:</p>
<pre><code class="language-text">== plan ==
beta: apt update               [changed: apt update]
beta: install nginx            [changed: 1 package(s) to install]
== log ==
beta: apt update               [changed: apt update]
    $ apt-get update
beta: install nginx            [changed: 1 package(s) to install]
    $ DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" install nginx
</code></pre>
<p>Run it again and it converges — except <code>apt.update</code>, which is deliberately stateless and always runs (there is no <code>cache_time</code> stamp file, because a timestamp would drift between the prepare and apply phases and break the determinism guard). Verified second run:</p>
<pre><code class="language-text">== plan ==
beta: apt update               [changed: apt update]
beta: install nginx            [no change: all packages present]
== log ==
beta: apt update               [changed: apt update]
    $ apt-get update
beta: install nginx            [no change: all packages present]
</code></pre>
<p>Disabling the default "Welcome to nginx!" site removes a symlink, and its plan reports exactly that. Verified output:</p>
<pre><code class="language-text">== plan ==
beta: disable default site     [changed: /etc/nginx/sites-enabled/default present, will remove]
== log ==
beta: disable default site     [changed: /etc/nginx/sites-enabled/default present, will remove]
    $ rm -f /etc/nginx/sites-enabled/default
</code></pre>
<p>Notice the ordering worked without any special handling. "Disable the default site" depends on nginx having created that symlink — and it did, because apply runs in <strong>lockstep</strong>, one request index at a time, so the install completes before the link step reads its fact. You might be tempted to gate the link on a fact instead:</p>
<pre><code class="language-python"># Do NOT do this — it cannot work, and reemote will not let it.
if some_link_fact:            # a live fact read inside the deploy body
    yield op(files.link, ...)
</code></pre>
<p>That branch reads a fact during <strong>prepare</strong>, when nginx is not installed yet, so the symlink would not exist and the step would be skipped on the very first run. Reemote forecloses the mistake entirely: branching on a live fact in a deploy raises <code>DeterminismError</code>. The lockstep apply is what makes "install, then configure what the install created" safe to write as two plain steps in order.</p>
<p>A config change usually needs a <strong>reload</strong>. Reemote has no <code>notify</code>/handler keyword; a conditional reload is built from the results of a previous run, in ordinary Python, between two deploys — the <strong>two-deploy split</strong>:</p>
<pre><code class="language-python">from reemote import changed_hosts, op, run, systemd

# Pass 1: converge the nginx config (the "notifying" tasks).
_, report, _ = await run(BETA, nginx_base)

# Freeze the hosts whose named operations actually changed.
changed = changed_hosts(report, {"disable default site"})

# Pass 2: reload nginx, only on those hosts.
def reload_nginx(host):
    if host.name in changed:
        yield op(systemd.service, name="reload nginx",
                 service="nginx", reloaded=True, sudo=True)

await run(BETA, reload_nginx)
</code></pre>
<p><code>changed</code> is a fixed value by the time pass 2 builds its deploy, so the branch is on a snapshot — never a live fact — and stays deterministic. An empty <code>changed</code> yields an empty deploy: no host reloads. This is the whole notification mechanism, and it needs no handler-ordering rules to memorise.</p>
<p>!!! note "systemd needs a real init"</p>
<pre><code class="language-plaintext">The reload above (and the service steps later) drive `systemctl`, which a
stock `ubuntu:24.04` container does not run as PID 1 — `systemctl` refuses
with "System has not been booted with systemd as init system". The
apt/files/user output in this article is verified against the container;
the systemd steps are shown as the code and the plan/log a systemd-enabled
host (a real VM, bare metal, or a `jrei/systemd-ubuntu` image) renders. On
production this just works.
</code></pre>
<h2>Static site + code reuse</h2>
<p>With nginx installed, the landing page is next. Keep its operations in a reusable function and include it conditionally, so a host only gets a site if its inventory says so:</p>
<pre><code class="language-python">def deploy(host):
    yield from nginx_base(host)
    if host.extra.get("home_domain"):
        yield from sub_deploy(home_site, name_prefix="home")(host)
</code></pre>
<p><code>host.extra</code> is static inventory data, so this <code>if</code> is deterministic — both phases resolve it the same way. <code>sub_deploy(..., name_prefix="home")</code> labels every request the sub-deploy yields with a <code>home:</code> prefix, which is how the plan keeps a composed deploy readable.</p>
<p>Two honest adaptations from a YAML-and-Jinja workflow:</p>
<ul>
<li><p><strong>There is no</strong> <code>files.sync</code> <strong>and no controller-filesystem awareness.</strong> Reemote never resolves a local directory path on the target's behalf; content crosses as <em>data</em>. To publish a folder of static files, read them control-side and install each one:</p>
<pre><code class="language-python">from pathlib import Path
from reemote import files, op

def home_site(host):
    root = Path("site")                       # read on the control machine
    for path in sorted(root.rglob("*")):
        if path.is_file():
            rel = path.relative_to(root)
            yield op(files.install, name=f"install {rel}",
                     dest=f"/var/www/home/{rel}",
                     content=path.read_text(), sudo=True)
</code></pre>
</li>
<li><p><strong>There is no</strong> <code>files.template</code><strong>.</strong> Jinja rendering happens control-side, in plain Python, and the resulting string goes to <code>files.install</code>. That keeps an unhashable template context out of the request (which must stay hashable) and needs no templating dependency.</p>
</li>
</ul>
<p>For a single landing page, <code>files.install</code> writes the text and compares content, so a re-run is a no-op. Verified output — first run, then the identical run again:</p>
<pre><code class="language-text">== plan ==
beta: install landing page     [changed: content /var/www/html/index.html is absent, will install]
== log ==
beta: install landing page     [changed: content /var/www/html/index.html is absent, will install]
    $ scp &lt;files install&gt; -&gt; /tmp/reemote-install-0c941e79a0742ece/content
    $ sh -c 'mkdir -p /var/www/html &amp;&amp; cp /tmp/reemote-install-0c941e79a0742ece/content /var/www/html/index.html &amp;&amp; chmod 644 /var/www/html/index.html &amp;&amp; rm -rf /tmp/reemote-install-0c941e79a0742ece'
</code></pre>
<pre><code class="language-text">== plan ==
beta: install landing page     [no change: content already up to date (/var/www/html/index.html)]
== log ==
beta: install landing page     [no change: content already up to date (/var/www/html/index.html)]
</code></pre>
<p>The applied log shows the <strong>spool-and-install</strong> pattern reemote uses for root-owned files: an unprivileged <code>scp</code> of the text to a <code>/tmp</code> spool (named after the destination's hash, so it is stable across phases and never collides), then one elevated <code>sh -c</code> that copies it into place, sets the mode, and removes the spool. Nothing ever runs <code>sed -i</code> on a protected file. And now we have a static site.</p>
<h2>Web app + secrets</h2>
<p>The application is a small Flask app served by gunicorn behind nginx on a Unix socket:</p>
<pre><code class="language-text">:80, :443
   |
.-------------.
|    nginx    |  groups: app-socket, www-data
'-------------'
   |
 app.sock     groups: app-socket
   |
.- gunicorn --.
|   web app   |  groups: app-socket, app
'-------------'
</code></pre>
<h3>Users and groups</h3>
<p>nginx and gunicorn share the socket through a group, so create the accounts first:</p>
<pre><code class="language-python">def app_users(host):
    yield op(server.group, name="create app group", group="app")
    yield op(server.group, name="create socket group", group="app-socket")
    yield op(server.user, name="create app user", user="app", group="app",
             groups=("app-socket",), home="/var/lib/app",
             shell="/usr/sbin/nologin", system=True)
</code></pre>
<p>Verified output:</p>
<pre><code class="language-text">== plan ==
beta: create app group         [changed: group app absent, will create]
beta: create socket group      [changed: group app-socket absent, will create]
beta: create app user          [changed: user app absent, will create; home /var/lib/app missing, will create]
== log ==
beta: create app group         [changed: group app absent, will create]
    $ groupadd app
beta: create socket group      [changed: group app-socket absent, will create]
    $ groupadd app-socket
beta: create app user          [changed: user app absent, will create; home /var/lib/app missing, will create]
    $ useradd -d /var/lib/app -s /usr/sbin/nologin -g app -G app-socket -r -M app
    $ mkdir -p /var/lib/app
    $ chown app:app /var/lib/app
</code></pre>
<p>Give nginx access to the socket by appending <code>www-data</code> to the group — <code>server.user(user="www-data", groups=("app-socket",), append=True)</code>. <code>groups</code> is a tuple, not a list, for the same hashability reason as <code>packages</code>.</p>
<h3>The virtual environment</h3>
<p>Reemote ships no <code>pip</code> module, so the venv is a <strong>custom operation</strong> — an async generator that reads a fact and yields a command, exactly like the built-ins:</p>
<pre><code class="language-python">from reemote import OpContext, StringCommand
from reemote.modules.files.files import FileExists

async def venv(ctx: OpContext, path: str, python: str = "python3"):
    """Ensure a virtualenv exists at ``path``."""
    if await ctx.facts.get(FileExists, f"{path}/bin/python"):
        ctx.reason = f"venv {path} already exists"
        return
    ctx.changed = True
    ctx.reason = f"venv {path} missing, will create"
    yield StringCommand(f"{python} -m venv {path}")

async def pip_install(ctx: OpContext, venv_path: str, packages: tuple[str, ...]):
    """Install packages into a venv (kept simple: always runs)."""
    ctx.changed = True
    ctx.reason = f"install {len(packages)} package(s) into {venv_path}"
    yield StringCommand(f"{venv_path}/bin/pip install {' '.join(packages)}")
</code></pre>
<p>Writing a custom operation is this little code, which is the offset reemote offers for a smaller built-in catalogue. A production <code>pip_install</code> would read <code>pip freeze</code> as a fact and skip work when the requirement is already satisfied; the version above is deliberately minimal. Use them like any built-in: <code>yield op(venv, name="create venv", path="/opt/app")</code>.</p>
<p>Publish the application source the same control-side way as the static files, then install it into the venv. The systemd unit is text, so it is <code>files.install</code> again, and the service is <code>systemd.service</code>:</p>
<pre><code class="language-python">def app_service(host):
    yield op(files.install, name="write app.service",
             dest="/etc/systemd/system/app.service",
             content=render_unit(host), sudo=True)
    yield op(systemd.service, name="run app", service="app",
             running=True, enabled=True, daemon_reload=True, sudo=True)
</code></pre>
<p>On a systemd-enabled host, a first run renders (from the module's own reason strings — start is yielded before enable):</p>
<pre><code class="language-text">== plan ==
web2: run app                  [changed: app.service not running (inactive), will start; app.service not enabled, will enable]
== log ==
web2: run app                  [changed: app.service not running (inactive), will start; app.service not enabled, will enable]
    $ sudo systemctl start app.service
    $ sudo systemctl enable app.service
</code></pre>
<p>...and converges on a re-run to <code>[no change: app.service already running; app.service already enabled]</code>. When the code changes you want a <strong>restart</strong>, which is the two-deploy split again — and reemote ships the helper for the common case:</p>
<pre><code class="language-python">from reemote import changed_hosts, restart_handler, run

_, report, _ = await run(PROD, app_deploy)          # pass 1: publish code + config
changed = changed_hosts(report, {"write app config", "write app.service"})  # config or unit changed?
await run(PROD, restart_handler(changed, "app"))     # pass 2: restart only those
</code></pre>
<h3>Secrets</h3>
<p>Flask needs a <code>SECRET_KEY</code>, and it should differ between beta and prod. A deploy is Python, so read the secret from wherever you keep it — a password manager, a hardware key, an encrypted file. To illustrate, a function that reads named secrets from a JSON file, keyed by stage:</p>
<pre><code class="language-python">import json
from pathlib import Path

def get_secret(name, stage):
    data = json.loads(Path("secrets.json").read_text())
    if secret := data.get(stage, {}).get(name):
        return secret
    raise RuntimeError(f"secret {name!r} for stage {stage!r} not found")
</code></pre>
<p>The stage comes from <code>host.extra["stage"]</code> — set per host in the inventory, not hardcoded. Render the config control-side and install it, owned by the <code>app</code> group and mode <code>0640</code>:</p>
<pre><code class="language-python">def app_config(host):
    stage = host.extra.get("stage", "beta")
    secret = get_secret("app_secret_key", stage)
    content = f"SECRET_KEY = {secret!r}\n"
    yield op(files.install, name="write app config",
             dest="/etc/opt/app/config.py", content=content,
             group="app", mode="0640", sudo=True)
</code></pre>
<p>!!! warning "A restrictive mode costs you content idempotence"</p>
<pre><code class="language-plaintext">`files.install` decides whether to write by reading the destination back —
unprivileged, as the login user. A file installed `0640` and owned by a
service account cannot be read by that login user, so it reads back empty
and re-installs on **every** run. Content idempotence needs a
login-readable file. Either install the secret `0644` (world-readable —
usually not what you want for a key), or accept the re-write, or keep the
secret out of a file the login user cannot read. This is a real, documented
trade-off, not a bug — plan the mode with it in mind.
</code></pre>
<p>Where you store the secret is up to you; reading an unencrypted JSON file is the simple-and-wrong baseline, exactly as hardcoding it would be. Use an encrypted store in production.</p>
<h2>HTTPS + prod</h2>
<p>A web app is nicer when it is not "not secure". Let's Encrypt gives free certificates and Certbot requests, installs, and renews them. Reemote has no built-in Certbot operation, so write one — a <strong>fact</strong> to read the current certificates and an <strong>operation</strong> to request a missing one:</p>
<pre><code class="language-python">from reemote import OpContext, StringCommand

class Certificates:
    """The set of domain names certbot already has certificates for."""
    @classmethod
    async def process(cls, host):
        out = await host.run_shell("certbot certificates")
        return _parse_certbot_names(out)          # -&gt; set[str]

async def certificate(ctx: OpContext, domain: str):
    """Request a certificate for ``domain`` if certbot does not have one."""
    if domain in await ctx.facts.get(Certificates):
        ctx.reason = f"certificate for {domain} already present"
        return
    ctx.changed = True
    ctx.reason = f"certificate for {domain} missing, will request"
    yield StringCommand(f"certbot certonly -d {domain} -n --nginx --agree-tos")
</code></pre>
<p>A fact is any class with an async <code>process</code> classmethod; an operation is any async generator taking an <code>OpContext</code>. Read state with the fact, yield a command only when it does not match — the same shape as every built-in. Install Certbot itself with <code>apt.packages(packages=("python3-certbot-nginx",))</code> first, and the site sub-deploy gains one conditional step:</p>
<pre><code class="language-python">def nginx_site(host, template, domain, https=False):
    if https:
        yield op(certificate, name="cert", domain=domain, sudo=True)
    yield op(files.install, name="write site",
             dest=f"/etc/nginx/sites-available/{domain}",
             content=render_site(template, domain, https), sudo=True)
    yield op(files.link, name="enable site",
             path=f"/etc/nginx/sites-enabled/{domain}",
             target=f"/etc/nginx/sites-available/{domain}", sudo=True)
</code></pre>
<p>Certbot needs a <strong>resolvable</strong> domain name to complete the HTTP-01 challenge, so this cannot be exercised against the local <code>beta</code> container — but it can always be tested in production. The prod inventory is two SSH hosts, each with its own domain and HTTPS switched on through <code>host.extra</code>:</p>
<pre><code class="language-python">PROD = [
    {"host": "web1",
     "connection": {"address": "web1.example.com", "username": "deploy",
                    "key_path": "~/.ssh/id_ed25519"},
     "home_domain": "www.example.com", "stage": "prod", "nginx_https": True},
    {"host": "web2",
     "connection": {"address": "web2.example.com", "username": "deploy",
                    "key_path": "~/.ssh/id_ed25519"},
     "app_domain": "app.example.com", "stage": "prod", "nginx_https": True},
]
</code></pre>
<p>There is no <code>--yes</code> flag to skip a prompt, because there is no prompt. Preview the whole fleet with <code>dry=True</code> — a real prepare pass against live facts on both servers, changing nothing — then run it for real:</p>
<pre><code class="language-python">plans, _, _ = await run(PROD, deploy, dry=True)     # look
print(format_plan(plans))

plans, report, _ = await run(PROD, deploy)          # leap
print(format_log(report))
</code></pre>
<p><code>sudo=True</code> on the requests is the whole elevation story on Ubuntu: reemote renders <code>sudo &lt;command&gt;</code> and never needs an interactive password prompt — use passwordless sudoers or a login user with <code>NOPASSWD</code>, and the run-as user and any password come from the inventory's <code>privilege</code> block, never from the operation. A minute later there are two new websites on the internet, and the identical deploy — the same generator functions — produced both, differing only in the inventory it was handed.</p>
<h2>Questions?</h2>
<p><strong>Can I use reemote from an interactive Python shell?</strong> Yes — it is a library, so <code>await run(inventory, deploy)</code> works in a REPL, a notebook, or <code>asyncio.run(...)</code>. There is a little boilerplate (building the inventory, wrapping the async call), and no CLI sugar to load inventories for you; that is the trade for it being importable.</p>
<p><strong>Can I use reemote to provision cloud resources?</strong> No — use Terraform for that. Reemote configures machines over SSH (or inside containers); it does not create or destroy infrastructure. Staying "commands over a connection" is a deliberate scope. And if you find you need fewer resources than you thought, one well-built machine goes a long way.</p>
<p><strong>Can I use reemote with cloud inventories?</strong> Yes. The inventory is a Python list, so build it from your cloud's SDK directly — listing the instances in an autoscaling group is a few lines. There is no separate dynamic-inventory plugin to configure; it is just an expression that evaluates to a list of dicts.</p>
<p><strong>What about the connectors?</strong> This article used two: the default <code>ssh</code> connector for production, and the <code>docker</code> connector for the local <code>beta</code> container. The same deploy runs against either, because a connector only overrides the transport seams (run a command, copy a file) — you pick it per inventory entry, never in the deploy.</p>
<h2>Try it</h2>
<ul>
<li>Documentation and full guide: <a href="https://reemote.org/">https://reemote.org/</a></li>
<li>Source: <a href="https://github.com/kimjarvis/reemote">https://github.com/kimjarvis/reemote</a></li>
<li>PyPI: <code>pip install reemote</code></li>
<li>License: EPL-2.0</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Managing CentOS from Python: Reemote's dnf and systemd modules]]></title><description><![CDATA[Ansible manages CentOS through a CLI, a YAML playbook, and a Jinja2 layer that fights your IDE. Reemote is a Python library that configures CentOS over…]]></description><link>https://reemote.hashnode.dev/managing-centos-from-python-reemote-s-dnf-and-systemd-modules</link><guid isPermaLink="true">https://reemote.hashnode.dev/managing-centos-from-python-reemote-s-dnf-and-systemd-modules</guid><category><![CDATA[Python]]></category><category><![CDATA[ansible]]></category><dc:creator><![CDATA[kimjarvis]]></dc:creator><pubDate>Fri, 13 Nov 2026 12:09:34 GMT</pubDate><content:encoded><![CDATA[<p><em>Ansible manages CentOS through a CLI, a YAML playbook, and a Jinja2 layer that fights your IDE. Reemote is a Python</em> <em><strong>library</strong></em> <em>that configures CentOS over</em> <code>sshd</code> <em>and a plain shell —</em> <code>dnf</code> <em>and</em> <code>systemd</code> <em>included — and hands you the result as data you can</em> <code>await</code><em>, inspect, and test. The whole thing runs in a throwaway container in under a minute.</em></p>
<h2>The problem: configuration management on CentOS</h2>
<p>CentOS — and the RHEL family it anchors: Rocky, Alma, Fedora Server — runs a huge share of enterprise fleets, a lot of cPanel/hosting infrastructure, and most on-prem boxes that were "standardised on Red Hat" a decade ago and never moved. Its shape is well known and Ansible handles it cleanly, but the pain is not "can the tool talk to the box". It is everything a CLI-shaped tool costs you once you want to embed deployment in a Python program.</p>
<p>That is the gap: <strong>there is no serious configuration-management library you can</strong> <code>import</code> <strong>and point at a CentOS box.</strong> Reemote is that library, and the RHEL family is where its <code>dnf</code> namespace feels most at home — application streams, repo files, and rpm's GPG keyring all ship as first-class facts and operations, alongside the same two-phase prepare-then-apply runner everything else uses.</p>
<h2>Try it in under a minute (docker connector)</h2>
<p>Reemote ships a <strong>docker connector</strong> that runs your deploy inside a local container, so you need neither a remote host nor an SSH key to see it work. <strong>You do need Docker or Podman installed locally</strong> — the connector shells out to whichever CLI you name, and there is no default. Three commands:</p>
<pre><code class="language-bash"># 1. install reemote into a fresh venv (Python 3.13+; pip works just as well as uv)
uv venv .venv &amp;&amp; source .venv/bin/activate &amp;&amp; uv pip install reemote
# 2. save the script below as centos_demo.py
# 3. run it — starts a throwaway CentOS Stream 9 container, configures it, cleans up
python centos_demo.py
</code></pre>
<pre><code class="language-python"># centos_demo.py
import asyncio
from reemote import dnf, format_log, format_plan, op, run

INVENTORY = [{
    "host": "centos-demo",
    "connection": {
        "connector": "docker",
        "identifier": "quay.io/centos/centos:stream9",
        "docker_cmd": "podman",   # or "docker"
    },
}]

def deploy(host):
    # The most common first task after dropping onto a fresh CentOS server:
    # install the web server the RHEL family ships as its default — httpd.
    yield op(dnf.packages,
             name="Install httpd",
             packages=("httpd",))

async def main():
    plans, report, _ = await run(INVENTORY, deploy)
    print("== Plan ==")
    print(format_plan(plans))
    print("== Applied ==")
    if report is not None:
        print(format_log(report))

asyncio.run(main())
</code></pre>
<p>Verified output:</p>
<pre><code class="language-text">== Plan ==
centos-demo: Install httpd [changed: 1 package(s) to install]
== Applied ==
centos-demo: Install httpd [changed: 1 package(s) to install]
    $ dnf install -y httpd
</code></pre>
<p>Three things worth noticing, because they are exactly where CentOS + Reemote differ from CentOS + Ansible:</p>
<ul>
<li><strong>No YAML, no Jinja2, no</strong> <code>ansible.builtin.dnf</code><strong>.</strong> <code>dnf.packages</code> is a Python function your editor knows: go-to-definition, autocomplete, type check. The <code>packages=</code> argument is a tuple because operation kwargs must stay hashable — the same determinism rule that lets reemote run your deploy twice (once to plan, once to apply) and prove both passes agree.</li>
<li><strong>No</strong> <code>-y</code> <strong>to remember.</strong> The dnf namespace knows its own non-interactive vocabulary and renders <code>dnf install -y httpd</code> for you. Reemote exposes dnf's own idioms rather than flattening them into a lowest-common-denominator package API — the same shape <code>apt.packages</code> takes on Ubuntu and <code>apk.no_cache</code> on Alpine.</li>
<li><strong>A full-system upgrade is a different word here.</strong> On Ubuntu <code>apt-get update</code> just refreshes the package lists; on CentOS <code>dnf update</code> <em>upgrades every package</em>. That is why this quickstart installs one package and stops. To refresh metadata without upgrading, use <code>dnf.makecache</code>; to actually patch the box, use <code>dnf.update</code> — reemote spells each one explicitly rather than collapsing them into one ambiguous <code>update=</code> flag.</li>
</ul>
<p>One honesty note, the same one the throwaway-container workflow always carries: every run reports <code>[changed: ...]</code>, even the second time, because the image target starts a <em>fresh</em> <code>centos:stream9</code> container on each invocation, so httpd genuinely is absent each time. Point reemote at a stable target — an SSH host, or an existing container ID — and the second run correctly reports <code>[no change: all packages present]</code>. You will see exactly that below.</p>
<h2>Why Python has real advantages over Ansible</h2>
<p>Ansible chose YAML because it is readable and does not require knowing a programming language. That is a fair trade when your team does not write Python. When it does — and most automation teams today do — the trade costs more than it saves:</p>
<ul>
<li><strong>Your editor knows your code.</strong> <code>dnf.packages</code> and <code>systemd.service</code> are functions: go-to-definition, rename, autocomplete, and a type check that catches a typo before it reaches a production box. A misspelled YAML module name surfaces at runtime, in the middle of a rollout.</li>
<li><strong>No Jinja2 as a second language.</strong> <code>{{ hostvars[...] }}</code> is a stringly-typed escape hatch that fights your IDE and your tests. In reemote the value is just a Python expression, evaluated by the interpreter you already trust.</li>
<li><strong>Composition is</strong> <code>import</code><strong>, not directory layout.</strong> Ansible roles load by convention (<code>tasks/main.yml</code>, <code>defaults/main.yml</code>, <code>handlers/main.yml</code>); reemote deploys are generators you import, call, and loop over.</li>
<li><strong>The result is data, not stdout.</strong> If your application needs to know what changed, you read <code>report.entries[i].changed</code> — you do not regex-scrape a terminal, and you do not depend on <code>callback_plugins</code> to reshape the log for you.</li>
<li><strong>Testing is</strong> <code>pytest</code><strong>.</strong> A deploy function is a pure generator; you can inspect the requests it yields without touching a server. No molecule, no testinfra, no ansible-lint.</li>
<li><code>await</code> <strong>is your concurrency model.</strong> Hosts progress in parallel because the transport is asyncssh, not because a CLI forked worker processes you have to configure.</li>
</ul>
<h2>The solution: Reemote is a library</h2>
<p>Reemote is a Python package (<code>pip install reemote</code>) with two properties that separate it from every CLI-shaped tool.</p>
<h3>Async end to end, on asyncssh</h3>
<p>The transport is <a href="https://asyncssh.readthedocs.io/">asyncssh</a>, and the entire execution path — connecting, gathering facts, sending commands, closing — is coroutines. You <code>await</code> a deploy and hosts progress in parallel; network waits overlap across hosts while each host's commands stay in order. Your deploy script remains a plain synchronous generator, so you never write <code>async</code> inside an operation.</p>
<pre><code class="language-python">plans, report, hosts = await run(inventory, deploy)
</code></pre>
<p><code>run</code> is the single entry point: it validates the inventory, opens connections lazily, plans and applies in two phases, then closes everything in a <code>finally</code>. The <strong>same code</strong> runs over SSH or over the docker connector — you pick the transport in the inventory entry, not in the deploy. That is why you can develop a CentOS role against a local container and ship it to a VM without changing a line.</p>
<h3>A library returning structured results, not a CLI</h3>
<p><code>run(...)</code> returns <code>(plans, report, hosts)</code>:</p>
<ul>
<li><code>plans</code> — <code>{host_label: [PlannedOperation, ...]}</code>, the proposed work per host.</li>
<li><code>report</code> — an <code>ApplyReport</code> with a <code>LogEntry</code> per applied operation, each carrying <code>changed</code>, <code>reason</code>, and the exact commands sent. <code>None</code> when <code>dry=True</code>.</li>
<li><code>hosts</code> — the materialised host objects.</li>
</ul>
<p><code>format_plan</code> and <code>format_log</code> (what you saw above) are convenience renderers for humans. Your application consumes the objects directly — no parsing stdout to figure out what happened, no <code>--check</code> mode plus a callback plugin to reconstruct the plan after the fact.</p>
<h2>The <code>dnf</code> module: CentOS's package manager as Python</h2>
<p><code>dnf</code> pairs read-only <strong>facts</strong> with <strong>operations</strong> that converge the box toward a described state. An operation reads a fact, decides whether anything must change, and yields commands only when it must — so re-running against a stable target is a no-op. The non-idempotent verbs (<code>update</code>, <code>makecache</code>, <code>clean</code>, and the key-import direction) are the deliberate exceptions, and each is documented as such.</p>
<p>The <strong>operations</strong> cover the whole package lifecycle, in dnf's own vocabulary rather than a lowest-common-denominator one: install/upgrade/remove by name (<code>dnf.packages</code>), a full-system upgrade (<code>dnf.update</code>), metadata refresh and cache cleanup (<code>dnf.makecache</code>, <code>dnf.clean</code>), repository-file management (<code>dnf.repo</code>), GPG-key import and removal on the rpm keyring (<code>dnf.key</code>), local <code>.rpm</code> file installs (<code>dnf.rpm</code>), and the RHEL-only application/module streams (<code>dnf.module</code>). Most read a fact and yield commands only when the box differs from the request, so re-running against a stable target is a no-op; a few verbs — <code>update</code>, <code>makecache</code>, <code>clean</code>, and the key-import direction — are deliberately non-idempotent, because there is no cheap fact that predicts whether they would change anything.</p>
<p>The <strong>facts</strong> are the read side of the same vocabulary: which packages (and which versions) are installed, which repositories are configured and what their <code>.repo</code> files contain, which module streams are enabled/disabled/installed, which package groups exist, the version of a local <code>.rpm</code>, and which GPG keys sit in the rpm keyring. Every enumerating fact returns sorted, de-duplicated, JSON-printable typed entries — not a scraped string — so a read is data you can diff, log, or assert on.</p>
<h2>The <code>systemd</code> module: CentOS's init as Python</h2>
<p>systemd manages services along <strong>two independent axes</strong>: whether a unit is <em>running</em> right now, and whether it is <em>enabled</em> at boot. A unit can be enabled but stopped, or running but not enabled. <code>systemd.service</code> handles both in one operation, and the namespace goes well beyond services — drop-ins, timers, tmpfiles, sysusers, masking, presets. It is the same module Ubuntu uses; the RHEL family is where systemd was born, so nothing here is a portability compromise.</p>
<p>The <strong>operations</strong> center on <code>systemd.service</code> — one call that manages a unit along both axes (<code>running</code> and <code>enabled</code>) plus the restart/reload verbs — and extend to the surrounding unit-file machinery: an explicit <code>daemon_reload</code>, drop-ins, <code>tmpfiles.d</code>/<code>sysusers.d</code> config files, masking, presets, and clearing a failed state. The state-changing verbs read a fact first and yield a <code>systemctl</code> command only on a difference, so they are idempotent; restart, reload, and daemon-reload are the intentional non-idempotent exceptions.</p>
<p>The <strong>facts</strong> answer both the per-unit questions (is it running? enabled? what are its properties, dependencies, or unit-file path?) and the whole-system audit questions (what is failed? what timers are scheduled? what is the system state? which systemd version?). Several are reemote originals that expose the audit view the baseline never surfaced, and every <code>systemctl</code>-based fact and operation takes scope flags for the per-user manager or a named machine.</p>
<h3>Install a package, then ensure its service — verified</h3>
<p>The classic CentOS post-install move is <em>install the daemon, then enable and start it</em> — httpd for a web tier. In reemote that is <code>dnf.packages</code> followed by <code>systemd.service</code>, composed in one deploy:</p>
<pre><code class="language-python">def deploy(host):
    # 1. Install httpd (ships /usr/lib/systemd/system/httpd.service).
    yield op(dnf.packages,
             name="Install httpd",
             packages=("httpd",),
             sudo=True)
    # 2. Enable and start its systemd unit.
    yield op(systemd.service,
             name="Run httpd",
             service="httpd",
             running=True,
             enabled=True,
             sudo=True)
</code></pre>
<p>The ordering is the point. The apply phase runs in <strong>lockstep</strong> — one request index at a time across all hosts — so every host finishes the install before any host begins the service step. The <code>systemd.service</code> request therefore always sees the <code>httpd.service</code> unit file the install just delivered. There is no combined "ensure" operation to maintain; the two halves compose, and each is idempotent against its own facts.</p>
<p>First run against a CentOS host with systemd as PID 1:</p>
<pre><code class="language-text">== Plan ==
centos-demo: Install httpd [changed: 1 package(s) to install]
centos-demo: Run httpd     [changed: httpd.service not running (inactive), will start; httpd.service not enabled, will enable]
== Applied ==
centos-demo: Install httpd [changed: 1 package(s) to install]
    $ sudo dnf install -y httpd
centos-demo: Run httpd     [changed: httpd.service not running (inactive), will start; httpd.service not enabled, will enable]
    $ sudo systemctl start httpd.service
    $ sudo systemctl enable httpd.service
</code></pre>
<p>Run the identical script again against the <em>same</em> host and it converges — this is the <code>[no change: ...]</code> the throwaway quickstart could not show:</p>
<pre><code class="language-text">== Plan ==
centos-demo: Install httpd [no change: all packages present]
centos-demo: Run httpd     [no change: httpd.service already running; httpd.service already enabled]
== Applied ==
centos-demo: Install httpd [no change: all packages present]
centos-demo: Run httpd     [no change: httpd.service already running; httpd.service already enabled]
</code></pre>
<p>Two CentOS-specific honesty notes:</p>
<ul>
<li><strong>The stock</strong> <code>centos:stream9</code> <strong>container image does not run systemd as PID 1</strong> — <code>systemctl start</code>/<code>enable</code> refuse to operate with "System has not been booted with systemd as init system". The quickstart at the top deliberately sticks to <code>dnf.packages</code> for exactly that reason. To exercise the systemd half locally, use a systemd-capable image or your own <code>docker run --privileged ... /sbin/init</code> recipe; on a real CentOS VM or bare-metal host systemd is PID 1 and everything above just works.</li>
<li><code>sudo</code> <strong>is uniform on CentOS.</strong> The RHEL family ships <code>sudo</code> preinstalled, so <code>sudo=True</code> on a request is the whole elevation story. Reemote renders <code>sudo &lt;command&gt;</code> on the descriptor and never needs a password prompt: use passwordless sudoers, or an SSH key for a login user with <code>NOPASSWD</code>, or <code>su=True</code> if you would rather reemote escalate through <code>su</code> (root logins are common on legacy CentOS boxes).</li>
</ul>
<h2>A day-one CentOS playbook, in Python</h2>
<p>Here is the routine post-install set — the tasks a CentOS playbook always opens with — as one reemote deploy. No YAML, no Jinja2, no handlers section:</p>
<pre><code class="language-python">def deploy(host):
    # (1) Patch the box. On the RHEL family `dnf update` is a full-system
    #     upgrade — the literal "yum update" every CentOS admin runs first.
    yield op(dnf.update, name="Apply pending updates", sudo=True)

    # (2) Baseline tooling every CentOS server wants.
    yield op(dnf.packages, name="Install baseline tools",
             packages=("curl", "git", "htop"),
             sudo=True)

    # (3) Web tier: install httpd, then enable and start it.
    yield op(dnf.packages, name="Install httpd",
             packages=("httpd",), sudo=True)
    yield op(systemd.service, name="Run httpd", service="httpd",
             running=True, enabled=True, sudo=True)

    # (4) Time sync — chrony is the RHEL-family default NTP client, and its
    #     unit is chronyd (not Debian's chrony).
    yield op(dnf.packages, name="Install chrony",
             packages=("chrony",), sudo=True)
    yield op(systemd.service, name="Run chronyd", service="chronyd",
             running=True, enabled=True, sudo=True)

    # (5) Application streams — a RHEL-only concept: pin nginx to the 1.24
    #     stream so a later `dnf install nginx` resolves that version.
    yield op(dnf.module, name="Enable nginx:1.24 stream",
             module="nginx", stream="1.24", sudo=True)

    # (6) Hardening: a drop-in that tightens sshd, then a reload — the
    #     "handler" is just the next request in the same generator.
    yield op(systemd.dropin, name="Harden sshd",
             filename="10-reemote.conf", unit="sshd.service",
             content="[Service]\nPermitRootLogin=no\n",
             sudo=True)
    yield op(systemd.daemon_reload, name="Reload systemd after drop-in",
             sudo=True)
    yield op(systemd.service, name="Restart sshd", service="sshd",
             restarted=True, sudo=True)
</code></pre>
<p>Task <strong>(6)</strong> is the one that shows off the library shape most. In Ansible the drop-in is a <code>template</code> plus a <code>notify: restart sshd</code> handler, and the handler fires only if the template reported <code>changed</code>. In reemote the "handler" is ordinary Python: run the config deploy, read its <code>report</code>, freeze the set of hosts that actually changed with <code>reemote.handlers.changed_hosts</code>, and feed that set into a second deploy that calls <code>systemd.service("sshd", restarted=True)</code>. No <code>notify</code> keyword, no handler-ordering rules to memorise — just the structured result you already have.</p>
<h2>What follows: Reemote can manage CentOS instead of Ansible</h2>
<p>Once configuration management is a library, everything Python already has becomes available to your CentOS deploys:</p>
<ul>
<li><strong>Deploy from your application.</strong> <code>await run(inventory, deploy)</code> inside a FastAPI handler, a Celery task, a Python GitHub Action, or a REPL.</li>
<li><strong>Compose with real code.</strong> Build the inventory from a database query; generate <code>.repo</code> file text or <code>daemon.json</code> from a Pydantic model; share validation between the app and the deploy.</li>
<li><strong>Test with</strong> <code>pytest</code><strong>.</strong> Deploy functions are pure generators — assert on the requests they yield without a server in sight.</li>
<li><strong>Consume structured results.</strong> <code>report.entries[i].changed</code>, <code>.reason</code>, <code>.commands</code> are data you can log, chart, feed to a webhook, or gate a rollout on.</li>
<li><strong>Two-phase execution for free.</strong> Every <code>run</code> prepares then applies; <code>dry=True</code> is a real preview against live facts, not a simulation.</li>
<li><strong>Same code over SSH or Docker.</strong> Flip the <code>connector</code> key and the identical deploy runs against a CentOS VM or a local container — test the role before you point it at production, or build an image from the same code that configures a host.</li>
<li><strong>Handlers as ordinary Python.</strong> Read the first deploy's report, freeze the changed hosts, feed them into a second deploy. <code>changed_hosts</code> and <code>restart_handler</code> ship for the common case.</li>
</ul>
<p>You can use reemote instead of Ansible whenever managing CentOS is part of a Python program rather than a shell command in a CI job — which, for most teams building products today, is the more natural shape. And on the RHEL family specifically, you keep <code>dnf</code>'s own vocabulary intact — module streams, repo files, the rpm keyring — rather than flattening it through a lowest-common-denominator module API.</p>
<h2>What Reemote is not</h2>
<ul>
<li><strong>Not a deployment CLI.</strong> There is no <code>reemote</code> command; you write the script.</li>
<li><strong>Not an inventory format.</strong> Inventory is a Python list of dicts — load it from JSON, YAML, a database, or write it inline.</li>
<li><strong>Not a role ecosystem.</strong> Reemote ships modules for <code>dnf</code>, <code>systemd</code>, <code>apt</code>, <code>apk</code>, <code>openrc</code>, <code>files</code>, <code>server</code>, <code>git</code>, <code>mysql</code>, <code>postgresql</code>, <code>iptables</code>, <code>firewalld</code>, <code>ufw</code>, <code>selinux</code>, <code>lxd</code>, <code>snap</code>, <code>flatpak</code>, <code>sysctl</code>, <code>gpg</code>, and <code>hardware</code> — but there is no Ansible Galaxy equivalent.</li>
<li><strong>Not a download tool.</strong> Reemote never fetches a URL mid-run: <code>dnf.repo</code>, <code>dnf.rpm</code>, and <code>dnf.key</code> all reject a URL <code>src</code> by design. Fetch control-side (optionally checksum-verified) and stream the bytes in.</li>
<li><strong>Not a translation layer between OSes.</strong> Python does not make <code>httpd</code> installable by the same name on CentOS and Ubuntu, nor make <code>dnf update</code> mean the same as <code>apt-get update</code>. You still pick the right module for the target — and on CentOS that means <code>dnf</code> and <code>systemd</code>, which reemote ships as first-class citizens.</li>
</ul>
<h2>Try it</h2>
<ul>
<li>Documentation and full guide: <a href="https://reemote.org/">https://reemote.org/</a></li>
<li>Source: <a href="https://github.com/kimjarvis/reemote">https://github.com/kimjarvis/reemote</a></li>
<li>PyPI: <code>pip install reemote</code></li>
<li>License: EPL-2.0</li>
</ul>
<p>If you have ever wanted to describe a CentOS box's state in the same language as the rest of your stack — with imports, types, tests, and <code>await</code>, and without translating <code>dnf</code>'s and <code>systemd</code>'s vocabularies through YAML — the docker quickstart at the top of this article is the whole onboarding. Give it a minute.</p>
]]></content:encoded></item><item><title><![CDATA[Managing Ubuntu from Python: Reemote's apt and systemd modules]]></title><description><![CDATA[Ansible manages Ubuntu through a CLI, a YAML playbook, and a Jinja2 layer that fights your IDE. Reemote is a Python library that configures Ubuntu over…]]></description><link>https://reemote.hashnode.dev/managing-ubuntu-from-python-reemote-s-apt-and-systemd-modules</link><guid isPermaLink="true">https://reemote.hashnode.dev/managing-ubuntu-from-python-reemote-s-apt-and-systemd-modules</guid><category><![CDATA[Python]]></category><category><![CDATA[ansible]]></category><dc:creator><![CDATA[kimjarvis]]></dc:creator><pubDate>Fri, 13 Nov 2026 12:02:46 GMT</pubDate><content:encoded><![CDATA[<p><em>Ansible manages Ubuntu through a CLI, a YAML playbook, and a Jinja2 layer that fights your IDE. Reemote is a Python</em> <em><strong>library</strong></em> <em>that configures Ubuntu over</em> <code>sshd</code> <em>and a plain shell —</em> <code>apt</code> <em>and</em> <code>systemd</code> <em>included — and hands you the result as data you can</em> <code>await</code><em>, inspect, and test. The whole thing runs in a throwaway container in under a minute.</em></p>
<h2>The problem: configuration management on Ubuntu</h2>
<p>Ubuntu is what most cloud VMs, most CI runners and a large share of internal fleets boot into. Its shape is well known and Ansible handles it cleanly — but the pain is not "can the tool talk to the box". It is everything the tool-shaped-like-a-CLI costs you once you want to embed deployment in a Python program:</p>
<ul>
<li><strong>apt</strong>, not <code>apk</code> or <code>dnf</code>. A whole vocabulary of its own — <code>apt-get update</code>, <code>apt-get install</code>, <code>apt-mark hold</code>, <code>apt-add-repository</code>, <code>sources.list.d/*.sources</code> deb822 files, <code>/etc/apt/keyrings/</code> for <code>signed-by=</code>.</li>
<li><strong>systemd</strong>, the init every modern Linux converged on — services along two independent axes (<code>running</code> right now, <code>enabled</code> at boot), plus drop-ins, timers, <code>tmpfiles.d</code>, <code>sysusers.d</code>, masking, presets.</li>
<li><code>sudo</code> preinstalled, so elevation is uniform. <strong>Python 3 preinstalled</strong>, so Ansible's usual bootstrap dance does not apply here — but every other cost of a CLI-shaped tool does: YAML plus Jinja2, roles loaded by directory convention, results scraped from stdout, tests written in testinfra/molecule rather than <code>pytest</code>, and no way to <code>await</code> a deploy from inside your own application.</li>
</ul>
<p>That is the gap: <strong>there is no serious configuration-management library you can</strong> <code>import</code> <strong>and point at an Ubuntu box.</strong> Reemote is that library, and Ubuntu is where it feels most at home — <code>apt</code> and <code>systemd</code> ship as first-class namespaces with facts, operations, and the same two-phase prepare-then-apply runner everything else uses.</p>
<h2>Try it in under a minute (docker connector)</h2>
<p>Reemote ships a <strong>docker connector</strong> that runs your deploy inside a local container, so you need neither a remote host nor an SSH key to see it work. <strong>You do need Docker or Podman installed locally</strong> — the connector shells out to whichever CLI you name, and there is no default. Three commands:</p>
<pre><code class="language-bash"># 1. install reemote into a fresh venv (Python 3.13+; pip works just as well as uv)
uv venv .venv &amp;&amp; source .venv/bin/activate &amp;&amp; uv pip install reemote
# 2. save the script below as ubuntu_demo.py
# 3. run it — starts a throwaway ubuntu:24.04 container, configures it, cleans up
python ubuntu_demo.py
</code></pre>
<pre><code class="language-python"># ubuntu_demo.py
import asyncio
from reemote import apt, format_log, format_plan, op, run

INVENTORY = [{
    "host": "ubuntu-demo",
    "connection": {
        "connector": "docker",
        "identifier": "ubuntu:24.04",
        "docker_cmd": "podman",   # or "docker"
    },
}]

def deploy(host):
    # The most common first task after dropping onto a fresh Ubuntu server:
    # refresh the package lists and install nginx.
    yield op(apt.packages,
             name="Install nginx",
             packages=("nginx",),
             update=True)

async def main():
    plans, report, _ = await run(INVENTORY, deploy)
    print("== Plan ==")
    print(format_plan(plans))
    print("== Applied ==")
    if report is not None:
        print(format_log(report))

asyncio.run(main())
</code></pre>
<p>Verified output:</p>
<pre><code class="language-text">== Plan ==
ubuntu-demo: Install nginx [changed: 1 package(s) to install]
== Applied ==
ubuntu-demo: Install nginx [changed: 1 package(s) to install]
    $ apt-get update
    $ DEBIAN_FRONTEND=noninteractive apt-get -y install nginx
</code></pre>
<p>Three things worth noticing, because they are exactly where Ubuntu + Reemote differ from Ubuntu + Ansible:</p>
<ul>
<li><strong>No YAML, no Jinja2, no</strong> <code>ansible.builtin.apt</code><strong>.</strong> <code>apt.packages</code> is a Python function your editor knows: go-to-definition, autocomplete, type check. The <code>packages=</code> argument is a tuple because operation kwargs must stay hashable — the same determinism rule that lets reemote run your deploy twice (once to plan, once to apply) and prove both passes agree.</li>
<li><strong>No</strong> <code>-y</code> <strong>to remember, no</strong> <code>DEBIAN_FRONTEND</code> <strong>to export.</strong> The apt namespace knows its own non-interactive vocabulary and renders it for you. Reemote exposes apt's own idioms rather than flattening them into a lowest-common-denominator package API — the same shape <code>apk.no_cache</code> takes on Alpine.</li>
<li><code>update=True</code> <strong>is a stateless prefix.</strong> It always runs <code>apt-get update</code> before the install, exactly like the baseline's <code>apt.update</code>; there is no <code>cache_time</code> stamp file, deliberately, because the stamp would drift between the prepare and apply phases and break the determinism guard.</li>
</ul>
<p>One honesty note, the same one the throwaway-container workflow always carries: every run reports <code>[changed: ...]</code>, even the second time, because the image target starts a <em>fresh</em> <code>ubuntu:24.04</code> container on each invocation, so nginx genuinely is absent each time. Point reemote at a stable target — an SSH host, or an existing container ID — and the second run correctly reports <code>[no change: all packages present]</code>. You will see exactly that below.</p>
<h2>Why Python has real advantages over Ansible</h2>
<p>Ansible chose YAML because it is readable and does not require knowing a programming language. That is a fair trade when your team does not write Python. When it does — and most automation teams today do — the trade costs more than it saves:</p>
<ul>
<li><strong>Your editor knows your code.</strong> <code>apt.packages</code> and <code>systemd.service</code> are functions: go-to-definition, rename, autocomplete, and a type check that catches a typo before it reaches a production box. A misspelled YAML module name surfaces at runtime, in the middle of a rollout.</li>
<li><strong>No Jinja2 as a second language.</strong> <code>{{ hostvars[...] }}</code> is a stringly-typed escape hatch that fights your IDE and your tests. In reemote the value is just a Python expression, evaluated by the interpreter you already trust.</li>
<li><strong>Composition is</strong> <code>import</code><strong>, not directory layout.</strong> Ansible roles load by convention (<code>tasks/main.yml</code>, <code>defaults/main.yml</code>, <code>handlers/main.yml</code>); reemote deploys are generators you import, call, and loop over.</li>
<li><strong>The result is data, not stdout.</strong> If your application needs to know what changed, you read <code>report.entries[i].changed</code> — you do not regex-scrape a terminal, and you do not depend on <code>callback_plugins</code> to reshape the log for you.</li>
<li><strong>Testing is</strong> <code>pytest</code><strong>.</strong> A deploy function is a pure generator; you can inspect the requests it yields without touching a server. No molecule, no testinfra, no ansible-lint.</li>
<li><code>await</code> <strong>is your concurrency model.</strong> Hosts progress in parallel because the transport is asyncssh, not because a CLI forked worker processes you have to configure.</li>
</ul>
<h2>The solution: Reemote is a library</h2>
<p>Reemote is a Python package (<code>pip install reemote</code>) with two properties that separate it from every CLI-shaped tool.</p>
<h3>Async end to end, on asyncssh</h3>
<p>The transport is <a href="https://asyncssh.readthedocs.io/">asyncssh</a>, and the entire execution path — connecting, gathering facts, sending commands, closing — is coroutines. You <code>await</code> a deploy and hosts progress in parallel; network waits overlap across hosts while each host's commands stay in order. Your deploy script remains a plain synchronous generator, so you never write <code>async</code> inside an operation.</p>
<pre><code class="language-python">plans, report, hosts = await run(inventory, deploy)
</code></pre>
<p><code>run</code> is the single entry point: it validates the inventory, opens connections lazily, plans and applies in two phases, then closes everything in a <code>finally</code>. The <strong>same code</strong> runs over SSH or over the docker connector — you pick the transport in the inventory entry, not in the deploy. That is why you can develop an Ubuntu role against a local container and ship it to a VM without changing a line.</p>
<h3>A library returning structured results, not a CLI</h3>
<p><code>run(...)</code> returns <code>(plans, report, hosts)</code>:</p>
<ul>
<li><code>plans</code> — <code>{host_label: [PlannedOperation, ...]}</code>, the proposed work per host.</li>
<li><code>report</code> — an <code>ApplyReport</code> with a <code>LogEntry</code> per applied operation, each carrying <code>changed</code>, <code>reason</code>, and the exact commands sent. <code>None</code> when <code>dry=True</code>.</li>
<li><code>hosts</code> — the materialised host objects.</li>
</ul>
<p><code>format_plan</code> and <code>format_log</code> (what you saw above) are convenience renderers for humans. Your application consumes the objects directly — no parsing stdout to figure out what happened, no <code>--check</code> mode plus a callback plugin to reconstruct the plan after the fact.</p>
<h2>The <code>apt</code> module: Ubuntu's package manager as Python</h2>
<p><code>apt</code> pairs read-only <strong>facts</strong> with <strong>operations</strong> that converge the box toward a described state. An operation reads a fact, decides whether anything must change, and yields commands only when it must — so re-running against a stable target is a no-op.</p>
<p>The operations cover four kinds of apt work:</p>
<ul>
<li><strong>Package lifecycle</strong> — install, upgrade, remove, autoremove, and hold by name, with exact-version pinning. Every mutation is idempotent against the installed-package fact; the whole-box upgrade verbs are additionally gated on a dry-run simulation, so a fully patched host is a genuine no-op rather than a shell-out that quietly does nothing.</li>
<li><strong>Repository configuration</strong> — legacy <code>sources.list</code> lines, modern deb822 <code>.sources</code> files, GPG keyrings under <code>/etc/apt/keyrings/</code> for <code>signed-by=</code>, and Ubuntu PPAs. The file-managing operations all use a <strong>spool-and-install rewrite</strong> — read the current text, apply a pure transform, scp the result to <code>/tmp</code>, install it with one elevated <code>sh -c</code> — so comments, ordering, and unrelated lines survive verbatim and nothing ever runs <code>sed -i</code> on a protected file.</li>
<li><strong>Standalone</strong> <code>.deb</code> <strong>files</strong> — install or remove a package archive already on the target (upload one with <code>files.put</code> first), version-aware and idempotent.</li>
<li><strong>The stateless refresh</strong> — <code>apt-get update</code> as its own step, so the list-refresh is separately plannable from anything that consumes it.</li>
</ul>
<p>The facts cover the same ground from the read side: what is installed, what would upgrade, what would autoremove, what is held back, which version <code>apt install</code> would pick and from which repository, which sources are configured (parsed from both the legacy and the deb822 formats), which keyrings are present, and what a dry-run <code>apt-get</code> proposes to change. Every list fact returns sorted, de-duplicated, JSON-printable typed entries — not a scraped string — so your application can consume them directly.</p>
<h2>The <code>systemd</code> module: Ubuntu's init as Python</h2>
<p>systemd manages services along <strong>two independent axes</strong>: whether a unit is <em>running</em> right now, and whether it is <em>enabled</em> at boot. A unit can be enabled but stopped, or running but not enabled. The <code>systemd</code> namespace handles both in one operation, and goes well beyond services.</p>
<p>The operations cover three kinds of systemd work:</p>
<ul>
<li><strong>Unit state</strong> — one service operation converges <code>running</code> and <code>enabled</code> from their facts and yields <code>systemctl start</code>/<code>stop</code>/<code>enable</code>/<code>disable</code> only on a difference, alongside the non-idempotent verbs (<code>restart</code>, <code>reload</code>, <code>reload-or-try-restart</code>, <code>reset-failed</code>) for when you mean it. A separate <code>daemon-reload</code> operation keeps the reload step plannable on its own.</li>
<li><strong>Unit-file configuration</strong> — drop-ins under <code>/etc/systemd/system/&lt;unit&gt;.d/</code>, plus <code>/etc/tmpfiles.d/*.conf</code> and <code>/etc/sysusers.d/*.conf</code>. All three ride the same <strong>spool-and-install</strong> rewrite <code>apt.repo</code> uses, so the stock unit file stays pristine and every write is one unprivileged scp followed by one elevated <code>sh -c</code>.</li>
<li><strong>Unit policy</strong> — mask a unit to <code>/dev/null</code> so nothing can start it, apply the systemd preset policy, or clear failed state without also starting the unit.</li>
</ul>
<p>The facts cover the audit view: per-unit running and enabled state, unit-file presence, unit properties and dependencies, failed units, scheduled timers, and the systemd version. Every <code>systemctl</code>-based fact and operation also accepts scope flags (<code>user_mode</code>, <code>machine</code>, <code>user_name</code>) for the per-user manager or a named machine, so the same code drives a system unit or a user unit.</p>
<h3>Install a package, then ensure its service — verified</h3>
<p>The classic Ubuntu post-install move is <em>install the daemon, then enable and start it</em> — nginx for a web tier, say. In reemote that is <code>apt.packages</code> followed by <code>systemd.service</code>, composed in one deploy:</p>
<pre><code class="language-python">def deploy(host):
    # 1. Install nginx (ships /lib/systemd/system/nginx.service).
    yield op(apt.packages,
             name="Install nginx",
             packages=("nginx",),
             update=True,
             sudo=True)
    # 2. Enable and start its systemd unit.
    yield op(systemd.service,
             name="Run nginx",
             service="nginx",
             running=True,
             enabled=True,
             sudo=True)
</code></pre>
<p>The ordering is the point. The apply phase runs in <strong>lockstep</strong> — one request index at a time across all hosts — so every host finishes the install before any host begins the service step. The <code>systemd.service</code> request therefore always sees the <code>nginx.service</code> unit file the install just delivered. There is no combined "ensure" operation to maintain; the two halves compose, and each is idempotent against its own facts.</p>
<p>First run against an Ubuntu host with systemd as PID 1:</p>
<pre><code class="language-text">== Plan ==
ubuntu-demo: Install nginx [changed: 1 package(s) to install]
ubuntu-demo: Run nginx     [changed: nginx.service not running (inactive), will start; nginx.service not enabled, will enable]
== Applied ==
ubuntu-demo: Install nginx [changed: 1 package(s) to install]
    $ sudo apt-get update
    $ sudo DEBIAN_FRONTEND=noninteractive apt-get -y install nginx
ubuntu-demo: Run nginx     [changed: nginx.service not running (inactive), will start; nginx.service not enabled, will enable]
    $ sudo systemctl start nginx.service
    $ sudo systemctl enable nginx.service
</code></pre>
<p>Run the identical script again against the <em>same</em> host and it converges — this is the <code>[no change: ...]</code> the throwaway quickstart could not show:</p>
<pre><code class="language-text">== Plan ==
ubuntu-demo: Install nginx [no change: all packages present]
ubuntu-demo: Run nginx     [no change: nginx.service already running; nginx.service already enabled]
== Applied ==
ubuntu-demo: Install nginx [no change: all packages present]
ubuntu-demo: Run nginx     [no change: nginx.service already running; nginx.service already enabled]
</code></pre>
<p>Two Ubuntu-specific honesty notes:</p>
<ul>
<li><strong>The stock</strong> <code>ubuntu:24.04</code> <strong>container image does not run systemd as PID 1</strong> — <code>systemctl start</code>/<code>enable</code> refuse to operate with "System has not been booted with systemd as init system". The quickstart at the top deliberately sticks to <code>apt.packages</code> for exactly that reason. To exercise the systemd half locally, use a systemd-capable image (<code>jrei/systemd-ubuntu:24.04</code> or your own <code>docker run --privileged ... /sbin/init</code> recipe); on a real Ubuntu VM or bare-metal host systemd is PID 1 and everything above just works.</li>
<li><code>sudo</code> <strong>is uniform on Ubuntu.</strong> Unlike Alpine (no <code>sudo</code>, only <code>doas</code>), Ubuntu ships <code>sudo</code> preinstalled and the first user is in the <code>sudo</code> group, so <code>sudo=True</code> on a request is the whole elevation story. Reemote renders <code>sudo &lt;command&gt;</code> on the descriptor and never needs a password prompt: use passwordless sudoers, or an SSH key for a login user with <code>NOPASSWD</code>, or <code>su=True</code> if you would rather reemote escalate through <code>su</code>.</li>
</ul>
<h2>A day-one Ubuntu playbook, in Python</h2>
<p>Here is the routine post-install set — the tasks an Ubuntu playbook always opens with — as one reemote deploy. No YAML, no Jinja2, no handlers section:</p>
<pre><code class="language-python">def deploy(host):
    # (1) Patch the box: refresh lists, then upgrade everything the
    #     simulation says would move.
    yield op(apt.update, name="Refresh package lists", sudo=True)
    yield op(apt.upgrade, name="Apply pending upgrades", sudo=True)

    # (2) Baseline tooling every Ubuntu server wants.
    yield op(apt.packages, name="Install baseline tools",
             packages=("curl", "git", "htop", "unattended-upgrades"),
             sudo=True)

    # (3) Web tier: install nginx, then enable and start it.
    yield op(apt.packages, name="Install nginx",
             packages=("nginx",), sudo=True)
    yield op(systemd.service, name="Run nginx", service="nginx",
             running=True, enabled=True, sudo=True)

    # (4) Time sync — Ubuntu ships systemd-timesyncd already enabled;
    #     chrony is the drop-in when you need NTP-server behaviour.
    yield op(apt.packages, name="Install chrony",
             packages=("chrony",), sudo=True)
    yield op(systemd.service, name="Run chrony", service="chrony",
             running=True, enabled=True, sudo=True)

    # (5) Hardening: a drop-in that tightens sshd, then a reload — the
    #     "handler" is just the next request in the same generator.
    yield op(systemd.dropin, name="Harden sshd",
             filename="10-reemote.conf", unit="sshd.service",
             content="[Service]\nPermitRootLogin=no\n",
             sudo=True)
    yield op(systemd.daemon_reload, name="Reload systemd after drop-in",
             sudo=True)
    yield op(systemd.service, name="Restart sshd", service="sshd",
             restarted=True, sudo=True)
</code></pre>
<p>Task <strong>(5)</strong> is the one that shows off the library shape most. In Ansible the drop-in is a <code>template</code> plus a <code>notify: restart sshd</code> handler, and the handler fires only if the template reported <code>changed</code>. In reemote the "handler" is ordinary Python: run the config deploy, read its <code>report</code>, freeze the set of hosts that actually changed with <code>reemote.handlers.changed_hosts</code>, and feed that set into a second deploy that calls <code>systemd.service("sshd", restarted=True)</code>. No <code>notify</code> keyword, no handler-ordering rules to memorise — just the structured result you already have.</p>
<h2>What follows: Reemote can manage Ubuntu instead of Ansible</h2>
<p>Once configuration management is a library, everything Python already has becomes available to your Ubuntu deploys:</p>
<ul>
<li><strong>Deploy from your application.</strong> <code>await run(inventory, deploy)</code> inside a FastAPI handler, a Celery task, a Python GitHub Action, or a REPL.</li>
<li><strong>Compose with real code.</strong> Build the inventory from a database query; generate config text from a Pydantic model; share validation between the app and the deploy.</li>
<li><strong>Test with</strong> <code>pytest</code><strong>.</strong> Deploy functions are pure generators — assert on the requests they yield without a server in sight.</li>
<li><strong>Consume structured results.</strong> <code>report.entries[i].changed</code>, <code>.reason</code>, <code>.commands</code> are data you can log, chart, feed to a webhook, or gate a rollout on.</li>
<li><strong>Two-phase execution for free.</strong> Every <code>run</code> prepares then applies; <code>dry=True</code> is a real preview against live facts, not a simulation.</li>
<li><strong>Same code over SSH or Docker.</strong> Flip the <code>connector</code> key and the identical deploy runs against an Ubuntu VM or a local container — test the role before you point it at production, or build an image from the same code that configures a host.</li>
<li><strong>Handlers as ordinary Python.</strong> Read the first deploy's report, freeze the changed hosts, feed them into a second deploy. <code>changed_hosts</code> and <code>restart_handler</code> ship for the common case.</li>
</ul>
<p>You can use reemote instead of Ansible whenever managing Ubuntu is part of a Python program rather than a shell command in a CI job — which, for most teams building products today, is the more natural shape. And on Ubuntu specifically, you keep <code>apt</code>'s and <code>systemd</code>'s own vocabularies intact rather than flattening them through a lowest-common-denominator module API.</p>
<h2>What Reemote is not</h2>
<ul>
<li><strong>Not a deployment CLI.</strong> There is no <code>reemote</code> command; you write the script.</li>
<li><strong>Not an inventory format.</strong> Inventory is a Python list of dicts — load it from JSON, YAML, a database, or write it inline.</li>
<li><strong>Not a role ecosystem.</strong> Reemote ships modules for <code>apt</code>, <code>systemd</code>, <code>apk</code>, <code>openrc</code>, <code>dnf</code>, <code>files</code>, <code>server</code>, <code>git</code>, <code>mysql</code>, <code>postgresql</code>, <code>iptables</code>, <code>firewalld</code>, <code>ufw</code>, <code>selinux</code>, <code>lxd</code>, <code>snap</code>, <code>flatpak</code>, <code>sysctl</code>, <code>gpg</code>, and <code>hardware</code> — but there is no Ansible Galaxy equivalent.</li>
<li><strong>Not a translation layer between OSes.</strong> Python does not make <code>nginx</code> installable by the same name on Ubuntu and Alpine. You still pick the right module for the target — and on Ubuntu that means <code>apt</code> and <code>systemd</code>, which reemote ships as first-class citizens.</li>
</ul>
<h2>Try it</h2>
<ul>
<li>Documentation and full guide: <a href="https://reemote.org/">https://reemote.org/</a></li>
<li>Source: <a href="https://github.com/kimjarvis/reemote">https://github.com/kimjarvis/reemote</a></li>
<li>PyPI: <code>pip install reemote</code></li>
<li>License: EPL-2.0</li>
</ul>
<p>If you have ever wanted to describe an Ubuntu box's state in the same language as the rest of your stack — with imports, types, tests, and <code>await</code>, and without translating <code>apt</code>'s and <code>systemd</code>'s vocabularies through YAML — the docker quickstart at the top of this article is the whole onboarding. Give it a minute.</p>
]]></content:encoded></item><item><title><![CDATA[Managing Alpine Linux from Python: Reemote's apk and openrc modules]]></title><description><![CDATA[Ansible manages Alpine through a CLI, a YAML playbook, and a Python-bootstrap dance the target never asked for. Reemote is a Python library that configures…]]></description><link>https://reemote.hashnode.dev/managing-alpine-linux-from-python-reemote-s-apk-and-openrc-modules</link><guid isPermaLink="true">https://reemote.hashnode.dev/managing-alpine-linux-from-python-reemote-s-apk-and-openrc-modules</guid><dc:creator><![CDATA[kimjarvis]]></dc:creator><pubDate>Fri, 13 Nov 2026 11:52:54 GMT</pubDate><content:encoded><![CDATA[<p><em>Ansible manages Alpine through a CLI, a YAML playbook, and a Python-bootstrap dance the target never asked for. Reemote is a Python</em> <em><strong>library</strong></em> <em>that configures Alpine over a plain shell —</em> <code>apk</code> <em>and OpenRC included — and hands you the result as data you can</em> <code>await</code><em>, inspect, and test. The whole thing runs in a throwaway container in under a minute.</em></p>
<h2>The problem: configuration management on Alpine</h2>
<p>Alpine runs the world's containers, a lot of its edge boxes, and every minimal VM someone cared enough to slim down. It is not Debian and not RHEL, and three things make it its own animal:</p>
<ul>
<li><strong>apk</strong> (Alpine Package Keeper), not <code>apt</code> or <code>dnf</code>.</li>
<li><strong>OpenRC</strong>, not systemd. A service is an <code>/etc/init.d</code> script managed at runtime with <code>rc-service</code>; a <em>runlevel</em> is a directory of symlinks under <code>/etc/runlevels</code> managed with <code>rc-update</code>.</li>
<li><strong>musl libc</strong> and a deliberately minimal userland — a fresh Alpine ships <strong>no Python at all</strong>.</li>
</ul>
<p>That last point is where Ansible hurts first. Ansible executes by pushing modules to a <strong>Python interpreter on the managed node</strong>. Every mainstream distro already has one; Alpine does not. So the very first task of every Alpine playbook is a bootstrap you would never need anywhere else — the one module that does <em>not</em> require Python, <code>raw</code>, sneaking an interpreter onto the box so Ansible can then talk to it:</p>
<pre><code class="language-yaml">- name: Install Python 3 on Alpine          # a whole task just to make Ansible work
  ansible.builtin.raw: test -x /usr/bin/python3 || apk add --no-cache python3
</code></pre>
<p>Everything after that is a translation layer. <code>community.general.apk</code> for packages. <code>ansible.builtin.service</code> quietly becoming <code>rc-service</code>/<code>rc-update</code> behind your back. <code>lineinfile</code> edits to <code>/etc/apk/repositories</code>. It works — but you are describing a Python program's worth of logic in YAML, for a target you first had to bootstrap into having Python, driven by a CLI whose result is terminal output you scrape with a regex.</p>
<p>That is the gap: <strong>there is no serious configuration-management library you can</strong> <code>import</code> <strong>and point at an Alpine box.</strong></p>
<h2>Try it in under a minute (docker connector)</h2>
<p>Reemote ships a <strong>docker connector</strong> that runs your deploy inside a local container, so you need neither a remote host nor an SSH key to see it work. <strong>You do need Docker or Podman installed locally</strong> — the connector shells out to whichever CLI you name, and there is no default. Three commands:</p>
<pre><code class="language-bash"># 1. install reemote into a fresh venv (Python 3.13+; pip works just as well as uv)
uv venv .venv &amp;&amp; source .venv/bin/activate &amp;&amp; uv pip install reemote
# 2. save the script below as alpine_demo.py
# 3. run it — starts a throwaway alpine:3.20 container, configures it, cleans up
python alpine_demo.py
</code></pre>
<pre><code class="language-python"># alpine_demo.py
import asyncio
from reemote import apk, format_log, format_plan, op, run

INVENTORY = [{
    "host": "alpine-demo",
    "connection": {
        "connector": "docker",
        "identifier": "alpine:3.20",
        "docker_cmd": "podman",   # or "docker"
    },
}]

def deploy(host):
    # The first thing you always do on a fresh Alpine: install packages.
    yield op(apk.packages,
             name="Install baseline packages",
             packages=("chrony", "curl"),
             no_cache=True)

async def main():
    plans, report, _ = await run(INVENTORY, deploy)
    print("== Plan ==")
    print(format_plan(plans))
    print("== Applied ==")
    if report is not None:
        print(format_log(report))

asyncio.run(main())
</code></pre>
<p>The most common first task after dropping onto a fresh Alpine is the one Ansible itself reaches for first: <strong>install packages with</strong> <code>apk</code>. Verified output:</p>
<pre><code class="language-text">== Plan ==
alpine-demo: Install baseline packages [changed: 2 package(s) to install]
== Applied ==
alpine-demo: Install baseline packages [changed: 2 package(s) to install]
    $ apk add --no-cache chrony curl
</code></pre>
<p>Three things worth noticing, because they are exactly where Alpine + Reemote differ from Alpine + Ansible:</p>
<ul>
<li><strong>No Python bootstrap.</strong> Reemote talks to a <strong>shell</strong> over <code>sshd</code> (or <code>docker exec</code>), not to a Python interpreter on the target. The <code>raw: apk add python3</code> task simply does not exist here. Under the hood that <code>$ apk add ...</code> line ran as <code>podman exec -i &lt;container&gt; sh -c 'apk add --no-cache chrony curl'</code> — the transport wrapping is invisible in the log by design, so the same deploy reads identically over SSH.</li>
<li><strong>No</strong> <code>-y</code><strong>, no</strong> <code>sudo</code><strong>.</strong> apk never prompts, so reemote never adds a <code>-y</code> flag (unlike apt and dnf). And the container's exec user is already root, so nothing needed escalating. On a real Alpine host — which ships <strong>no</strong> <code>sudo</code> — you would flag the request <code>doas=True</code> and reemote would run a passwordless <code>doas -n apk add ...</code>.</li>
<li><code>no_cache=True</code> <strong>is an apk-native modifier.</strong> It renders <code>--no-cache</code>, the container-idiomatic "don't leave an index behind" flag. Reemote exposes apk's own vocabulary rather than flattening it into a lowest-common-denominator package API.</li>
</ul>
<p>One honesty note, the same one the throwaway-container workflow always carries: every run reports <code>[changed: ...]</code>, even the second time, because the image target starts a <em>fresh</em> <code>alpine:3.20</code> container on each invocation, so the packages genuinely are absent each time. Point reemote at a stable target — an SSH host, or an existing container ID — and the second run correctly reports <code>[no change: all packages present]</code>. You will see exactly that below.</p>
<h2>Why Python has real advantages over Ansible</h2>
<p>Ansible chose YAML because it is readable and does not require knowing a programming language. That is a fair trade when your team does not write Python. When it does — and most automation teams today do — the trade costs more than it saves, and on Alpine the bill is higher still:</p>
<ul>
<li><strong>The target does not need Python.</strong> Ansible's whole execution model assumes an interpreter on the managed node. Reemote assumes a POSIX shell and <code>sshd</code>. On a minimal Alpine that is the difference between a bootstrap task and none.</li>
<li><strong>Your editor knows your code.</strong> <code>apk.packages</code> and <code>openrc.service</code> are functions: go-to-definition, rename, autocomplete, and a type check that catches a typo before it reaches a production box. A misspelled YAML module name surfaces at runtime.</li>
<li><strong>No Jinja2 as a second language.</strong> <code>{{ hostvars[...] }}</code> is a stringly-typed escape hatch that fights your IDE and your tests. In reemote the value is just a Python expression.</li>
<li><strong>Composition is</strong> <code>import</code><strong>, not directory layout.</strong> Roles load by convention; reemote deploys are generators you import, call, and loop over.</li>
<li><strong>The result is data, not stdout.</strong> If your application needs to know what changed, you read <code>report.entries[i].changed</code> — you do not regex-scrape a terminal.</li>
<li><strong>Testing is</strong> <code>pytest</code><strong>.</strong> A deploy function is a pure generator; you can inspect the requests it yields without touching a server. No molecule, no testinfra, no ansible-lint.</li>
</ul>
<h2>The solution: Reemote is a library</h2>
<p>Reemote is a Python package (<code>pip install reemote</code>) with two properties that separate it from every CLI-shaped tool.</p>
<h3>Async end to end, on asyncssh</h3>
<p>The transport is <a href="https://asyncssh.readthedocs.io/">asyncssh</a>, and the entire execution path — connecting, gathering facts, sending commands, closing — is coroutines. You <code>await</code> a deploy and hosts progress in parallel; network waits overlap across hosts while each host's commands stay in order. Your deploy script remains a plain synchronous generator, so you never write <code>async</code> inside an operation.</p>
<pre><code class="language-python">plans, report, hosts = await run(inventory, deploy)
</code></pre>
<p><code>run</code> is the single entry point: it validates the inventory, opens connections lazily, plans and applies in two phases, then closes everything in a <code>finally</code>. The <strong>same code</strong> runs over SSH or over the docker connector — you pick the transport in the inventory entry, not in the deploy. That is why you can develop an Alpine role against a local container and ship it to a VM without changing a line.</p>
<h3>A library returning structured results, not a CLI</h3>
<p><code>run(...)</code> returns <code>(plans, report, hosts)</code>:</p>
<ul>
<li><code>plans</code> — <code>{host_label: [PlannedOperation, ...]}</code>, the proposed work per host.</li>
<li><code>report</code> — an <code>ApplyReport</code> with a <code>LogEntry</code> per applied operation, each carrying <code>changed</code>, <code>reason</code>, and the exact commands sent. <code>None</code> when <code>dry=True</code>.</li>
<li><code>hosts</code> — the materialised host objects.</li>
</ul>
<p><code>format_plan</code> and <code>format_log</code> (what you saw above) are convenience renderers for humans. Your application consumes the objects directly — no parsing stdout to figure out what happened.</p>
<h2>The <code>apk</code> module: Alpine's package manager as Python</h2>
<p><code>apk</code> pairs read-only <strong>facts</strong> with <strong>operations</strong> that converge the box toward a described state. An operation reads a fact, decides whether anything must change, and yields commands only when it must — so re-running is a no-op.</p>
<p><strong>Operations</strong></p>
<ul>
<li><code>apk.packages</code> — install, upgrade, or remove by name. A name can be pinned <code>&lt;pkg&gt;=&lt;version&gt;</code>, and a pin counts as present only when that <em>exact</em> version is installed. <code>present=False</code> removes; <code>latest=True</code> upgrades unpinned names; <code>no_cache</code>/<code>no_scripts</code> are the apk-native <code>--no-cache</code>/<code>--no-scripts</code> modifiers. Idempotence reads <code>ApkInstalledPackages</code>.</li>
<li><code>apk.repository</code> — ensure one line in <code>/etc/apk/repositories</code>. This is the day-one "enable the community repo" task, done as a <strong>spool-and-install rewrite</strong> of the file (never an in-place <code>sed -i</code>), so the other lines, comments, and ordering survive verbatim. A <code>path</code> parameter manages a non-standard repositories file.</li>
<li><code>apk.virtual</code> — manage an apk <em>virtual package</em> group (<code>apk add -t &lt;tag&gt; ...</code>), the build-dependency pattern a compile step wraps itself in so its toolchain does not linger on the finished image; tear it down with one <code>apk del &lt;tag&gt;</code>.</li>
<li><code>apk.hold</code> — pin packages back from upgrades. apk has no <code>apt-mark hold</code>; it holds by writing a version <em>constraint</em> into <code>/etc/apk/world</code>, and that is exactly what this does, with version-aware idempotence.</li>
<li><code>apk.autoremove</code> — purge the orphaned packages nothing in world needs any more (<code>apk add --purge</code>). apk has no <code>autoremove</code> verb, so reemote splits the <code>--purge</code> flag into a standalone, separately-plannable step.</li>
</ul>
<p><strong>Facts</strong> — <code>ApkInstalledPackages</code>, <code>ApkExplicitPackages</code> (the world), <code>ApkHeldPackages</code>, <code>ApkAutoremovablePackages</code>, <code>ApkRepositories</code>, <code>ApkRepositoriesFile</code>, and <code>ApkPackage</code> (one package's version, size, dependencies, provides). Every list fact returns sorted, de-duplicated, JSON-printable typed entries — not a scraped string.</p>
<h2>The <code>openrc</code> module: Alpine's init as Python</h2>
<p>OpenRC manages services along <strong>two independent axes</strong>: whether a service is <em>running</em> right now (<code>rc-service</code>), and whether it is <em>enabled</em> in a runlevel at boot (<code>rc-update</code>). A service can be enabled but stopped, or running but not enabled. <code>openrc.service</code> handles both in one operation.</p>
<p><strong>Operations</strong></p>
<ul>
<li><code>openrc.service</code> — <code>running=True/False/None</code> compares <code>OpenrcStatus</code> and starts/stops only on a difference; <code>enabled=True/False/None</code> compares <code>OpenrcEnabled</code> for a <code>runlevel</code> (default <code>"default"</code>) and yields <code>rc-update add</code>/<code>del</code> only on a difference; <code>restarted=True</code>/<code>reloaded=True</code> are the non-idempotent verbs, each gated on the service actually running.</li>
<li><code>openrc.runlevel</code> — the dedicated runlevel-membership verb: assign or unassign a service <em>without</em> touching its runtime state.</li>
<li><code>openrc.custom_runlevel</code> — manage the runlevel directories themselves, including <strong>stacking</strong> (<code>rc-update -s add</code>). Removal unstacks from every target first, then deletes the directory, so no dangling symlink survives.</li>
<li><code>openrc.config</code> — set or unset one shell-variable assignment in <code>/etc/rc.conf</code> or <code>/etc/conf.d/&lt;service&gt;</code>, via the same spool-and-install rewrite as <code>apk.repository</code>.</li>
</ul>
<p><strong>Facts</strong> — <code>OpenrcStatus</code> (name → running bool), <code>OpenrcEnabled</code> (name → enabled-in-runlevel bool), <code>OpenrcServices</code> (the <em>raw</em> state word, so a <code>crashed</code> service is distinguishable from a <code>stopped</code> one), <code>OpenrcRunlevels</code>, <code>OpenrcVersion</code>, and <code>OpenrcConfigFile</code>.</p>
<p>One honest boundary: there is <strong>no</strong> <code>openrc.mask</code>. OpenRC has no masking mechanism (no systemd-style symlink-to-<code>/dev/null</code>), so reemote does not pretend to emulate one — disable the service in every runlevel instead.</p>
<h3>Install OpenRC, then use it — self-contained and verified</h3>
<p>The classic Alpine post-install move is <em>install the daemon, then enable and start it</em> — chrony for time sync, say. In reemote that is <code>apk.packages</code> followed by <code>openrc.service</code>. There is one wrinkle worth understanding first, because it is the two-phase model showing through:</p>
<p><strong>You cannot install OpenRC and read OpenRC facts in the same deploy.</strong> <code>run()</code> gathers facts for <em>every</em> operation during the prepare pass, before the apply pass sends a single command. So a one-deploy <code>apk.packages(("openrc", …))</code> → <code>openrc.service(…)</code> dies at plan time: <code>openrc.service</code> runs <code>rc-status</code> / <code>rc-update show -v</code> while OpenRC is not installed yet → exit 127 → <code>CommandNotFoundError</code>.</p>
<p>The fix is two <code>run()</code> calls — and the docker connector keeps it <strong>fully self-contained</strong>: no manual <code>podman run</code>, no custom Dockerfile. Run 1 installs OpenRC into a throwaway <code>alpine:3.20</code> container; on close the connector <strong>commits that container into a new image</strong> and hands you its short ID as <code>hosts[0].image_id</code>. Run 2 targets that committed image, so OpenRC already exists when its prepare pass reads the facts:</p>
<pre><code class="language-python"># alpine_openrc.py
import asyncio
from reemote import apk, format_log, format_plan, openrc, op, run

DOCKER_CMD = "podman"   # or "docker"


def conn(identifier):
    return {"host": "alpine-demo",
            "connection": {"connector": "docker",
                           "identifier": identifier,
                           "docker_cmd": DOCKER_CMD}}


def bootstrap(host):
    # Run 1 -- install OpenRC itself. apk is in the stock image, so this
    # deploy's prepare stage gathers facts fine. On close the docker connector
    # commits the container into a new image, OpenRC baked in.
    yield op(apk.packages, name="Install openrc",
             packages=("openrc",), no_cache=True)


def deploy(host):
    # Run 2 -- targets the committed image, so OpenRC already exists and this
    # prepare stage can read rc-status/rc-update without a CommandNotFoundError.
    yield op(apk.packages, name="Install chrony",
             packages=("chrony",), no_cache=True)
    yield op(openrc.service, name="Enable chronyd at boot",
             service="chronyd", running=None, enabled=True)


async def main():
    # Run 1: bootstrap OpenRC into a throwaway alpine:3.20 container.
    plans, report, hosts = await run([conn("alpine:3.20")], bootstrap)
    print("== Run 1: install OpenRC ==")
    print(format_plan(plans))
    if report is not None:
        print(format_log(report))

    # Chain: run 2 starts from the image run 1 committed.
    image_id = hosts[0].image_id
    print(f"(committed image: {image_id})")
    plans, report, _ = await run([conn(image_id)], deploy)
    print("== Run 2: install chrony + enable chronyd ==")
    print(format_plan(plans))
    if report is not None:
        print(format_log(report))

asyncio.run(main())
</code></pre>
<p>Verified output (the committed image ID is ephemeral — yours will differ):</p>
<pre><code class="language-text">== Run 1: install OpenRC ==
alpine-demo: Install openrc           [changed: 1 package(s) to install]
alpine-demo: Install openrc           [changed: 1 package(s) to install]
    $ apk add --no-cache openrc
(committed image: 37a2428674df)
== Run 2: install chrony + enable chronyd ==
alpine-demo: Install chrony           [changed: 1 package(s) to install]
alpine-demo: Enable chronyd at boot   [changed: service chronyd is not enabled, will enable]
alpine-demo: Install chrony           [changed: 1 package(s) to install]
    $ apk add --no-cache chrony
alpine-demo: Enable chronyd at boot   [changed: service chronyd is not enabled, will enable]
    $ rc-update add chronyd default
</code></pre>
<p>Each operation prints twice — once from <code>format_plan</code> (the proposal), once from the <code>format_log</code> apply report (with the <code>$ …</code> commands actually sent). Within run 2 the ordering is the point: the apply phase runs in <strong>lockstep</strong>, one request index at a time across all hosts, so every host finishes the <code>chrony</code> install before any host begins the service step. <code>openrc.service</code> therefore always sees the <code>/etc/init.d/chronyd</code> script the install just delivered. There is no combined "ensure" operation to maintain — the halves compose, and each is idempotent against its own facts.</p>
<p>A throwaway image target starts fresh every time, so re-running the whole script always reports <code>[changed: …]</code>. Point run 2 at a <strong>stable</strong> target instead — an SSH host, or an existing container ID — and re-running it converges, which is the <code>[no change: …]</code> the quickstart could not show:</p>
<pre><code class="language-text">alpine-demo: Install chrony           [no change: all packages present]
alpine-demo: Enable chronyd at boot   [no change: service chronyd is enabled]
</code></pre>
<p>Two Alpine-specific honesty notes:</p>
<ul>
<li><strong>The stock</strong> <code>alpine</code> <strong>image ships no OpenRC</strong> — <code>rc-service</code>/<code>rc-update</code> are absent until you <code>apk add openrc</code>, which is exactly what run 1 does. On a real Alpine VM or bare-metal box OpenRC <em>is</em> the init system and is always present, so there you skip the bootstrap run and keep only run 2.</li>
<li><code>running=True</code> <strong>(an actual</strong> <code>rc-service chronyd start</code><strong>) needs OpenRC to be PID 1</strong>, which a plain <code>docker run ... tail -f /dev/null</code> container is not. That is why this example uses <code>running=None, enabled=True</code> — the boot-assignment half (<code>rc-update</code>) is init-independent and works anywhere. On a real Alpine host, add <code>running=True</code> and reemote will start the daemon too.</li>
</ul>
<p>On a real host both halves write root-owned state, so flag them <code>doas=True</code>: Alpine has no <code>sudo</code>, and reemote runs <code>doas -n</code>, which needs a <code>permit nopass</code> rule for your login user in <code>/etc/doas.conf</code>.</p>
<h2>A day-one Alpine playbook, in Python</h2>
<p>Here is the routine post-install set — the tasks an Alpine playbook always opens with — as one reemote deploy. No Python bootstrap, no YAML, no Jinja2:</p>
<pre><code class="language-python">def deploy(host):
    # (1) There is no "install Python so the tool works" step: reemote needs
    #     only a shell + sshd on the target.

    # (2) Enable the community repository (spool-and-install rewrite of the file).
    yield op(apk.repository, name="Enable community repo",
             src="http://dl-cdn.alpinelinux.org/alpine/v3.20/community",
             doas=True)

    # (3) Time sync: install chrony, then enable and start chronyd.
    yield op(apk.packages, name="Install chrony",
             packages=("chrony",), doas=True)
    yield op(openrc.service, name="Run chronyd", service="chronyd",
             running=True, enabled=True, doas=True)

    # (5) Baseline daemons every Alpine box wants up and enabled.
    for svc in ("crond", "networking"):
        yield op(openrc.service, name=f"Ensure {svc}", service=svc,
                 running=True, enabled=True, doas=True)

    # (6) Resolvers: a plain content install, mode enforced.
    yield op(files.install, name="Write resolv.conf",
             content="nameserver 1.1.1.1\nnameserver 8.8.8.8\n",
             dest="/etc/resolv.conf", mode="0644", doas=True)
</code></pre>
<p>Task <strong>(4), hardening</strong> <code>sshd_config</code>, is the one that shows off the library shape most. In Ansible it is a <code>lineinfile</code> plus a <code>notify: restart sshd</code> handler. In reemote it is <code>files.edit</code> (or <code>files.transform</code>) rewriting the <code>PermitRootLogin</code> line, and the "handler" is ordinary Python: run the config deploy, read its <code>report</code>, freeze the set of hosts that actually changed with <code>reemote.handlers.changed_hosts</code>, and feed that set into a second deploy that calls <code>openrc.service("sshd", restarted=True)</code>. No <code>notify</code> keyword, no handler-ordering rules to memorise — just the structured result you already have.</p>
<h2>What follows: Reemote can manage Alpine instead of Ansible</h2>
<p>Once configuration management is a library, everything Python already has becomes available to your Alpine deploys:</p>
<ul>
<li><strong>Deploy from your application.</strong> <code>await run(inventory, deploy)</code> inside a FastAPI handler, a Celery task, a Python GitHub Action, or a REPL.</li>
<li><strong>Compose with real code.</strong> Build the inventory from a database query; generate config text from a Pydantic model; share validation between the app and the deploy.</li>
<li><strong>Test with</strong> <code>pytest</code><strong>.</strong> Deploy functions are pure generators — assert on the requests they yield without a server in sight.</li>
<li><strong>Consume structured results.</strong> <code>report.entries[i].changed</code>, <code>.reason</code>, <code>.commands</code> are data you can log, chart, feed to a webhook, or gate a rollout on.</li>
<li><strong>Two-phase execution for free.</strong> Every <code>run</code> prepares then applies; <code>dry=True</code> is a real preview against live facts, not a simulation.</li>
<li><strong>Same code over SSH or Docker.</strong> Flip the <code>connector</code> key and the identical deploy runs against an Alpine VM or a local container — test the role before you point it at production, or build an image from the same code that configures a host.</li>
<li><strong>Handlers as ordinary Python.</strong> Read the first deploy's report, freeze the changed hosts, feed them into a second deploy. <code>changed_hosts</code> and <code>restart_handler</code> ship for the common case.</li>
</ul>
<p>You can use reemote instead of Ansible whenever managing Alpine is part of a Python program rather than a shell command in a CI job — which, for most teams building products today, is the more natural shape. And on Alpine specifically, you drop the Python-bootstrap task entirely.</p>
<h2>What Reemote is not</h2>
<ul>
<li><strong>Not a deployment CLI.</strong> There is no <code>reemote</code> command; you write the script.</li>
<li><strong>Not an inventory format.</strong> Inventory is a Python list of dicts — load it from JSON, YAML, a database, or write it inline.</li>
<li><strong>Not a role ecosystem.</strong> Reemote ships modules for <code>apk</code>, <code>openrc</code>, <code>apt</code>, <code>dnf</code>, <code>files</code>, <code>systemd</code>, <code>server</code>, <code>git</code>, <code>mysql</code>, <code>postgresql</code>, <code>iptables</code>, <code>firewalld</code>, <code>ufw</code>, <code>selinux</code>, <code>lxd</code>, <code>snap</code>, <code>flatpak</code>, <code>sysctl</code>, <code>gpg</code>, and <code>hardware</code> — but there is no Ansible Galaxy equivalent.</li>
<li><strong>Not a translation layer between OSes.</strong> Python does not make <code>nginx</code> installable by the same name on Alpine and Debian. You still pick the right module for the target — and on Alpine that means <code>apk</code> and <code>openrc</code>, which reemote ships as first-class citizens.</li>
</ul>
<h2>Try it</h2>
<ul>
<li>Documentation and full guide: <a href="https://reemote.org/">https://reemote.org/</a></li>
<li>Source: <a href="https://github.com/kimjarvis/reemote">https://github.com/kimjarvis/reemote</a></li>
<li>PyPI: <code>pip install reemote</code></li>
<li>License: EPL-2.0</li>
</ul>
<p>If you have ever wanted to describe an Alpine box's state in the same language as the rest of your stack — with imports, types, tests, and <code>await</code>, and without bootstrapping Python onto the target just to configure it — the docker quickstart at the top of this article is the whole onboarding. Give it a minute.</p>
]]></content:encoded></item><item><title><![CDATA[Reemote: configuration management as a Python library]]></title><description><![CDATA[Ansible is a CLI. Pyinfra is a CLI. Salt is a CLI. Chef and Puppet have their own DSLs. There is no serious configuration-management library you can import…]]></description><link>https://reemote.hashnode.dev/reemote-configuration-management-as-a-python-library</link><guid isPermaLink="true">https://reemote.hashnode.dev/reemote-configuration-management-as-a-python-library</guid><dc:creator><![CDATA[kimjarvis]]></dc:creator><pubDate>Fri, 13 Nov 2026 08:28:36 GMT</pubDate><content:encoded><![CDATA[<p><em>Ansible is a CLI. Pyinfra is a CLI. Salt is a CLI. Chef and Puppet have their own DSLs. There is no serious configuration-management library you can</em> <code>import</code> <em>from Python — until now.</em></p>
<h2>The problem</h2>
<p>Python is the language of automation glue: CI, dashboards, data pipelines, deploy orchestration. But the moment you want to describe the state of a server, you leave Python behind. You write YAML for Ansible. You write Ruby for Chef. You write Puppet DSL for Puppet. Then you invoke a binary that parses, plans, and executes for you.</p>
<p>There is no serious configuration-management <strong>library</strong> for Python. Nothing you can <code>import</code>. Nothing that returns typed results you can inspect. Nothing you can embed inside an application, unit-test with <code>pytest</code>, or <code>await</code> from an async handler. The mainstream tools are all command-line binaries wrapped around their own task language, and if you want to trigger a deploy from Python, you end up shelling out to that binary and scraping its terminal output.</p>
<p>That is the gap.</p>
<h2>Try it in under a minute (docker connector)</h2>
<p>Reemote ships a <strong>docker connector</strong> that runs your deploy inside a local container, so you do not need a remote host or an SSH key to see it work. <strong>You do need Docker or Podman installed locally</strong> — the connector shells out to whichever CLI you name in the inventory, and there is no default. Three commands:</p>
<pre><code class="language-bash"># 1. Install (Python 3.13+; pip works just as well as uv)
uv venv .venv &amp;&amp; source .venv/bin/activate &amp;&amp; uv pip install reemote

# 2. Save the script below as hello.py, then run it
python hello.py
</code></pre>
<pre><code class="language-python"># hello.py
import asyncio
from reemote import files, format_log, format_plan, op, run

INVENTORY = [{
    "host": "demo",
    "connection": {
        "connector": "docker",
        "identifier": "alpine:3.20",
        "docker_cmd": "podman",   # or "docker"
    },
}]

def deploy(host):
    yield op(files.install,
             name="Write a greeting",
             content="Hello from reemote!\n",
             dest="/tmp/reemote-hello.txt",
             mode="0644")

async def main():
    plans, report, _ = await run(INVENTORY, deploy)
    print("== Plan ==")
    print(format_plan(plans))
    print("== Applied ==")
    if report is not None:
        print(format_log(report))

asyncio.run(main())
</code></pre>
<p>Reemote starts a throwaway <code>alpine:3.20</code> container, writes the file inside it, prints the plan and the apply log, then commits the container into a new image and removes it. Verified output:</p>
<pre><code class="language-text">== Plan ==
demo: Write a greeting         [changed: content /tmp/reemote-hello.txt is absent, will install]
== Applied ==
demo: Write a greeting         [changed: content /tmp/reemote-hello.txt is absent, will install]
    $ scp &lt;files install&gt; -&gt; /tmp/reemote-install-2e52d4f8373a608a/content
    $ sh -c 'mkdir -p /tmp &amp;&amp; cp /tmp/reemote-install-2e52d4f8373a608a/content /tmp/reemote-hello.txt &amp;&amp; chmod 644 /tmp/reemote-hello.txt &amp;&amp; rm -rf /tmp/reemote-install-2e52d4f8373a608a'
</code></pre>
<p>Two things worth noticing:</p>
<ul>
<li>The <code>$ scp</code> and <code>$ sh -c</code> lines are <strong>command descriptors rendered canonically</strong> — they look the same whether the transport is SSH, docker, or podman. Under the hood reemote is running <code>podman cp</code> and <code>podman exec -i demo sh -c '...'</code>; the transport wrapping is invisible in the log by design, so the same deploy reads the same on any target.</li>
<li>Every run reports <code>[changed: ... is absent, will install]</code>, even the second time. That is not a bug — the image workflow starts a <em>fresh throwaway container</em> from <code>alpine:3.20</code> on every invocation, so the file genuinely is absent each time. Point reemote at a stable target (an SSH host, or an existing container ID) and the second run correctly reports <code>[no change: content already up to date]</code>. Idempotence is a property of the operations against a persistent state, and the quickstart deliberately trades persistence for a zero-setup demo.</li>
</ul>
<p>That is the whole loop. Describe the state you want, hand over an inventory, <code>await</code> the result. No CLI, no playbook, no YAML.</p>
<h2>Why Python has real advantages over Ansible</h2>
<p>Ansible chose YAML because it is readable and it does not require knowing a programming language. That is a fair trade-off when your team does not write Python. When your team does — and most automation teams today do — the trade-off costs you more than it saves:</p>
<ul>
<li><strong>Your editor does not know your YAML.</strong> No go-to-definition, no rename, no reliable type check. A typo in a module name surfaces at runtime, in production.</li>
<li><strong>Jinja2 is a second language inside the first.</strong> <code>{{ hostvars[...] }}</code> is a stringly-typed escape hatch that fights your IDE and your test suite.</li>
<li><strong>Roles load by convention, not by import.</strong> Composition becomes a directory-layout puzzle rather than a <code>from ... import ...</code>.</li>
<li><strong>The result is terminal output.</strong> If your application needs to know what changed, you parse stdout with a regex and hope.</li>
<li><strong>Testing means testinfra, molecule, ansible-lint.</strong> Not <code>pytest</code>. You cannot <code>import</code> a task and inspect it.</li>
</ul>
<p>Python already solves all of these. <code>import</code> gives you composition. Types give you editor support. <code>pytest</code> gives you tests. <code>await</code> gives you concurrency. Data structures give you results you can consume programmatically. The reason configuration management did not use Python is that nobody shipped the library.</p>
<h2>The solution: Reemote is a library</h2>
<p>Reemote is a Python package (<code>pip install reemote</code>) with two properties that separate it from every CLI-shaped tool:</p>
<h3>Async end to end, on asyncssh</h3>
<p>The transport is <a href="https://asyncssh.readthedocs.io/">asyncssh</a>, and the entire execution path — connecting, gathering facts, sending commands, closing — is coroutines. You <code>await</code> a deploy and hosts progress in parallel. Within a deploy, hosts step through operations in lockstep; commands for one host run in order; network waits overlap across hosts. Your deploy script stays a plain synchronous generator, so you never write <code>async</code> inside an operation.</p>
<pre><code class="language-python">plans, report, hosts = await run(inventory, deploy)
</code></pre>
<p><code>run</code> is the single entry point. It validates the inventory, opens connections lazily, plans and applies in two phases, then closes everything in a <code>finally</code>. Same code over SSH or over the docker connector — pick the transport in the inventory entry, not in the deploy.</p>
<h3>A library returning structured results, not a CLI</h3>
<p><code>run(...)</code> returns <code>(plans, report, hosts)</code>:</p>
<ul>
<li><code>plans</code> — <code>{host_label: [PlannedOperation, ...]}</code>, the proposed work per host.</li>
<li><code>report</code> — an <code>ApplyReport</code> with a <code>LogEntry</code> per applied operation, each carrying <code>changed</code>, <code>reason</code>, and the exact commands that were sent. <code>None</code> when <code>dry=True</code>.</li>
<li><code>hosts</code> — the materialised host objects.</li>
</ul>
<p>No parsing stdout to figure out what happened. You already have the objects. <code>format_plan</code> and <code>format_log</code> are convenience renderers for humans; your application consumes the data directly.</p>
<h3>Four words to know</h3>
<ul>
<li><strong>Inventory</strong> — a list of dicts naming your targets and how to reach them.</li>
<li><strong>Fact</strong> — a read of remote state (hostname, installed packages, file contents).</li>
<li><strong>Operation</strong> — a request to converge the server toward a described state. Operations read facts, decide whether anything must change, and yield commands only when it must.</li>
<li><strong>Action</strong> — an immediate, non-deploy escape hatch (<code>Shell</code>, <code>Script</code>, <code>WaitForConnection</code>) for one-off commands.</li>
</ul>
<p>A deploy is a generator that yields operation requests in order:</p>
<pre><code class="language-python">def deploy(host):
    yield op(apt.packages, name="Install nginx",
             packages=("nginx",), sudo=True)
    yield op(files.install, name="Write landing page",
             content=PAGE, dest="/var/www/html/index.html",
             mode="0644", sudo=True)
    yield op(systemd.service, name="Run nginx",
             service="nginx", running=True, enabled=True, sudo=True)
</code></pre>
<p>Every request argument is hashable, every deploy is a pure function of its host, and the runner executes it twice — once to plan, once to apply — with a determinism guard that raises if the two passes disagree. <code>dry=True</code> stops after the plan and gives you a real preview against live facts.</p>
<h2>What follows: Reemote can replace Ansible</h2>
<p>Once configuration management is a library, everything Python already has becomes available to your deploys:</p>
<ul>
<li><strong>Deploy from your application.</strong> <code>await run(inventory, deploy)</code> inside a FastAPI handler, a Celery task, a GitHub Action written in Python, or a REPL.</li>
<li><strong>Compose with real code.</strong> Build the inventory from a database query. Generate config text from a Pydantic model. Share validation logic between the app and the deploy.</li>
<li><strong>Test with</strong> <code>pytest</code><strong>.</strong> Deploy functions are pure generators — inspect the requests they yield without touching a server.</li>
<li><strong>Consume structured results.</strong> <code>report.entries[i].changed</code>, <code>.reason</code>, <code>.commands</code> are data you can log, chart, feed to a webhook, or gate a rollout on.</li>
<li><strong>Two-phase execution for free.</strong> Every <code>run</code> prepares then applies. <code>dry=True</code> is a real preview against live state, not a simulation.</li>
<li><strong>Same code over SSH or Docker.</strong> Flip the <code>connector</code> key and the identical deploy runs against a remote server or a local container — useful for testing a role before you point it at production, or for building an image from the same code that configures a host.</li>
<li><strong>Handlers as ordinary Python.</strong> No <code>notify</code>/<code>handler</code> keyword. Read the first deploy's report, freeze the hosts that changed, feed that set into a second deploy. <code>reemote.handlers.changed_hosts</code> and <code>restart_handler</code> ship as helpers for the common case.</li>
</ul>
<p>You can use Reemote instead of Ansible whenever deployment is part of a Python program rather than a shell command in a CI job — which, for most teams building products today, is the more natural shape.</p>
<h2>What Reemote is not</h2>
<p>It is worth naming the boundaries:</p>
<ul>
<li><strong>Not a deployment tool.</strong> There is no <code>reemote</code> command. You write the script.</li>
<li><strong>Not an inventory format.</strong> Inventory is a Python list of dicts. Load it from JSON, YAML, a database, or write it inline.</li>
<li><strong>Not a role ecosystem.</strong> Reemote ships modules for <code>apt</code>, <code>dnf</code>, <code>apk</code>, <code>files</code>, <code>systemd</code>, <code>server</code>, <code>git</code>, <code>mysql</code>, <code>postgresql</code>, <code>iptables</code>, <code>firewalld</code>, <code>ufw</code>, <code>selinux</code>, <code>lxd</code>, <code>snap</code>, <code>flatpak</code>, <code>sysctl</code>, <code>gpg</code>, <code>openrc</code>, and <code>hardware</code> — but there is no Ansible Galaxy equivalent.</li>
<li><strong>Not a translation layer between OSes.</strong> Python does not make <code>nginx</code> installable by the same name on Alpine and Debian. You still pick the right module for the target.</li>
</ul>
<p>What it is, is the missing library.</p>
<h2>Try it</h2>
<ul>
<li>Documentation and full guide: <a href="https://reemote.org/">https://reemote.org/</a></li>
<li>Source: <a href="https://github.com/kimjarvis/reemote">https://github.com/kimjarvis/reemote</a></li>
<li>PyPI: <code>pip install reemote</code></li>
<li>License: EPL-2.0</li>
</ul>
<p>If you have ever wanted to describe server state in the same language as the rest of your stack — with imports, types, tests, and <code>await</code> — the docker quickstart at the top of this article is the whole onboarding. Give it a minute.</p>
]]></content:encoded></item></channel></rss>