From 5f46fde558c467414fa151ad962caaf7f07628fa Mon Sep 17 00:00:00 2001 From: Jan Tuomi Date: Mon, 16 Feb 2026 12:59:14 +0200 Subject: Initial commit --- jprov/main.py | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 jprov/main.py (limited to 'jprov/main.py') 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()) -- cgit v1.3