1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
|
"""Main entrypoint for jprov."""
from __future__ import annotations
import argparse
import os
import sys
import time
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)")
log_line("INFO", "config", f"loaded {jail_cfg.config_path}")
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 not dry_run:
for attempt in range(5):
if jail_exists(args.jailname, log_file=log_file):
break
time.sleep(1)
else:
raise RuntimeError(f"jail failed to start: {args.jailname}")
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())
|