aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJan Tuomi <jan@jantuomi.fi>2026-02-16 14:46:24 +0200
committerJan Tuomi <jan@jantuomi.fi>2026-02-16 14:46:24 +0200
commit34f5f546841c8e9022ac0ccf957f636907e643bb (patch)
tree8885e5f2209baa04128d75ca9bb340a999723828
parent5f46fde558c467414fa151ad962caaf7f07628fa (diff)
UpdatesHEADmain
-rw-r--r--docs/CONFIG.md49
-rw-r--r--docs/PLAN.md2
-rw-r--r--jprov/config.py29
-rw-r--r--jprov/jailconf.py62
-rw-r--r--jprov/main.py9
-rw-r--r--jprov/provision.py2
-rw-r--r--jprov/runner.py7
-rw-r--r--pyproject.toml2
8 files changed, 128 insertions, 34 deletions
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index bdc5b02..252e34d 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -3,41 +3,49 @@
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"
+jail_conf_dir = "/etc/jail.conf.d"
+log_dir = "/var/log/jprov"
[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"
+# Example: "RELEASE-15.0-p3@base" resolves to "zroot/jails/templates/RELEASE-15.0-p3@base"
+# Note: ZFS clone requires a snapshot (e.g., "@base") in the template name.
+template = "RELEASE-15.0-p3@base"
-# Required
-# Command executed inside the jail via: jexec <jailname> <cmd>
-cmd = "/bin/sh /usr/local/sbin/provision.sh"
+# Required/optional commands
+[cmds]
+# Command executed inside the jail via: jexec <jailname> <cmds.provision>
+provision = "/bin/sh /usr/local/sbin/provision.sh"
# Optional
# Overlay defaults to base_dir/defs/<jailname>/overlay/
@@ -47,10 +55,20 @@ overlay = "overlay"
[env]
FOO = "bar"
-# Optional: append raw jail.conf content
-extra_jail_conf = """
-allow.raw_sockets = 1;
-"""
+# Optional: structured jail.conf entries
+[jailconf]
+vnet = true
+persist = true
+exec.clean = true
+mount.devfs = true
+devfs_ruleset = 4
+allow.raw_sockets = 1
+
+# Optional: jail lifecycle scripts (become exec.* entries)
+# These are prefixed with env variables from [env] when emitted.
+prestart = "/usr/local/jails/scripts/prestart.sh"
+poststart = "/usr/local/jails/scripts/poststart.sh"
+poststop = "/usr/local/jails/scripts/poststop.sh"
# Optional: nullfs mounts
[[mounts]]
@@ -60,6 +78,11 @@ readonly = false
```
Notes:
-- `cmd` is executed as a direct command (no implicit shell wrapping); jprov only checks its exit code.
+
+- `cmds.provision` is executed as a direct command (no implicit shell wrapping); jprov only checks its exit code.
+- `[jailconf]` maps key/value pairs directly into `jail.conf` entries (no `+=` support).
+- `true` values emit `key;` (no `=`). `false` values are omitted.
+- Dotted keys are supported via TOML nesting (e.g., `exec.prestart` becomes a nested table).
+- `cmds.*` entries are emitted as `exec.*` entries and are prefixed with `env KEY=VAL` for all variables in `[env]` (unless the command already starts with `env `).
- `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
index 81a4b81..ee5dbe3 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -8,7 +8,7 @@ Build `jprov`, a FreeBSD jail provisioning tool that treats jails as nearly stat
## 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.
+- Per-jail config is TOML and includes at least: template, provisioning command (`cmds.provision`), and nullfs mounts. `jailconf` is a map of keys to values for jail.conf entries.
- 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.
diff --git a/jprov/config.py b/jprov/config.py
index e7fc504..d4eb5dc 100644
--- a/jprov/config.py
+++ b/jprov/config.py
@@ -28,12 +28,13 @@ class Mount:
@dataclass
class JailConfig:
name: str
+ config_path: str
template: str
- cmd: str
overlay: str | None
mounts: list[Mount]
env: dict[str, str]
- extra_jail_conf: str | None
+ jailconf: dict[str, object]
+ cmds: dict[str, str]
class ConfigError(RuntimeError):
@@ -96,28 +97,34 @@ def load_jail_config(main: MainConfig, jailname: str) -> JailConfig:
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")
+ jailconf = data.get("jailconf", {})
+ cmds = data.get("cmds", {})
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 cmds and not isinstance(cmds, dict):
+ raise ConfigError("invalid jail config cmds")
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 jailconf and not isinstance(jailconf, dict):
+ raise ConfigError("invalid jail config jailconf")
if mounts_raw and not isinstance(mounts_raw, list):
raise ConfigError("invalid jail config mounts")
+ if not isinstance(cmds, dict) or "provision" not in cmds or not isinstance(cmds["provision"], str):
+ raise ConfigError("missing or invalid cmds.provision")
+ for key, val in cmds.items():
+ if not isinstance(val, str) or not val:
+ raise ConfigError(f"invalid cmds.{key}")
+
mounts: list[Mount] = []
for item in mounts_raw:
if not isinstance(item, dict):
@@ -134,12 +141,13 @@ def load_jail_config(main: MainConfig, jailname: str) -> JailConfig:
return JailConfig(
name=jailname,
+ config_path=path,
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,
+ jailconf=jailconf,
+ cmds={str(k): str(v) for k, v in cmds.items()} if cmds else {},
)
@@ -161,6 +169,7 @@ def validate_configs(main: MainConfig, jail: JailConfig) -> None:
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 == "":
diff --git a/jprov/jailconf.py b/jprov/jailconf.py
index 0478cbd..ba304ae 100644
--- a/jprov/jailconf.py
+++ b/jprov/jailconf.py
@@ -3,6 +3,25 @@
from __future__ import annotations
from .config import JailConfig, MainConfig
+
+
+def _format_value(value: object) -> str:
+ if isinstance(value, str):
+ return f"\"{value}\""
+ if isinstance(value, bool):
+ return "true" if value else "false"
+ if isinstance(value, (int, float)):
+ return str(value)
+ raise ValueError(f"unsupported jailconf value type: {type(value).__name__}")
+
+
+def _flatten(prefix: str, value: object, out: list[tuple[str, object]]) -> None:
+ if isinstance(value, dict):
+ for key, val in value.items():
+ new_prefix = f"{prefix}.{key}" if prefix else str(key)
+ _flatten(new_prefix, val, out)
+ else:
+ out.append((prefix, value))
def render_jail_conf(
main: MainConfig,
jail: JailConfig,
@@ -18,14 +37,43 @@ def render_jail_conf(
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
+ if jail.jailconf:
+ flattened: list[tuple[str, object]] = []
+ _flatten("", jail.jailconf, flattened)
+ for key, value in flattened:
+ if value is True:
+ lines.append(f" {key};")
+ continue
+ if value is False:
+ continue
+ lines.append(f" {key} = {_format_value(value)};")
+
+ # Exec commands from [cmds], with env prefix
+ env_prefix = ""
+ if jail.env:
+ pairs = [f"{k}={v}" for k, v in jail.env.items()]
+ env_prefix = "env " + " ".join(pairs) + " "
+
+ cmd_map = {
+ "prestart": "exec.prestart",
+ "poststart": "exec.poststart",
+ "prestop": "exec.prestop",
+ "poststop": "exec.poststop",
+ "start": "exec.start",
+ "stop": "exec.stop",
+ }
+ for key, jailconf_key in cmd_map.items():
+ cmd = jail.cmds.get(key)
+ if cmd:
+ if env_prefix and not cmd.startswith("env "):
+ cmd_value = f"{env_prefix}{cmd}"
+ else:
+ cmd_value = cmd
+ lines.append(f" {jailconf_key} = \"{cmd_value}\";")
+
+ lines.append("}")
+ return "\n".join(lines) + "\n"
diff --git a/jprov/main.py b/jprov/main.py
index a66892c..40cf715 100644
--- a/jprov/main.py
+++ b/jprov/main.py
@@ -5,6 +5,7 @@ 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
@@ -52,6 +53,7 @@ def main(argv: list[str] | None = None) -> int:
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}"
@@ -101,6 +103,13 @@ def main(argv: list[str] | None = None) -> int:
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")
diff --git a/jprov/provision.py b/jprov/provision.py
index f3352e9..f6c7678 100644
--- a/jprov/provision.py
+++ b/jprov/provision.py
@@ -10,6 +10,6 @@ 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)
+ cmd = ["jexec", jail.name] + shlex.split(jail.cmds["provision"])
result = run(cmd, env=jail.env, log_file=log_file)
return result.returncode
diff --git a/jprov/runner.py b/jprov/runner.py
index 88dfe7f..0f0a629 100644
--- a/jprov/runner.py
+++ b/jprov/runner.py
@@ -58,6 +58,11 @@ def run(
fh.write(stderr)
if proc.returncode != 0 and not allow_fail:
- raise RuntimeError(f"command failed: {' '.join(cmd_list)} (exit {proc.returncode})")
+ detail = ""
+ if stderr:
+ detail = f" stderr: {stderr.strip()}"
+ raise RuntimeError(
+ f"command failed: {' '.join(cmd_list)} (exit {proc.returncode}){detail}"
+ )
return RunResult(cmd=cmd_list, returncode=proc.returncode, stdout=stdout, stderr=stderr)
diff --git a/pyproject.toml b/pyproject.toml
index ae1f84e..232447d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "jprov"
-version = "0.1.0"
+version = "0.1.7"
description = "FreeBSD jail provisioning tool"
readme = "README.md"
requires-python = ">=3.11"