From 9641637d4d27df0c1d524a20c63adae90ff424fa Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sat, 20 Dec 2025 18:24:46 +1100 Subject: [PATCH] Add support for an enroll.ini config file to store arguments per subcommand, to avoid having to remember them all for repetitive executions. --- CHANGELOG.md | 2 + README.md | 56 ++++++++++ debian/changelog | 9 ++ enroll/cli.py | 264 ++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 330 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d8d6e4..90478e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ * Allow the user to add extra paths to harvest, or paths to ignore, using `--exclude-path` and `--include-path` arguments. + * Add support for an enroll.ini config file to store arguments per subcommand, to avoid having to remember + them all for repetitive executions. # 0.1.2 diff --git a/README.md b/README.md index 84a6965..a5d2157 100644 --- a/README.md +++ b/README.md @@ -336,3 +336,59 @@ ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml ```bash ansible-playbook /tmp/enroll-ansible/playbooks/"$(hostname -f)".yml ``` + +## Configuration file + +As can be seen above, there are a lot of powerful 'permutations' available to all four subcommands. + +Sometimes, it can be easier to store them in a config file so you don't have to remember them! + +Enroll supports reading an ini-style file of all the arguments for each subcommand. + +### Location of the config file + +The path the config file can be specified with `-c` or `--config` on the command-line. Otherwise, +Enroll will look for `./enroll.ini`, `./.enroll.ini` (in the current working directory), +``~/.config/enroll/enroll.ini` (or `$XDG_CONFIG_HOME/enroll/enroll.ini`). + +You may also pass `--no-config` if you deliberately want to ignore the config file even if it existed. + +### Precedence + +Highest wins: + + * Explicit CLI flags + * INI config ([cmd], [enroll]) + * argparse defaults + +### Example config file + +Here is an example. + +Whenever an argument on the command-line has a 'hyphen' in it, just be sure to change it to an underscore in the ini file. + +```ini +[enroll] +# (future global flags may live here) + +[harvest] +dangerous = false +include_path = + /home/*/.bashrc + /home/*/.profile +exclude_path = /usr/local/bin/docker-*, /usr/local/bin/some-tool +# remote_host = yourserver.example.com +# remote_user = you +# remote_port = 2222 + +[manifest] +# you can set defaults here too, e.g. +no_jinjaturtle = true +sops = 00AE817C24A10C2540461A9C1D7CDE0234DB458D + +[single-shot] +# if you use single-shot, put its defaults here. +# It does not inherit those of the subsections above, so you +# may wish to repeat them here. +include_path = re:^/home/[^/]+/\.config/myapp/.*$ +``` diff --git a/debian/changelog b/debian/changelog index 0b16cfa..f6ba2f7 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,12 @@ +enroll (0.1.3) unstable; urgency=medium + + * Allow the user to add extra paths to harvest, or paths to ignore, using `--exclude-path` and `--include-path` + arguments. + * Add support for an enroll.ini config file to store arguments per subcommand, to avoid having to remember + them all for repetitive executions. + + -- Miguel Jacq Sat, 20 Dec 2025 18:24:00 +1100 + enroll (0.1.2) unstable; urgency=medium * Include files from `/usr/local/bin` and `/usr/local/etc` in harvest (assuming they aren't binaries or diff --git a/enroll/cli.py b/enroll/cli.py index f6efe11..e5f729d 100644 --- a/enroll/cli.py +++ b/enroll/cli.py @@ -1,7 +1,9 @@ from __future__ import annotations import argparse +import configparser import os +import sys import tarfile import tempfile from pathlib import Path @@ -15,6 +17,232 @@ from .remote import remote_harvest from .sopsutil import SopsError, encrypt_file_binary +def _discover_config_path(argv: list[str]) -> Optional[Path]: + """Return the config path to use, if any. + + Precedence: + 1) --no-config disables loading. + 2) --config PATH (or -c PATH) + 3) $ENROLL_CONFIG + 4) ./enroll.ini, ./.enroll.ini + 5) $XDG_CONFIG_HOME/enroll/enroll.ini (or ~/.config/enroll/enroll.ini) + + The config file is optional; if no file is found, returns None. + """ + + # Quick scan for explicit flags without needing to build the full parser. + if "--no-config" in argv: + return None + + def _value_after(flag: str) -> Optional[str]: + try: + i = argv.index(flag) + except ValueError: + return None + if i + 1 >= len(argv): + return None + return argv[i + 1] + + p = _value_after("--config") or _value_after("-c") + if p: + return Path(p).expanduser() + + envp = os.environ.get("ENROLL_CONFIG") + if envp: + return Path(envp).expanduser() + + cwd = Path.cwd() + for name in ("enroll.ini", ".enroll.ini"): + cp = cwd / name + if cp.exists() and cp.is_file(): + return cp + + xdg = os.environ.get("XDG_CONFIG_HOME") + if xdg: + base = Path(xdg).expanduser() + else: + base = Path.home() / ".config" + cp = base / "enroll" / "enroll.ini" + if cp.exists() and cp.is_file(): + return cp + + return None + + +def _parse_bool(s: str) -> Optional[bool]: + v = str(s).strip().lower() + if v in {"1", "true", "yes", "y", "on"}: + return True + if v in {"0", "false", "no", "n", "off"}: + return False + return None + + +def _action_lookup(p: argparse.ArgumentParser) -> dict[str, argparse.Action]: + """Map config keys -> argparse actions for a parser. + + Accepts both dest names and long option names without leading dashes, + normalized with '-' -> '_'. + """ + + m: dict[str, argparse.Action] = {} + for a in p._actions: # noqa: SLF001 (argparse internal) + if not getattr(a, "dest", None): + continue + dest = str(a.dest).strip().lower() + if dest: + m[dest] = a + for opt in getattr(a, "option_strings", []) or []: + k = opt.lstrip("-").strip().lower() + if k: + m[k.replace("-", "_")] = a + m[k] = a + return m + + +def _choose_flag(a: argparse.Action) -> Optional[str]: + # Prefer a long flag if available (e.g. --dangerous over -d) + for s in getattr(a, "option_strings", []) or []: + if s.startswith("--"): + return s + for s in getattr(a, "option_strings", []) or []: + return s + return None + + +def _split_list_value(v: str) -> list[str]: + # Support comma-separated and/or multi-line lists. + raw = str(v) + if "\n" in raw: + parts = [p.strip() for p in raw.splitlines()] + return [p for p in parts if p] + if "," in raw: + parts = [p.strip() for p in raw.split(",")] + return [p for p in parts if p] + raw = raw.strip() + return [raw] if raw else [] + + +def _section_to_argv( + p: argparse.ArgumentParser, cfg: configparser.ConfigParser, section: str +) -> list[str]: + """Translate an INI section into argv tokens for this parser.""" + if not cfg.has_section(section): + return [] + + lookup = _action_lookup(p) + out: list[str] = [] + + for k, v in cfg.items(section): + key = str(k).strip().lower().replace("-", "_") + # Avoid recursion / confusing self-configuration. + if key in {"config", "no_config"}: + continue + + a = lookup.get(key) + if not a: + # Unknown keys are ignored (but we try to be helpful). + print( + f"warning: config [{section}] contains unknown option '{k}' (ignored)", + file=sys.stderr, + ) + continue + + flag = _choose_flag(a) + if not flag: + continue + + # Boolean flags + if isinstance(a, argparse._StoreTrueAction): # noqa: SLF001 + b = _parse_bool(v) + if b is True: + out.append(flag) + continue + if isinstance(a, argparse._StoreFalseAction): # noqa: SLF001 + b = _parse_bool(v) + if b is False: + out.append(flag) + continue + + # Repeated options + if isinstance(a, argparse._AppendAction): # noqa: SLF001 + for item in _split_list_value(v): + out.extend([flag, item]) + continue + + # Count flags (rare, but easy to support) + if isinstance(a, argparse._CountAction): # noqa: SLF001 + b = _parse_bool(v) + if b is True: + out.append(flag) + else: + try: + n = int(str(v).strip()) + except ValueError: + n = 0 + out.extend([flag] * max(0, n)) + continue + + # Standard scalar options + sval = str(v).strip() + if sval: + out.extend([flag, sval]) + + return out + + +def _inject_config_argv( + argv: list[str], + *, + cfg_path: Optional[Path], + root_parser: argparse.ArgumentParser, + subparsers: dict[str, argparse.ArgumentParser], +) -> list[str]: + """Return argv with config-derived tokens inserted. + + We insert: + - [enroll] options before the subcommand + - [] options immediately after the subcommand token + + CLI flags always win because they come later in argv. + """ + + if not cfg_path: + return argv + cfg_path = Path(cfg_path).expanduser() + if not (cfg_path.exists() and cfg_path.is_file()): + return argv + + cfg = configparser.ConfigParser() + try: + cfg.read(cfg_path, encoding="utf-8") + except (OSError, configparser.Error) as e: + raise SystemExit(f"error: failed to read config file {cfg_path}: {e}") + + global_tokens = _section_to_argv(root_parser, cfg, "enroll") + + # Find the subcommand token position. + cmd_pos: Optional[int] = None + cmd_name: Optional[str] = None + for i, tok in enumerate(argv): + if tok in subparsers: + cmd_pos = i + cmd_name = tok + break + if cmd_pos is None or cmd_name is None: + # No subcommand found (argparse will handle the error); only apply global. + return global_tokens + argv + + cmd_tokens = _section_to_argv(subparsers[cmd_name], cfg, cmd_name) + # Also accept section names with '_' in place of '-' (e.g. [single_shot]) + if "-" in cmd_name: + alt = cmd_name.replace("-", "_") + if alt != cmd_name: + cmd_tokens += _section_to_argv(subparsers[cmd_name], cfg, alt) + + return global_tokens + argv[: cmd_pos + 1] + cmd_tokens + argv[cmd_pos + 1 :] + + def _resolve_sops_out_file(out: Optional[str], *, hint: str) -> Path: """Resolve an output *file* path for --sops mode. @@ -95,6 +323,22 @@ def _add_remote_args(p: argparse.ArgumentParser) -> None: "--remote-host", help="SSH host to run harvesting on (if set, harvest runs remotely and is pulled locally).", ) + + +def _add_config_args(p: argparse.ArgumentParser) -> None: + p.add_argument( + "-c", + "--config", + help=( + "Path to an INI config file for default options. If omitted, enroll will look for " + "./enroll.ini, ./.enroll.ini, or ~/.config/enroll/enroll.ini (or $XDG_CONFIG_HOME/enroll/enroll.ini)." + ), + ) + p.add_argument( + "--no-config", + action="store_true", + help="Do not load any INI config file (even if one would be auto-discovered).", + ) p.add_argument( "--remote-port", type=int, @@ -110,9 +354,11 @@ def _add_remote_args(p: argparse.ArgumentParser) -> None: def main() -> None: ap = argparse.ArgumentParser(prog="enroll") + _add_config_args(ap) sub = ap.add_subparsers(dest="cmd", required=True) h = sub.add_parser("harvest", help="Harvest service/package/config state") + _add_config_args(h) h.add_argument( "--out", help=( @@ -163,6 +409,7 @@ def main() -> None: _add_remote_args(h) m = sub.add_parser("manifest", help="Render Ansible roles from a harvest") + _add_config_args(m) m.add_argument( "--harvest", required=True, @@ -195,6 +442,7 @@ def main() -> None: s = sub.add_parser( "single-shot", help="Harvest state, then manifest Ansible code, in one shot" ) + _add_config_args(s) s.add_argument( "--harvest", help=( @@ -255,6 +503,7 @@ def main() -> None: _add_remote_args(s) d = sub.add_parser("diff", help="Compare two harvests and report differences") + _add_config_args(d) d.add_argument( "--old", required=True, @@ -338,7 +587,20 @@ def main() -> None: help="Environment variable containing SMTP password (optional).", ) - args = ap.parse_args() + argv = sys.argv[1:] + cfg_path = _discover_config_path(argv) + argv = _inject_config_argv( + argv, + cfg_path=cfg_path, + root_parser=ap, + subparsers={ + "harvest": h, + "manifest": m, + "single-shot": s, + "diff": d, + }, + ) + args = ap.parse_args(argv) remote_host: Optional[str] = getattr(args, "remote_host", None)