From 5f46fde558c467414fa151ad962caaf7f07628fa Mon Sep 17 00:00:00 2001 From: Jan Tuomi Date: Mon, 16 Feb 2026 12:59:14 +0200 Subject: Initial commit --- .gitignore | 1 + README.md | 33 ++++++++++ docs/CONFIG.md | 65 ++++++++++++++++++++ docs/PLAN.md | 78 ++++++++++++++++++++++++ docs/USAGE.md | 20 ++++++ jprov/__init__.py | 1 + jprov/config.py | 175 +++++++++++++++++++++++++++++++++++++++++++++++++++++ jprov/jailconf.py | 31 ++++++++++ jprov/main.py | 132 ++++++++++++++++++++++++++++++++++++++++ jprov/mounts.py | 22 +++++++ jprov/overlay.py | 20 ++++++ jprov/provision.py | 15 +++++ jprov/runner.py | 63 +++++++++++++++++++ jprov/zfs.py | 33 ++++++++++ pyproject.toml | 30 +++++++++ 15 files changed, 719 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/CONFIG.md create mode 100644 docs/PLAN.md create mode 100644 docs/USAGE.md create mode 100644 jprov/__init__.py create mode 100644 jprov/config.py create mode 100644 jprov/jailconf.py create mode 100644 jprov/main.py create mode 100644 jprov/mounts.py create mode 100644 jprov/overlay.py create mode 100644 jprov/provision.py create mode 100644 jprov/runner.py create mode 100644 jprov/zfs.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..efb931c --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# jprov + +`jprov` is an opinionated FreeBSD jail provisioning tool designed for near-stateless jails. It rebuilds a jail from a template dataset, applies an overlay filesystem, mounts volumes, and runs a provisioning command inside the jail. It is intended to be used after system/template upgrades, not on every jail restart. + +## Status +Early development (pre-1.0). Expect breaking changes. + +## Requirements +- FreeBSD with ZFS +- Python 3.11+ (uses `tomllib`) +- Root privileges (for `zfs`, `jail`, `mount`, `jexec`) + +## Install (uv) +```bash +uv pip install . +``` + +## Usage +```bash +jprov [-y] [--dry-run] +``` + +## Configuration +See `docs/CONFIG.md` for the main config and per-jail TOML schema. + +## Logging +All command outputs are captured to a per-run log file. On fatal error, jprov prints: +``` +script logs written to .log +``` + +## License +BSD-2-Clause diff --git a/docs/CONFIG.md b/docs/CONFIG.md new file mode 100644 index 0000000..bdc5b02 --- /dev/null +++ b/docs/CONFIG.md @@ -0,0 +1,65 @@ +# jprov Configuration + +This document defines the main configuration file and per-jail TOML schema. + +## Main Config +Default locations (first found wins): +- `/usr/local/etc/jprov.conf` +- `/etc/jprov.conf` + +Schema (TOML): +```toml +base_dir = "/usr/local/jails" + +[datasets] +templates_prefix = "zroot/jails/templates/" +containers_prefix = "zroot/jails/containers/" + +jail_conf_dir = "/etc/jail.conf.d" +log_dir = "/var/log/jprov" +``` + +Notes: +- `base_dir` contains `templates/` and `defs/`. +- `templates_prefix` and `containers_prefix` are ZFS dataset name prefixes (must end with `/`). +- `log_dir` is where per-run logs are written. + +## Per-jail Config +Location: +- `base_dir/defs//jprov.conf` + +Schema (TOML): +```toml +# Required +# Relative to datasets.templates_prefix +# Example: "RELEASE-15.0-p3" resolves to "zroot/jails/templates/RELEASE-15.0-p3" +template = "RELEASE-15.0-p3" + +# Required +# Command executed inside the jail via: jexec +cmd = "/bin/sh /usr/local/sbin/provision.sh" + +# Optional +# Overlay defaults to base_dir/defs//overlay/ +overlay = "overlay" + +# Optional: environment variables passed to the provisioning command +[env] +FOO = "bar" + +# Optional: append raw jail.conf content +extra_jail_conf = """ +allow.raw_sockets = 1; +""" + +# Optional: nullfs mounts +[[mounts]] +host = "/usr/local/jails/volumes/foo" +jail = "/mnt/foo" +readonly = false +``` + +Notes: +- `cmd` is executed as a direct command (no implicit shell wrapping); jprov only checks its exit code. +- `overlay` is copied into the jail root after it is created. +- All `mounts[*].host` paths must exist on the host and are mounted via `nullfs`. diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..81a4b81 --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,78 @@ +# jprov Plan + +Date: 2026-02-14 + +## Goal +Build `jprov`, a FreeBSD jail provisioning tool that treats jails as nearly stateless, recreating them from a template, copying a filesystem overlay, and running a provisioning command inside the jail. If provisioning fails, the run fails. If the jail already exists, it is deleted and recreated. `-y` skips confirmation. + +## Current Understanding +- Primary command: `jprov [jailname]`. +- Reads a main config from `/etc/jprov.conf` (or `/usr/local/etc/jprov.conf`) which defines where per-jail configs live. +- Per-jail config is TOML and includes at least: template, provisioning command, and nullfs mounts. +- Creates a new thin jail as a ZFS clone of the template dataset. +- Deep-copies a tree into the jail filesystem (e.g. `jailname/usr/local/bin/foo` -> `/usr/local/bin/foo`). +- Runs a provisioning command inside the jail; provisioning success depends on this command's exit status. +- If jail already exists, delete it and recreate it. `-y` skips all confirmations. +- Backend is plain `jail(8)` + ZFS (potentially via `jail.conf.d` emission). +- Volume mounts are `nullfs` from host ZFS datasets mounted on the host. +- Example directory layout under a configurable base (e.g., `/usr/local/jails/`): + - `templates/RELEASE-15.0-p3/` + - `defs//jprov.conf` + - `defs//overlay/usr/...` +- `jprov` is intended to be run after system/template upgrades, not on every jail restart. +- Implementation language: Python 3. +- Destructive operations should be delayed as late as possible; validate configs and paths first to avoid tearing down a working jail on config errors. +- N+1 provisioning is not used; jprov stops and destroys the existing jail before creating and provisioning the new jail. +- Provide clear, informative progress output so the user can see each step at a glance. +- Capture all script output to log files in a configurable log dir (default `/var/log/jprov/`); on fatal error, print: "script logs written to .log". + +## Open Questions (Need Confirmation) +None. + +## Milestones +1. Confirm requirements and config schema. +2. Define operational workflow and backend commands. +3. Implement config parsing and validation. +4. Implement jail lifecycle (delete, create, overlay copy). +5. Implement provisioning execution and failure handling. +6. Add CLI flags, confirmations, and logging. +7. Add tests and documentation. + +## Architecture (Proposed) +Files and purposes: +- `bin/jprov` (entrypoint): CLI argument parsing, `-y` handling, top-level orchestration. +- `jprov/main.py`: main flow controller; calls config loading, validation, and provisioning steps. +- `jprov/config.py`: parse and validate main config and per-jail TOML; resolve paths; normalize defaults. +- `jprov/jailconf.py`: generate `jail.conf.d/.conf` from config, including `extra_jail_conf`. +- `jprov/zfs.py`: ZFS operations (clone, destroy, dataset existence checks). +- `jprov/mounts.py`: nullfs mount/unmount operations and validation of host paths. +- `jprov/overlay.py`: deep copy overlay into jail root. +- `jprov/provision.py`: run provisioning command via `jexec ` with env vars. +- `jprov/runner.py`: subprocess wrapper (stdout logging, error handling, and uniform exit code behavior). +- `docs/PLAN.md`: evolving plan and decisions. +- `docs/CONFIG.md`: user-facing config reference (main and per-jail TOML). +- `docs/USAGE.md`: CLI usage and examples. + +Notes: +- Keep module boundaries small and testable; all shell commands go through `runner.py`. +- Provide a dry-run mode later if desired (not in v1 unless requested). + +## Next Actions +1. Finalize main config schema and defaults. +2. Draft per-jail TOML schema and document in `docs/CONFIG.md`. +3. Scaffold Python package and CLI entrypoint. + +## Main Config (Proposed) +```toml +base_dir = "/usr/local/jails" + +[datasets] +templates_prefix = "zroot/jails/templates/" +containers_prefix = "zroot/jails/containers/" + +jail_conf_dir = "/etc/jail.conf.d" +log_dir = "/var/log/jprov" +``` + +Notes: +- `assume_yes` is CLI-only via `-y`. diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..295ae0b --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,20 @@ +# jprov Usage + +## Synopsis +```bash +jprov [-y] [--dry-run] +``` + +## Behavior Summary +- Validates configs and referenced paths. +- Stops and destroys any existing jail named ``. +- Clones the template dataset into a new jail dataset. +- Generates `/etc/jail.conf.d/.conf` (or configured path). +- Mounts nullfs volumes, copies overlay, and executes the provisioning command. + +## Exit Codes +- `0`: success +- `1`: failure + +## Dry Run +`--dry-run` logs actions without executing destructive or provisioning steps. diff --git a/jprov/__init__.py b/jprov/__init__.py new file mode 100644 index 0000000..c0d6059 --- /dev/null +++ b/jprov/__init__.py @@ -0,0 +1 @@ +"""jprov package.""" diff --git a/jprov/config.py b/jprov/config.py new file mode 100644 index 0000000..e7fc504 --- /dev/null +++ b/jprov/config.py @@ -0,0 +1,175 @@ +"""Configuration loading and validation.""" + +from __future__ import annotations + +from dataclasses import dataclass +import os +from typing import Any + +import tomllib + + +@dataclass +class MainConfig: + base_dir: str + templates_prefix: str + containers_prefix: str + jail_conf_dir: str + log_dir: str + + +@dataclass +class Mount: + host: str + jail: str + readonly: bool = False + + +@dataclass +class JailConfig: + name: str + template: str + cmd: str + overlay: str | None + mounts: list[Mount] + env: dict[str, str] + extra_jail_conf: str | None + + +class ConfigError(RuntimeError): + pass + + +def load_main_config() -> MainConfig: + """Load main config from /usr/local/etc/jprov.conf or /etc/jprov.conf.""" + candidates = ["/usr/local/etc/jprov.conf", "/etc/jprov.conf"] + path = next((p for p in candidates if os.path.exists(p)), None) + if not path: + raise ConfigError("main config not found (expected /usr/local/etc/jprov.conf or /etc/jprov.conf)") + + with open(path, "rb") as fh: + data = tomllib.load(fh) + + try: + base_dir = data["base_dir"] + datasets = data["datasets"] + templates_prefix = datasets["templates_prefix"] + containers_prefix = datasets["containers_prefix"] + jail_conf_dir = data["jail_conf_dir"] + log_dir = data["log_dir"] + except KeyError as exc: + raise ConfigError(f"missing main config key: {exc}") from exc + + for key, val in [ + ("base_dir", base_dir), + ("templates_prefix", templates_prefix), + ("containers_prefix", containers_prefix), + ("jail_conf_dir", jail_conf_dir), + ("log_dir", log_dir), + ]: + if not isinstance(val, str) or not val: + raise ConfigError(f"invalid main config value for {key}") + + if not templates_prefix.endswith("/"): + raise ConfigError("datasets.templates_prefix must end with '/'") + if not containers_prefix.endswith("/"): + raise ConfigError("datasets.containers_prefix must end with '/'") + + return MainConfig( + base_dir=base_dir, + templates_prefix=templates_prefix, + containers_prefix=containers_prefix, + jail_conf_dir=jail_conf_dir, + log_dir=log_dir, + ) + + +def load_jail_config(main: MainConfig, jailname: str) -> JailConfig: + """Load per-jail TOML config for the given jail name.""" + jail_dir = os.path.join(main.base_dir, "defs", jailname) + path = os.path.join(jail_dir, "jprov.conf") + if not os.path.exists(path): + raise ConfigError(f"jail config not found: {path}") + + with open(path, "rb") as fh: + data = tomllib.load(fh) + + try: + template = data["template"] + cmd = data["cmd"] + except KeyError as exc: + raise ConfigError(f"missing jail config key: {exc}") from exc + + overlay = data.get("overlay") + env = data.get("env", {}) + extra_jail_conf = data.get("extra_jail_conf") + mounts_raw = data.get("mounts", []) + + if not isinstance(template, str) or not template: + raise ConfigError("invalid jail config template") + if not isinstance(cmd, str) or not cmd: + raise ConfigError("invalid jail config cmd") + if overlay is not None and not isinstance(overlay, str): + raise ConfigError("invalid jail config overlay") + if env and not isinstance(env, dict): + raise ConfigError("invalid jail config env") + if extra_jail_conf is not None and not isinstance(extra_jail_conf, str): + raise ConfigError("invalid jail config extra_jail_conf") + if mounts_raw and not isinstance(mounts_raw, list): + raise ConfigError("invalid jail config mounts") + + mounts: list[Mount] = [] + for item in mounts_raw: + if not isinstance(item, dict): + raise ConfigError("invalid mount entry") + try: + host = item["host"] + jail = item["jail"] + except KeyError as exc: + raise ConfigError(f"missing mount key: {exc}") from exc + readonly = bool(item.get("readonly", False)) + if not isinstance(host, str) or not isinstance(jail, str): + raise ConfigError("invalid mount host/jail") + mounts.append(Mount(host=host, jail=jail, readonly=readonly)) + + return JailConfig( + name=jailname, + template=template, + cmd=cmd, + overlay=overlay, + mounts=mounts, + env={str(k): str(v) for k, v in env.items()} if env else {}, + extra_jail_conf=extra_jail_conf, + ) + + +def validate_configs(main: MainConfig, jail: JailConfig) -> None: + """Validate referenced paths and dataset names exist.""" + if not os.path.isdir(main.base_dir): + raise ConfigError(f"base_dir does not exist: {main.base_dir}") + + jail_dir = os.path.join(main.base_dir, "defs", jail.name) + if not os.path.isdir(jail_dir): + raise ConfigError(f"jail defs dir does not exist: {jail_dir}") + + overlay_path = resolve_overlay_path(main, jail) + if overlay_path and not os.path.isdir(overlay_path): + raise ConfigError(f"overlay dir does not exist: {overlay_path}") + + for mnt in jail.mounts: + if not os.path.exists(mnt.host): + raise ConfigError(f"mount host path does not exist: {mnt.host}") + + +def resolve_overlay_path(main: MainConfig, jail: JailConfig) -> str: + """Return resolved overlay path for the jail.""" + if jail.overlay is None or jail.overlay == "": + return os.path.join(main.base_dir, "defs", jail.name, "overlay") + if os.path.isabs(jail.overlay): + return jail.overlay + return os.path.join(main.base_dir, "defs", jail.name, jail.overlay) + + +def jail_conf_path(main: MainConfig, jailname: str) -> str: + """Return path to the generated jail.conf.d file for this jail.""" + return os.path.join(main.jail_conf_dir, f"{jailname}.conf") diff --git a/jprov/jailconf.py b/jprov/jailconf.py new file mode 100644 index 0000000..0478cbd --- /dev/null +++ b/jprov/jailconf.py @@ -0,0 +1,31 @@ +"""Generate jail.conf.d entries.""" + +from __future__ import annotations + +from .config import JailConfig, MainConfig +def render_jail_conf( + main: MainConfig, + jail: JailConfig, + *, + jailname: str | None = None, + root: str | None = None, +) -> str: + """Render jail.conf content for the jail.""" + name = jailname or jail.name + if not root: + raise ValueError("root path is required to render jail.conf") + lines = [ + f"{name} {{", + f" host.hostname = \"{name}\";", + f" path = \"{root}\";", + " mount.devfs;", + " exec.clean;", + " exec.start = \"/bin/sh /etc/rc\";", + " exec.stop = \"/bin/sh /etc/rc.shutdown jail\";", + "}", + ] + + base = "\n".join(lines) + if jail.extra_jail_conf: + base += "\n" + jail.extra_jail_conf.rstrip() + "\n" + return base diff --git a/jprov/main.py b/jprov/main.py new file mode 100644 index 0000000..a66892c --- /dev/null +++ b/jprov/main.py @@ -0,0 +1,132 @@ +"""Main entrypoint for jprov.""" + +from __future__ import annotations + +import argparse +import os +import sys +from datetime import datetime, timezone + +from .config import ConfigError, jail_conf_path, load_jail_config, load_main_config, resolve_overlay_path, validate_configs +from .jailconf import render_jail_conf +from .mounts import mount_all, unmount_all +from .overlay import copy_overlay +from .provision import run_provision +from .runner import log_line, run +from .zfs import clone_dataset, dataset_exists, destroy_dataset, get_mountpoint + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="jprov", description="FreeBSD jail provisioning tool") + parser.add_argument("-y", "--yes", action="store_true", help="skip confirmations") + parser.add_argument("--dry-run", action="store_true", help="log actions without executing") + parser.add_argument("jailname", help="name of the jail to provision") + return parser + + +def confirm(prompt: str) -> bool: + try: + return input(f"{prompt} [y/N]: ").strip().lower() in {"y", "yes"} + except EOFError: + return False + + +def jail_exists(name: str, *, log_file: str | None = None) -> bool: + result = run(["jls", "-j", name], allow_fail=True, log_file=log_file) + return result.returncode == 0 and result.stdout.strip() != "" + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + log_file = None + dry_run = args.dry_run + try: + main_cfg = load_main_config() + jail_cfg = load_jail_config(main_cfg, args.jailname) + validate_configs(main_cfg, jail_cfg) + + os.makedirs(main_cfg.log_dir, exist_ok=True) + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + log_file = os.path.join(main_cfg.log_dir, f"{args.jailname}-{ts}.log") + + log_line("INFO", "validate", "OK (config and paths)") + + template_ds = f"{main_cfg.templates_prefix}{jail_cfg.template}" + jail_ds = f"{main_cfg.containers_prefix}{args.jailname}" + + if not dataset_exists(template_ds, log_file=log_file, dry_run=dry_run): + raise RuntimeError(f"template dataset not found: {template_ds}") + + exists = dataset_exists(jail_ds, log_file=log_file, dry_run=dry_run) or jail_exists( + args.jailname, log_file=log_file + ) + if exists and not args.yes: + if not confirm(f"Jail {args.jailname} exists and will be destroyed. Continue?"): + log_line("INFO", "confirm", "aborted by user") + return 1 + + if exists: + log_line("INFO", "destroy", f"stopping jail {args.jailname}") + run(["service", "jail", "onestop", args.jailname], log_file=log_file, allow_fail=True, dry_run=dry_run) + root = ( + get_mountpoint(jail_ds, log_file=log_file) + if dataset_exists(jail_ds, log_file=log_file, dry_run=dry_run) + else "" + ) + if root: + log_line("INFO", "destroy", "unmounting volumes") + unmount_all(root, jail_cfg.mounts, log_file=log_file) + log_line("INFO", "destroy", f"destroying dataset {jail_ds}") + destroy_dataset(jail_ds, log_file=log_file) if not dry_run else None + + log_line("INFO", "create", f"cloning {template_ds} -> {jail_ds}") + if not dry_run: + clone_dataset(template_ds, jail_ds, log_file=log_file) + root = get_mountpoint(jail_ds, log_file=log_file) + else: + root = f"" + + os.makedirs(main_cfg.jail_conf_dir, exist_ok=True) + conf_path = jail_conf_path(main_cfg, args.jailname) + tmp_conf = conf_path + ".tmp" + if not dry_run: + with open(tmp_conf, "w", encoding="utf-8") as fh: + fh.write(render_jail_conf(main_cfg, jail_cfg, root=root)) + os.replace(tmp_conf, conf_path) + log_line("INFO", "jailconf", f"wrote {conf_path}") + else: + log_line("INFO", "jailconf", f"[dry-run] would write {conf_path}") + + log_line("INFO", "start", f"starting jail {args.jailname}") + run(["service", "jail", "start", args.jailname], log_file=log_file, dry_run=dry_run) + + if jail_cfg.mounts: + log_line("INFO", "mounts", "mounting nullfs volumes") + if not dry_run: + mount_all(root, jail_cfg.mounts, log_file=log_file) + + overlay_path = resolve_overlay_path(main_cfg, jail_cfg) + if overlay_path and os.path.isdir(overlay_path): + log_line("INFO", "overlay", f"copying overlay from {overlay_path}") + if not dry_run: + copy_overlay(overlay_path, root) + + log_line("INFO", "provision", "running provisioning command") + if not dry_run: + run_provision(jail_cfg, log_file=log_file) + else: + log_line("INFO", "provision", f"[dry-run] jexec {args.jailname} {jail_cfg.cmd}") + + log_line("INFO", "done", "provisioning complete") + return 0 + except (ConfigError, RuntimeError, OSError) as exc: + log_line("ERROR", "fatal", str(exc)) + if log_file: + print(f"script logs written to {log_file}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/jprov/mounts.py b/jprov/mounts.py new file mode 100644 index 0000000..2f87f8e --- /dev/null +++ b/jprov/mounts.py @@ -0,0 +1,22 @@ +"""Nullfs mount handling.""" + +from __future__ import annotations + +import os + +from .config import Mount +from .runner import run + + +def mount_all(root: str, mounts: list[Mount], *, log_file: str | None = None) -> None: + for mnt in mounts: + target = os.path.join(root, mnt.jail.lstrip("/")) + os.makedirs(target, exist_ok=True) + opts = "ro" if mnt.readonly else "rw" + run(["mount", "-t", "nullfs", "-o", opts, mnt.host, target], log_file=log_file) + + +def unmount_all(root: str, mounts: list[Mount], *, log_file: str | None = None) -> None: + for mnt in reversed(mounts): + target = os.path.join(root, mnt.jail.lstrip("/")) + run(["umount", target], allow_fail=True, log_file=log_file) diff --git a/jprov/overlay.py b/jprov/overlay.py new file mode 100644 index 0000000..c2a04da --- /dev/null +++ b/jprov/overlay.py @@ -0,0 +1,20 @@ +"""Overlay copy handling.""" + +from __future__ import annotations + +import os +import shutil + + +def copy_overlay(src: str, dest_root: str) -> None: + if not src or not os.path.isdir(src): + return + + for entry in os.listdir(src): + src_path = os.path.join(src, entry) + dest_path = os.path.join(dest_root, entry) + if os.path.isdir(src_path): + shutil.copytree(src_path, dest_path, symlinks=True, dirs_exist_ok=True) + else: + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + shutil.copy2(src_path, dest_path, follow_symlinks=True) diff --git a/jprov/provision.py b/jprov/provision.py new file mode 100644 index 0000000..f3352e9 --- /dev/null +++ b/jprov/provision.py @@ -0,0 +1,15 @@ +"""Provisioning command execution.""" + +from __future__ import annotations + +import shlex + +from .config import JailConfig +from .runner import run + + +def run_provision(jail: JailConfig, *, log_file: str | None = None) -> int: + """Run the provisioning command inside the jail.""" + cmd = ["jexec", jail.name] + shlex.split(jail.cmd) + result = run(cmd, env=jail.env, log_file=log_file) + return result.returncode diff --git a/jprov/runner.py b/jprov/runner.py new file mode 100644 index 0000000..88dfe7f --- /dev/null +++ b/jprov/runner.py @@ -0,0 +1,63 @@ +"""Subprocess runner and logging utilities.""" + +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Iterable, Mapping + + +@dataclass +class RunResult: + cmd: list[str] + returncode: int + stdout: str + stderr: str + + +def log_line(level: str, scope: str, message: str) -> None: + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + print(f"{ts} {level:<5} {scope}: {message}") + + +def run( + cmd: Iterable[str], + *, + log_file: str | None = None, + env: Mapping[str, str] | None = None, + allow_fail: bool = False, + dry_run: bool = False, +) -> RunResult: + """Run a command and optionally tee output to a log file.""" + cmd_list = list(cmd) + if dry_run: + if log_file: + with open(log_file, "a", encoding="utf-8") as fh: + fh.write(f"[dry-run] {' '.join(cmd_list)}\n") + return RunResult(cmd=cmd_list, returncode=0, stdout="", stderr="") + env_map = os.environ.copy() + if env: + env_map.update(env) + + proc = subprocess.Popen( + cmd_list, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env_map, + ) + stdout, stderr = proc.communicate() + + if log_file: + with open(log_file, "a", encoding="utf-8") as fh: + if stdout: + fh.write(stdout) + if stderr: + fh.write(stderr) + + if proc.returncode != 0 and not allow_fail: + raise RuntimeError(f"command failed: {' '.join(cmd_list)} (exit {proc.returncode})") + + return RunResult(cmd=cmd_list, returncode=proc.returncode, stdout=stdout, stderr=stderr) diff --git a/jprov/zfs.py b/jprov/zfs.py new file mode 100644 index 0000000..e3a1574 --- /dev/null +++ b/jprov/zfs.py @@ -0,0 +1,33 @@ +"""ZFS operations.""" + +from __future__ import annotations + +from .runner import run + + +def dataset_exists( + name: str, + *, + log_file: str | None = None, + dry_run: bool = False, +) -> bool: + result = run( + ["zfs", "list", "-H", "-o", "name", name], + allow_fail=True, + log_file=log_file, + dry_run=dry_run, + ) + return result.returncode == 0 + + +def clone_dataset(src: str, dest: str, *, log_file: str | None = None) -> None: + run(["zfs", "clone", src, dest], log_file=log_file) + + +def destroy_dataset(name: str, *, log_file: str | None = None) -> None: + run(["zfs", "destroy", "-r", name], log_file=log_file) + + +def get_mountpoint(name: str, *, log_file: str | None = None) -> str: + result = run(["zfs", "get", "-H", "-o", "value", "mountpoint", name], log_file=log_file) + return result.stdout.strip() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ae1f84e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "jprov" +version = "0.1.0" +description = "FreeBSD jail provisioning tool" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "BSD-2-Clause"} +authors = [ + {name = "jprov"} +] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: BSD License", + "Operating System :: POSIX :: BSD :: FreeBSD", +] + +[project.scripts] +jprov = "jprov.main:main" + +[tool.hatch.build] +include = [ + "jprov/**", + "docs/**", + "README.md", + "pyproject.toml", +] -- cgit v1.3