aboutsummaryrefslogtreecommitdiffstats
path: root/jprov/main.py
diff options
context:
space:
mode:
Diffstat (limited to 'jprov/main.py')
-rw-r--r--jprov/main.py132
1 files changed, 132 insertions, 0 deletions
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())