aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.md33
-rw-r--r--docs/CONFIG.md65
-rw-r--r--docs/PLAN.md78
-rw-r--r--docs/USAGE.md20
-rw-r--r--jprov/__init__.py1
-rw-r--r--jprov/config.py175
-rw-r--r--jprov/jailconf.py31
-rw-r--r--jprov/main.py132
-rw-r--r--jprov/mounts.py22
-rw-r--r--jprov/overlay.py20
-rw-r--r--jprov/provision.py15
-rw-r--r--jprov/runner.py63
-rw-r--r--jprov/zfs.py33
-rw-r--r--pyproject.toml30
15 files changed, 719 insertions, 0 deletions
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] <jailname>
+```
+
+## 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 <logpath>.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/<jailname>/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 <jailname> <cmd>
+cmd = "/bin/sh /usr/local/sbin/provision.sh"
+
+# Optional
+# Overlay defaults to base_dir/defs/<jailname>/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/<jailname>/jprov.conf`
+ - `defs/<jailname>/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 <logpath>.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/<name>.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 <name> <cmd>` 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] <jailname>
+```
+
+## Behavior Summary
+- Validates configs and referenced paths.
+- Stops and destroys any existing jail named `<jailname>`.
+- Clones the template dataset into a new jail dataset.
+- Generates `/etc/jail.conf.d/<jailname>.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"<mountpoint:{jail_ds}>"
+
+ 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",
+]