Add support for an enroll.ini config file to store arguments per subcommand, to avoid having to remember them all for repetitive executions.
This commit is contained in:
parent
240e79706f
commit
9641637d4d
4 changed files with 330 additions and 1 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
56
README.md
56
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/.*$
|
||||
```
|
||||
|
|
|
|||
9
debian/changelog
vendored
9
debian/changelog
vendored
|
|
@ -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 <mig@mig5.net> 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
|
||||
|
|
|
|||
264
enroll/cli.py
264
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
|
||||
- [<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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue