Patching a host
pytruenas.patch is a toolkit for changing a TrueNAS host beyond what the
middleware API exposes: a systemd unit for a daemon TrueNAS has no concept of,
a file under /etc, a line added to one of the middleware's own templates.
It is a supported part of pytruenas, versioned like the rest of the package.
What TrueNAS does not support is the change itself. The middleware owns the
appliance's configuration, /usr is a read-only dataset, and an update boots
a new environment that has none of your changes. So a patch here is written to
be re-applied: after an update, after a reboot, or every time a deploy
runs. Applying it to a host that already has it changes nothing and restarts
nothing, and every piece can be undone.
What the toolkit does for you
- Change detection. Every write compares content first and returns
whether anything changed. The follow-up work --
systemctl daemon-reload, regenerating an etc group, reloading a service -- runs only when it did. - Baselines and undo. A file target with
baseline=Truecopies the original aside (<name>.baseline) before its first change, templates from that copy ever after, andrevert()puts it back. A file the patch created is recorded as such (<name>.baseline.absent) and left in place byrevert(). - Regenerated files. Each change also records digests of what it wrote
(
<name>.baseline.applied). When the middleware rewrites the file later -- after a settings change in the UI, say -- that content becomes the original: the next apply layers onto it, andrevert()leaves it alone. - Failed follow-ups are retried. If the reload after a change fails, the previous content is put back before the error is raised, so the next apply sees the change again instead of finding the file already written.
- Permissions survive. A rewritten file keeps its mode;
mode=sets the mode of a file the patch creates, before any content is written. - Read-only datasets.
writable=True(orzfs.writable()) clearsreadonlyon the dataset holding a path for the duration of the write and restores it however the write ends. - Declared unit state. A unit's
enable/startare reconciled against what systemd reports;Nonemeans "leave it alone".
The pieces
| Piece | Use it for |
|---|---|
BasicTemplate(text) |
text with %{NAME} placeholders filled from a dict or object |
TextTemplate(text) |
literal text |
a BaseTemplate subclass |
anything else: render(context) returns the text; built with the target's original content when passed as a class to apply_template |
FileTarget(path, baseline=, mode=) |
a file reached through any path object |
SystemFile(path, client, etc=, services=, baseline=True, writable=False, mode=) |
a file on the host, plus the etc groups to regenerate and services to reload when it changes |
ServiceUnit / MountUnit / AutomountUnit |
a systemd unit: apply() writes it, reloads systemd and reconciles enable/start; uninstall() removes it |
MiddlewareFiles(client).find_template(name, ...) |
one of the middleware's own etc_files templates, as a SystemFile |
zfs.writable(client, path) |
a context manager making the dataset under path writable |
The full signatures and contracts are in the package's API header
(src/pytruenas/AGENTS.md, section patch).
A complete patch
The patch below installs an agent: its config file, rendered from a template;
a systemd unit that runs it; and a change layered onto the middleware's own
sshd_config template, which the middleware then regenerates
/etc/ssh/sshd_config from before the ssh service reloads.
from pytruenas.patch import (
BaseTemplate,
BasicTemplate,
MiddlewareFiles,
ServiceUnit,
SystemFile,
)
AGENT = "/mnt/tank/apps/agent/bin/agent"
CONFIG = """\
listen = 0.0.0.0:%{PORT}
data = %{DATA}
"""
class SshdKeepalive(BaseTemplate):
"""Adds two lines to the stock sshd_config template."""
def __init__(self, stock: bytes):
self.stock = stock.decode()
def render(self, context):
return self.stock + "ClientAliveInterval 300\nClientAliveCountMax 3\n"
def sshd_template(nas):
# The template lives in the middlewared package on the read-only /usr
# dataset, so the write needs `writable=True`.
return MiddlewareFiles(nas).find_template(
"ssh/sshd_config", etc="ssh", services="ssh", writable=True
)
def install(nas, port=9100, data="/mnt/tank/apps/agent/data"):
"""Apply the whole patch; return whether anything changed."""
config = SystemFile("/etc/agent.conf", nas, baseline=False, mode=0o640)
config_changed = BasicTemplate(CONFIG).apply(config, {"port": port, "data": data})
unit = ServiceUnit(
nas,
"agent",
cmd=f"{AGENT} --config /etc/agent.conf",
description="Example agent",
restart_on_change=True,
)
unit_changed = unit.apply() # write if changed, daemon-reload, enable, start
if config_changed and not unit_changed:
unit.restart() # same unit, new config
sshd_changed = sshd_template(nas).apply_template(SshdKeepalive)
return config_changed or unit_changed or sshd_changed
def uninstall(nas):
"""Undo install(): the stock template comes back, the unit goes."""
sshd_template(nas).revert()
ServiceUnit(nas, "agent").uninstall()
nas.path("/etc/agent.conf").unlink(missing_ok=True)
SshdKeepalive is passed as a class, so apply_template builds it from the
template's original content: the stock file the first time, its baseline
copy every time after. That is what keeps a second apply from adding the lines
twice.
Trying it without a NAS
A patch only needs client.path(), client.run() and client.api, so it runs
against a stand-in. This one keeps files under a local directory, answers
systemctl and zfs from memory, and records every command and middleware
call -- enough to see what the patch would do, or to test your own:
import pathlib
import subprocess
class FakeNAS:
"""A stand-in for TrueNASClient, for running a patch offline."""
PACKAGE = "/usr/lib/python3/dist-packages/middlewared"
def __init__(self, root):
self.root = pathlib.Path(root)
self.commands = [] # the argv of every run()
self.calls = [] # every middleware call, as ("etc.generate", ("ssh",))
self.units = {} # unit name -> {"enabled", "active"}
self.zfs = {"readonly": "on"} # the properties of the /usr dataset
def path(self, path):
if str(path).startswith(str(self.root)):
return pathlib.Path(path)
return self.root / str(path).lstrip("/")
@property
def api(self):
return _Call(self.calls)
def run(self, argv, check=False, **kwargs):
argv = [str(arg) for arg in argv]
self.commands.append(argv)
out, status = "", 0
if argv[0] == "systemctl" and len(argv) > 2:
state = self.units.setdefault(argv[2], set())
verb = argv[1]
if verb in ("is-enabled", "is-active"):
status = 0 if verb[3:] in state else 1 # the answer is the status
elif verb == "enable":
state.add("enabled")
elif verb == "start":
state.add("active")
elif verb == "disable":
state.discard("enabled")
if "--now" in argv:
state.discard("active")
elif verb == "stop":
state.discard("active")
elif argv[0] == "python3": # where is the middlewared package?
out = self.PACKAGE + "\n"
elif argv[0] == "findmnt":
out = "zfs boot-pool/ROOT/26.0/usr\n"
elif argv[:2] == ["zfs", "get"]:
names = argv[-2].split(",")
out = "".join(f"{name}\t{self.zfs.get(name, '-')}\tlocal\n" for name in names)
elif argv[:2] == ["zfs", "set"]:
self.zfs.update(pair.split("=", 1) for pair in argv[2:-1])
elif argv[:2] == ["zfs", "inherit"]:
self.zfs.pop(argv[-2], None)
return subprocess.CompletedProcess(argv, status, out, "")
class _Call:
"""`nas.api.etc.generate("ssh")` records ("etc.generate", ("ssh",))."""
def __init__(self, calls, name=""):
self._calls, self._name = calls, name
def __getattr__(self, name):
return _Call(self._calls, f"{self._name}.{name}".lstrip("."))
def __call__(self, *args):
self._calls.append((self._name, args))
Seed it with the stock template, then apply the patch twice:
import tempfile
nas = FakeNAS(tempfile.mkdtemp())
stock = nas.path(FakeNAS.PACKAGE + "/etc_files/ssh/sshd_config.mako")
stock.parent.mkdir(parents=True)
stock.write_bytes(b"Port 22\n")
assert install(nas) is True
assert nas.calls == [("etc.generate", ("ssh",)), ("service.reload", ("ssh",))]
assert nas.units["agent.service"] == {"enabled", "active"}
assert nas.zfs["readonly"] == "on" # opened for the write, then restored
nas.calls.clear()
assert install(nas) is False # already applied: nothing written, nothing reloaded
assert nas.calls == []
assert install(nas, port=9200) is True # only the config changed
assert ["systemctl", "restart", "agent.service"] in nas.commands
uninstall(nas)
assert stock.read_bytes() == b"Port 22\n"
assert not nas.path("/etc/systemd/system/agent.service").exists()
Running it against a NAS
Hand install() a real client. A patch runs commands on the host (systemctl,
zfs, findmnt, python3), so the client needs a working run(): over SSH
when one is configured, otherwise over the middleware's web shell -- see
Running commands.
# needs a NAS
from pytruenas import TrueNASClient
nas = TrueNASClient("nas.example.com", credentials="1-<64-char-api-key>")
install(nas)
Run it again after every TrueNAS update: the update boots a new environment without the patch, and re-applying puts back exactly what is missing.
Things to know
- The files beside a target are part of the patch.
<name>.baseline,<name>.baseline.absentand<name>.baseline.appliedhold the original and what was written; deleting them while the patch is applied loses the undo. - Only trusted files are used. A baseline, marker or record must be a
regular file with one link, owned by root or by the target's owner;
anything else (a symlink, say) raises
PermissionErrorrather than being read or written through. This matters where a non-root user can write the target's directory. zfs.writable()marks the dataset with the user propertypytruenas:restore-readonlywhile it is open, so a window left open by a crash is closed by the next one. Two windows on one dataset at the same time (two processes) are not coordinated. A path that is not on ZFS is left alone.- Unit names get their type.
ServiceUnit(nas, "my.agent")ismy.agent.service; a name already ending in a unit type keeps it. List values in a unit'sconfrender as repeated keys ({"Service": {"ExecStartPre": ["/a", "/b"]}}). - A changed unit is reloaded, not restarted, unless it was built with
restart_on_change=True: a restart interrupts the service, so it is your call.