aboutsummaryrefslogtreecommitdiffstats
path: root/jprov
diff options
context:
space:
mode:
Diffstat (limited to 'jprov')
-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
9 files changed, 492 insertions, 0 deletions
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()