feat(zfs): enhance command handling with run_zfs and run_zpool functions
treefmt / nix fmt (pull_request) Successful in 6s
pytest / pytest (pull_request) Successful in 30s
test ebook search / test-ebook-search (pull_request) Successful in 36s
build_systems / build-brain (pull_request) Successful in 46s
build_systems / build-bob (pull_request) Successful in 47s
build_systems / build-rhapsody-in-green (pull_request) Successful in 58s
build_systems / build-jeeves (pull_request) Successful in 2m23s
pytest / pytest (push) Successful in 33s
test ebook search / test-ebook-search (push) Successful in 42s
build_systems / build-jeeves (push) Successful in 2m26s
treefmt / nix fmt (push) Successful in 5s
build_systems / build-brain (push) Successful in 9s
build_systems / build-bob (push) Successful in 40s
build_systems / build-rhapsody-in-green (push) Successful in 53s

This commit was merged in pull request #48.
This commit is contained in:
2026-07-30 12:49:18 -04:00
parent cc166df90f
commit 48a7e3a54c
5 changed files with 606 additions and 25 deletions
+20 -1
View File
@@ -1,11 +1,30 @@
"""init."""
from python.zfs.dataset import Dataset, Snapshot, get_datasets
# run_command is deliberately not re-exported here. It is available from
# python.zfs.command when something genuinely needs another binary, but the
# wrappers are what callers should reach for by default.
from python.zfs.command import CommandResult, run_zfs, run_zpool
from python.zfs.dataset import (
Dataset,
Snapshot,
create_dataset,
get_datasets,
get_properties,
list_dataset_names,
set_property,
)
from python.zfs.zpool import Zpool
__all__ = [
"CommandResult",
"Dataset",
"Snapshot",
"Zpool",
"create_dataset",
"get_datasets",
"get_properties",
"list_dataset_names",
"run_zfs",
"run_zpool",
"set_property",
]
+90
View File
@@ -0,0 +1,90 @@
"""Running zfs and zpool commands.
One implementation shared by both, so the zpool side gets the same handling the
zfs side does: arguments passed as a list, streams kept apart, and failures
returned as data rather than guessed at by the caller.
"""
from __future__ import annotations
import logging
import subprocess
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class CommandResult:
"""The outcome of a single zfs or zpool invocation."""
args: tuple[str, ...]
stdout: str
stderr: str
return_code: int
@property
def ok(self) -> bool:
"""Whether the command reported success."""
return self.return_code == 0
@property
def message(self) -> str:
"""The most useful description of what went wrong."""
return (self.stderr or self.stdout).strip()
def run_command(*args: str) -> CommandResult:
"""Run a command, passing arguments as a list rather than a shell string.
Two things this buys over bash_wrapper. Arguments are never split on
whitespace, so a value containing a space arrives intact. And stdout stays
separate from stderr, so a warning on a successful command is never
mistaken for output, which bash_wrapper does whenever stderr is non-empty
regardless of the return code.
The encoding is pinned rather than using text=True, which would decode with
the locale encoding. These run from systemd units, where LANG is often
unset.
Args:
*args: The command and its arguments.
Returns:
CommandResult: The streams and return code, never raising on failure.
"""
completed = subprocess.run(list(args), capture_output=True, encoding="utf-8", check=False)
if completed.returncode != 0:
logger.debug(f"{' '.join(args)} exited {completed.returncode}: {completed.stderr.strip()}")
return CommandResult(
args=tuple(args),
stdout=completed.stdout,
stderr=completed.stderr,
return_code=completed.returncode,
)
def run_zfs(*args: str) -> CommandResult:
"""Run a zfs command.
Args:
*args: The arguments to pass to zfs.
Returns:
CommandResult: The streams and return code.
"""
return run_command("zfs", *args)
def run_zpool(*args: str) -> CommandResult:
"""Run a zpool command.
Args:
*args: The arguments to pass to zpool.
Returns:
CommandResult: The streams and return code.
"""
return run_command("zpool", *args)
+87 -3
View File
@@ -8,6 +8,7 @@ from datetime import UTC, datetime
from typing import Any
from python.common import bash_wrapper
from python.zfs.command import run_zfs
logger = logging.getLogger(__name__)
@@ -207,8 +208,91 @@ def get_datasets() -> list[Dataset]:
"""
logger.info("Getting zfs list")
dataset_names, _ = bash_wrapper("zfs list -Hp -t filesystem -o name")
return [Dataset(dataset_name) for dataset_name in list_dataset_names() if "/" in dataset_name]
cleaned_datasets = dataset_names.strip().split("\n")
return [Dataset(dataset_name) for dataset_name in cleaned_datasets if "/" in dataset_name]
def get_properties(name: str) -> dict[str, tuple[str, str]]:
"""Get every property of a dataset along with where its value came from.
The source is what distinguishes a property that was deliberately set on
this dataset from one that is merely inherited or left at its default.
Args:
name (str): The name of the dataset.
Returns:
dict[str, tuple[str, str]]: A mapping of property name to (value, source).
"""
result = run_zfs("get", "-Hp", "-o", "property,value,source", "all", name)
if not result.ok:
error = f"Failed to get properties for {name}: {result.message}"
raise RuntimeError(error)
properties = {}
for line in result.stdout.strip().splitlines():
if not line:
continue
prop, value, source = line.split("\t")
properties[prop] = (value, source)
return properties
def create_dataset(name: str, properties: dict[str, str]) -> str | None:
"""Create a dataset with the given properties.
Args:
name (str): The name of the dataset.
properties (dict[str, str]): The properties to create the dataset with.
Returns:
str | None: An error message on failure, None on success.
"""
options = [argument for key, value in sorted(properties.items()) for argument in ("-o", f"{key}={value}")]
logger.debug(f"creating {name} with {properties}")
result = run_zfs("create", *options, name)
if not result.ok:
return f"Failed to create {name}: {result.message}"
return None
def set_property(name: str, key: str, value: str) -> str | None:
"""Set a single property on a dataset.
Args:
name (str): The name of the dataset.
key (str): The property to set.
value (str): The value to set the property to.
Returns:
str | None: An error message on failure, None on success.
"""
logger.debug(f"setting {key}={value} on {name}")
result = run_zfs("set", f"{key}={value}", name)
if not result.ok:
return f"Failed to set {key}={value} on {name}: {result.message}"
return None
def list_dataset_names() -> list[str]:
"""List every zfs filesystem name, including pool root filesystems.
Unlike get_datasets this does not build Dataset objects and does not filter
out pool roots, which makes it usable for existence checks.
Returns:
list[str]: The names of every zfs filesystem.
Raises:
RuntimeError: If zfs list fails. Never returns a partial or error
derived list, since treating stderr as dataset names would make
the reconciler think every dataset is missing.
"""
result = run_zfs("list", "-Hp", "-t", "filesystem", "-o", "name")
if not result.ok:
error = f"Failed to list ZFS datasets: {result.message}"
raise RuntimeError(error)
return [name for name in result.stdout.strip().splitlines() if name]
+25 -11
View File
@@ -1,28 +1,42 @@
"""test."""
"""zpool."""
from __future__ import annotations
import json
from typing import Any
from python.common import bash_wrapper
from python.zfs.command import run_zpool
def _zpool_list(zfs_list: str) -> dict[str, Any]:
"""Check the version of zfs."""
raw_zfs_list_data, _ = bash_wrapper(zfs_list)
def _zpool_list(*args: str) -> dict[str, Any]:
"""Run a zpool list and check the output is a format we understand.
zfs_list_data = json.loads(raw_zfs_list_data)
Args:
*args: The arguments to pass to zpool.
vers_major = zfs_list_data["output_version"]["vers_major"]
vers_minor = zfs_list_data["output_version"]["vers_minor"]
command = zfs_list_data["output_version"]["command"]
Returns:
dict[str, Any]: The decoded output.
Raises:
RuntimeError: If zpool fails, or reports a format this does not parse.
Never decodes a partial or error derived payload.
"""
result = run_zpool(*args)
if not result.ok:
error = f"Failed to run zpool {' '.join(args)}: {result.message}"
raise RuntimeError(error)
zpool_data = json.loads(result.stdout)
vers_major = zpool_data["output_version"]["vers_major"]
vers_minor = zpool_data["output_version"]["vers_minor"]
command = zpool_data["output_version"]["command"]
if vers_major != 0 or vers_minor != 1 or command != "zpool list":
error = f"Datasets are not in the correct format {vers_major=} {vers_minor=} {command=}"
raise RuntimeError(error)
return zfs_list_data
return zpool_data
class Zpool:
@@ -33,7 +47,7 @@ class Zpool:
name: str,
) -> None:
"""__init__."""
zpool_data = _zpool_list(f"zpool list {name} -pHj -o all")
zpool_data = _zpool_list("list", name, "-pHj", "-o", "all")
properties = zpool_data["pools"][name]["properties"]