diff --git a/README.md b/README.md index 8d61936..5f862c1 100644 --- a/README.md +++ b/README.md @@ -39,20 +39,26 @@ After an update of TrueNAS SCALE the symlink will be lost (but the shell aliases ### Create Jail -Creating a jail is interactive. You'll be presented with questions which guide you through the process. +Creating jail with the default settings is as simple as: ```shell jlmkr create myjail ``` -After answering some questions you should have your first jail up and running! - You may also specify a path to a config template, for a quick and consistent jail creation process. ```shell -jlmkr create myjail /path/to/config/template +jlmkr create --config /path/to/config/template myjail ``` +If you omit the jail name, the create process is interactive. You'll be presented with questions which guide you through the process. + +```shell +jlmkr create +``` + +After answering some questions you should have your first jail up and running! + ### Startup Jails on Boot ```shell diff --git a/jlmkr.py b/jlmkr.py index 2aac834..159f777 100755 --- a/jlmkr.py +++ b/jlmkr.py @@ -240,6 +240,8 @@ class KeyValueParser(configparser.ConfigParser): def my_set(self, option, value): if isinstance(value, bool): value = str(int(value)) + elif isinstance(value, list): + value = str("\n ".join(value)) elif not isinstance(value, str): value = str(value) @@ -253,9 +255,23 @@ class KeyValueParser(configparser.ConfigParser): def my_getboolean(self, option, fallback=_UNSET): return super().getboolean(self._section_name, option, fallback=fallback) - # # Return all keys inside our only section - # def my_options(self): - # return super().options(self._section_name) + +class ExceptionWithParser(Exception): + def __init__(self, parser, message): + self.parser = parser + self.message = message + super().__init__(message) + + +# Workaround for exit_on_error=False not applying to: +# "error: the following arguments are required" +# https://github.com/python/cpython/issues/103498 +class CustomSubParser(argparse.ArgumentParser): + def error(self, message): + if self.exit_on_error: + super().error(message) + else: + raise ExceptionWithParser(self, message) def eprint(*args, **kwargs): @@ -420,7 +436,7 @@ def passthrough_nvidia( systemd_nspawn_additional_args += nvidia_mounts -def exec_jail(jail_name, cmd, args): +def exec_jail(jail_name, cmd): """ Execute a command in the jail with given name. """ @@ -434,9 +450,8 @@ def exec_jail(jail_name, cmd, args): "--wait", "--collect", "--service-type=exec", - cmd, + *cmd, ] - + args ).returncode @@ -923,11 +938,7 @@ def agree_with_default(config, key, question): config.my_set(key, agree(question, default_answer)) -def create_jail_interactive(): - """ - Create jail with given name. - """ - +def interactive_config(): config = KeyValueParser() config.read_string(DEFAULT_CONFIG) @@ -998,7 +1009,6 @@ def create_jail_interactive(): # Ask for jail name jail_name = ask_jail_name(jail_name) - jail_path = get_jail_path(jail_name) else: print() if not agree( @@ -1044,7 +1054,6 @@ def create_jail_interactive(): config.my_set("release", input("Release: ")) jail_name = ask_jail_name(jail_name) - jail_path = get_jail_path(jail_name) print( dedent( @@ -1167,31 +1176,67 @@ def create_jail_interactive(): print() - ############## - # Create start - ############## - - create_options = { - "jail_name": jail_name, - "jail_path": jail_path, - "start_now": start_now, - "config": config, - } - - rc = write_jail(create_options) - if rc != 0: - return rc - - if create_options["start_now"]: - return start_jail(jail_name) - - return 0 + return jail_name, config, start_now -def write_jail(create_options): - jail_name = create_options["jail_name"] - jail_path = create_options["jail_path"] - config = create_options["config"] +def create_jail(**kwargs): + jail_name = kwargs.pop("jail_name", None) + start_now = False + + # Non-interactive create + if jail_name: + if not check_jail_name_valid(jail_name): + return 1 + + if not check_jail_name_available(jail_name): + return 1 + + jail_config_path = kwargs.pop("config") + + config = KeyValueParser() + + if jail_config_path: + print(f"Creating jail {jail_name} from config template {jail_config_path}.") + if jail_config_path not in config.read(jail_config_path): + eprint(f"Failed to read config config template {jail_config_path}.") + return 1 + else: + print(f"Creating jail {jail_name} with default config.") + config.read_string(DEFAULT_CONFIG) + + user_overridden = False + + for option in [ + "distro", + "docker_compatible", + "gpu_passthrough_intel", + "gpu_passthrough_nvidia", + "release", + "startup", + "systemd_nspawn_user_args", + ]: + value = kwargs.pop(option) + if value: + # TODO: this will wipe all systemd_nspawn_user_args from the template... + # Should there be an option to append them instead? + print(f"Overriding {option} config value with {value}.") + config.my_set(option, value) + user_overridden = True + + if not user_overridden: + print( + dedent( + f""" + TIP: Run `{SYMLINK_NAME} create` without any arguments for interactive config. + Or use CLI args to override the default options. + For more info, run: `{SYMLINK_NAME} create --help` + """ + ) + ) + else: + jail_name, config, start_now = interactive_config() + + jail_path = get_jail_path(jail_name) distro = config.my_get("distro") release = config.my_get("release") @@ -1267,7 +1312,7 @@ def write_jail(create_options): print("Autostart has been disabled.") print("You need to start this jail manually.") config.my_set("startup", 0) - create_options["start_now"] = False + start_now = False with contextlib.suppress(FileNotFoundError): # Remove config which systemd handles for us @@ -1348,6 +1393,9 @@ def write_jail(create_options): cleanup(jail_path) raise error + if start_now: + return start_jail(jail_name) + return 0 @@ -1728,84 +1776,205 @@ def startup_jails(): return 0 +def split_at_string(lst, string): + try: + index = lst.index(string) + return lst[:index], lst[index + 1 :] + except ValueError: + return lst, [] + + +def add_parser(subparser, **kwargs): + if kwargs.get("add_help") is False: + # Don't add help if explicitly disabled + add_help = False + else: + # Never add help with the built in add_help + kwargs["add_help"] = False + add_help = True + + kwargs["epilog"] = DISCLAIMER + kwargs["exit_on_error"] = False + parser = subparser.add_parser(**kwargs) + + if add_help: + parser.add_argument( + "-h", "--help", help="show this help message and exit", action="store_true" + ) + + # Setting the add_help after the parser has been created with add_parser has no effect, + # but it allows us to look up if this parser has a help message available + parser.add_help = add_help + + return parser + + def main(): if os.stat(SCRIPT_PATH).st_uid != 0: fail( f"This script should be owned by the root user... Fix it manually with: `chown root {SCRIPT_PATH}`." ) - parser = argparse.ArgumentParser(description=__doc__, epilog=DISCLAIMER) + parser = argparse.ArgumentParser( + description=__doc__, epilog=DISCLAIMER, allow_abbrev=False + ) parser.add_argument("--version", action="version", version=__version__) - subparsers = parser.add_subparsers(title="commands", dest="subcommand", metavar="") - - subparsers.add_parser( - name="install", - epilog=DISCLAIMER, - help="install jailmaker dependencies and create symlink", + subparsers = parser.add_subparsers( + title="commands", dest="command", metavar="", parser_class=CustomSubParser ) - subparsers.add_parser(name="create", epilog=DISCLAIMER, help="create a new jail") + split_commands = ["create", "exec"] + commands = {} - subparsers.add_parser( - name="start", epilog=DISCLAIMER, help="start a previously created jail" - ).add_argument("name", help="name of the jail") + for d in [ + dict( + name="create", # + help="create a new jail", + ), + dict( + name="edit", + help=f"edit jail config with {TEXT_EDITOR} text editor", + ), + dict( + name="exec", + help="execute a command in the jail", + ), + dict( + name="images", + help="list available images to create jails from", + ), + dict( + name="install", + help="install jailmaker dependencies and create symlink", + ), + dict( + name="list", # + help="list jails", + ), + dict( + name="log", # + help="show jail log", + ), + dict( + name="remove", + help="remove previously created jail", + ), + dict( + name="restart", # + help="restart a running jail", + ), + dict( + name="shell", + help="open shell in running jail (alias for machinectl shell)", + add_help=False, + ), + dict( + name="start", + help="start previously created jail", + ), + dict( + name="startup", + help=f"install {SYMLINK_NAME} and startup selected jails", + ), + dict( + name="status", # + help="show jail status", + ), + dict( + name="stop", # + help="stop a running jail", + ), + ]: + commands[d["name"]] = add_parser(subparsers, **d) - subparsers.add_parser( - name="restart", epilog=DISCLAIMER, help="restart a running jail" - ).add_argument("name", help="name of the jail") + # Install parser + commands["install"].set_defaults(func=install_jailmaker) - subparsers.add_parser( - name="shell", - epilog=DISCLAIMER, - help="open shell in running jail (alias for machinectl shell)", + # Create parser + commands["create"].add_argument("jail_name", nargs="?", help="name of the jail") + commands["create"].add_argument("--distro") + commands["create"].add_argument("--release") + commands["create"].add_argument( + "--startup", + type=int, + choices=[0, 1], + help=f"start this jail when running: {SCRIPT_NAME} startup", ) - - exec_parser = subparsers.add_parser( - name="exec", epilog=DISCLAIMER, help="execute a command in the jail" + commands["create"].add_argument("--docker_compatible", type=int, choices=[0, 1]) + commands["create"].add_argument( + "-c", "--config", help="path to config file template" ) - exec_parser.add_argument("name", help="name of the jail") - exec_parser.add_argument("cmd", help="command to execute") - - subparsers.add_parser( - name="status", epilog=DISCLAIMER, help="show jail status" - ).add_argument("name", help="name of the jail") - - subparsers.add_parser( - name="log", epilog=DISCLAIMER, help="show jail log" - ).add_argument("name", help="name of the jail") - - subparsers.add_parser( - name="stop", epilog=DISCLAIMER, help="stop a running jail" - ).add_argument("name", help="name of the jail") - - subparsers.add_parser( - name="edit", - epilog=DISCLAIMER, - help=f"edit jail config with {TEXT_EDITOR} text editor", - ).add_argument("name", help="name of the jail to edit") - - subparsers.add_parser( - name="remove", epilog=DISCLAIMER, help="remove a previously created jail" - ).add_argument("name", help="name of the jail to remove") - - subparsers.add_parser(name="list", epilog=DISCLAIMER, help="list jails") - - subparsers.add_parser( - name="images", - epilog=DISCLAIMER, - help="list available images to create jails from", + commands["create"].add_argument( + "-gi", "--gpu_passthrough_intel", type=int, choices=[0, 1] ) - - subparsers.add_parser( - name="startup", - epilog=DISCLAIMER, - help=f"install {SYMLINK_NAME} and startup selected jails", + commands["create"].add_argument( + "-gn", "--gpu_passthrough_nvidia", type=int, choices=[0, 1] ) + commands["create"].add_argument( + "systemd_nspawn_user_args", + nargs="*", + help="add additional systemd-nspawn flags", + ) + commands["create"].set_defaults(func=create_jail) + + # Start parser + commands["start"].add_argument("jail_name", help="name of the jail") + commands["start"].set_defaults(func=start_jail) + + # Restart parser + commands["restart"].add_argument("jail_name", help="name of the jail") + commands["restart"].set_defaults(func=restart_jail) + + # Shell parser + commands["shell"].add_argument( + "args", + nargs="*", + help="args to pass to machinectl shell", + ) + commands["shell"].set_defaults(func=shell_jail) + + # Exec parser + commands["exec"].add_argument("jail_name", help="name of the jail") + commands["exec"].add_argument( + "cmd", + nargs="*", + help="command to execute", + ) + commands["exec"].set_defaults(func=exec_jail) + + # Status parser + commands["status"].add_argument("jail_name", help="name of the jail") + commands["status"].set_defaults(func=status_jail) + + # Log parser + commands["log"].add_argument("jail_name", help="name of the jail") + commands["log"].set_defaults(func=log_jail) + + # Stop parser + commands["stop"].add_argument("jail_name", help="name of the jail") + commands["stop"].set_defaults(func=stop_jail) + + # Edit parser + commands["edit"].add_argument("jail_name", help="name of the jail to edit") + commands["edit"].set_defaults(func=edit_jail) + + # Remove parser + commands["remove"].add_argument("jail_name", help="name of the jail to remove") + commands["remove"].set_defaults(func=remove_jail) + + # List parser + commands["list"].set_defaults(func=list_jails) + + # Images parser + commands["images"].set_defaults(func=run_lxc_download_script) + + # Startup parser + commands["startup"].set_defaults(func=startup_jails) if os.getuid() != 0: - parser.print_usage() + parser.print_help() fail("Run this script as root...") # Set appropriate permissions (if not already set) for this file, since it's executed as root @@ -1814,56 +1983,82 @@ def main(): # Work relative to this script os.chdir(SCRIPT_DIR_PATH) - args, additional_args = parser.parse_known_args() + # Ignore all args after the first "--" + args_to_parse = split_at_string(sys.argv[1:], "--")[0] + # Check for help + if any(item in args_to_parse for item in ["-h", "--help"]): + # Likely we need to show help output... + try: + args = vars(parser.parse_known_args(args_to_parse)[0]) + # We've exited by now if not invoking a subparser: jlmkr.py --help + if args.get("help"): + need_help = True + command = args.get("command") - if args.subcommand == "install": - sys.exit(install_jailmaker()) + # Edge case for some commands + if command in split_commands and args["jail_name"]: + # Ignore all args after the jail name + args_to_parse = split_at_string(args_to_parse, args["jail_name"])[0] + # Add back the jail_name as it may be a required positional and we + # don't want to end up in the except clause below + args_to_parse += [args["jail_name"]] + # Parse one more time... + args = vars(parser.parse_known_args(args_to_parse)[0]) + # ...and check if help is still in the remaining args + need_help = args.get("help") + print(need_help) - elif args.subcommand == "create": - sys.exit(create_jail_interactive()) + if need_help: + commands[command].print_help() + sys.exit() + except ExceptionWithParser as e: + # Print help output on error, e.g. due to: + # "error: the following arguments are required" + if e.parser.add_help: + e.parser.print_help() + sys.exit() - elif args.subcommand == "start": - sys.exit(start_jail(args.name)) + # Exit on parse errors (e.g. missing positional args) + for command in commands: + commands[command].exit_on_error = True - elif args.subcommand == "restart": - sys.exit(restart_jail(args.name)) + # Parse to find command and function and ignore unknown args which may be present + # such as args intended to pass through to systemd-run + args = vars(parser.parse_known_args()[0]) + command = args.pop("command", None) - elif args.subcommand == "shell": - sys.exit(shell_jail(additional_args)) + # Start over with original args + args_to_parse = sys.argv[1:] - elif args.subcommand == "exec": - sys.exit(exec_jail(args.name, args.cmd, additional_args)) + if not command: + # Parse args and show error for unknown args + parser.parse_args(args_to_parse) - elif args.subcommand == "status": - sys.exit(status_jail(args.name)) - - elif args.subcommand == "log": - sys.exit(log_jail(args.name)) - - elif args.subcommand == "stop": - sys.exit(stop_jail(args.name)) - - elif args.subcommand == "edit": - sys.exit(edit_jail(args.name)) - - elif args.subcommand == "remove": - sys.exit(remove_jail(args.name)) - - elif args.subcommand == "list": - sys.exit(list_jails()) - - elif args.subcommand == "images": - sys.exit(run_lxc_download_script()) - - elif args.subcommand == "startup": - sys.exit(startup_jails()) - - else: if agree("Create a new jail?", "y"): print() - sys.exit(create_jail_interactive()) + sys.exit(create_jail()) else: - parser.print_usage() + parser.print_help() + sys.exit() + + elif command == "shell": + # Pass anything after the "shell" command to machinectl + _, shell_args = split_at_string(args_to_parse, command) + sys.exit(args["func"](shell_args)) + elif command in split_commands and args["jail_name"]: + jlmkr_args, remaining_args = split_at_string(args_to_parse, args["jail_name"]) + if remaining_args and remaining_args[0] != "--": + # Add "--" after the jail name to ensure further args, e.g. + # --help or --version, are captured as systemd_nspawn_user_args + args_to_parse = jlmkr_args + [args["jail_name"], "--"] + remaining_args + + # Parse args again, but show error for unknown args + args = vars(parser.parse_args(args_to_parse)) + # Clean the args + args.pop("help") + args.pop("command", None) + func = args.pop("func") + sys.exit(func(**args)) if __name__ == "__main__": diff --git a/templates/docker/README.md b/templates/docker/README.md index 29212ce..b7e0764 100644 --- a/templates/docker/README.md +++ b/templates/docker/README.md @@ -2,4 +2,4 @@ ## Setup -Check out the [config](./config) template file. You may provide it when asked during `jlmkr create` or, if you have the template file stored on your NAS, you may provide it directly by running `jlmkr create mydockerjail /mnt/tank/path/to/docker/config`. \ No newline at end of file +Check out the [config](./config) template file. You may provide it when asked during `jlmkr create` or, if you have the template file stored on your NAS, you may provide it directly by running `jlmkr create --config /mnt/tank/path/to/docker/config mydockerjail`. \ No newline at end of file diff --git a/templates/incus/README.md b/templates/incus/README.md index 36d0add..70cc801 100644 --- a/templates/incus/README.md +++ b/templates/incus/README.md @@ -6,7 +6,7 @@ ## Setup -Check out the [config](./config) template file. You may provide it when asked during `jlmkr create` or, if you have the template file stored on your NAS, you may provide it directly by running `jlmkr create myincusjail /mnt/tank/path/to/incus/config`. +Check out the [config](./config) template file. You may provide it when asked during `jlmkr create` or, if you have the template file stored on your NAS, you may provide it directly by running `jlmkr create --config /mnt/tank/path/to/incus/config myincusjail`. Unfortunately incus doesn't want to install from the `initial_setup` script inside the config file. So we manually finish the setup by running the following after creating and starting the jail: diff --git a/templates/lxd/README.md b/templates/lxd/README.md index 03e3026..baa24cb 100644 --- a/templates/lxd/README.md +++ b/templates/lxd/README.md @@ -6,7 +6,7 @@ ## Setup -Check out the [config](./config) template file. You may provide it when asked during `jlmkr create` or, if you have the template file stored on your NAS, you may provide it directly by running `jlmkr create mylxdjail /mnt/tank/path/to/lxd/config`. +Check out the [config](./config) template file. You may provide it when asked during `jlmkr create` or, if you have the template file stored on your NAS, you may provide it directly by running `jlmkr create --config /mnt/tank/path/to/lxd/config mylxdjail`. Unfortunately snapd doesn't want to install from the `initial_setup` script inside the config file. So we manually finish the setup by running the following after creating and starting the jail: diff --git a/templates/podman/README.md b/templates/podman/README.md index b27682e..dba7fbd 100644 --- a/templates/podman/README.md +++ b/templates/podman/README.md @@ -2,7 +2,7 @@ ## Setup -Check out the [config](./config) template file. You may provide it when asked during `jlmkr create` or, if you have the template file stored on your NAS, you may provide it directly by running `jlmkr create mypodmanjail /mnt/tank/path/to/podman/config`. +Check out the [config](./config) template file. You may provide it when asked during `jlmkr create` or, if you have the template file stored on your NAS, you may provide it directly by running `jlmkr create --config /mnt/tank/path/to/podman/config mypodmanjail`. ## Rootless