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`
|
* Allow the user to add extra paths to harvest, or paths to ignore, using `--exclude-path` and `--include-path`
|
||||||
arguments.
|
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
|
# 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
|
```bash
|
||||||
ansible-playbook /tmp/enroll-ansible/playbooks/"$(hostname -f)".yml
|
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
|
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
|
* 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import configparser
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -15,6 +17,232 @@ from .remote import remote_harvest
|
||||||
from .sopsutil import SopsError, encrypt_file_binary
|
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:
|
def _resolve_sops_out_file(out: Optional[str], *, hint: str) -> Path:
|
||||||
"""Resolve an output *file* path for --sops mode.
|
"""Resolve an output *file* path for --sops mode.
|
||||||
|
|
||||||
|
|
@ -95,6 +323,22 @@ def _add_remote_args(p: argparse.ArgumentParser) -> None:
|
||||||
"--remote-host",
|
"--remote-host",
|
||||||
help="SSH host to run harvesting on (if set, harvest runs remotely and is pulled locally).",
|
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(
|
p.add_argument(
|
||||||
"--remote-port",
|
"--remote-port",
|
||||||
type=int,
|
type=int,
|
||||||
|
|
@ -110,9 +354,11 @@ def _add_remote_args(p: argparse.ArgumentParser) -> None:
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
ap = argparse.ArgumentParser(prog="enroll")
|
ap = argparse.ArgumentParser(prog="enroll")
|
||||||
|
_add_config_args(ap)
|
||||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
h = sub.add_parser("harvest", help="Harvest service/package/config state")
|
h = sub.add_parser("harvest", help="Harvest service/package/config state")
|
||||||
|
_add_config_args(h)
|
||||||
h.add_argument(
|
h.add_argument(
|
||||||
"--out",
|
"--out",
|
||||||
help=(
|
help=(
|
||||||
|
|
@ -163,6 +409,7 @@ def main() -> None:
|
||||||
_add_remote_args(h)
|
_add_remote_args(h)
|
||||||
|
|
||||||
m = sub.add_parser("manifest", help="Render Ansible roles from a harvest")
|
m = sub.add_parser("manifest", help="Render Ansible roles from a harvest")
|
||||||
|
_add_config_args(m)
|
||||||
m.add_argument(
|
m.add_argument(
|
||||||
"--harvest",
|
"--harvest",
|
||||||
required=True,
|
required=True,
|
||||||
|
|
@ -195,6 +442,7 @@ def main() -> None:
|
||||||
s = sub.add_parser(
|
s = sub.add_parser(
|
||||||
"single-shot", help="Harvest state, then manifest Ansible code, in one shot"
|
"single-shot", help="Harvest state, then manifest Ansible code, in one shot"
|
||||||
)
|
)
|
||||||
|
_add_config_args(s)
|
||||||
s.add_argument(
|
s.add_argument(
|
||||||
"--harvest",
|
"--harvest",
|
||||||
help=(
|
help=(
|
||||||
|
|
@ -255,6 +503,7 @@ def main() -> None:
|
||||||
_add_remote_args(s)
|
_add_remote_args(s)
|
||||||
|
|
||||||
d = sub.add_parser("diff", help="Compare two harvests and report differences")
|
d = sub.add_parser("diff", help="Compare two harvests and report differences")
|
||||||
|
_add_config_args(d)
|
||||||
d.add_argument(
|
d.add_argument(
|
||||||
"--old",
|
"--old",
|
||||||
required=True,
|
required=True,
|
||||||
|
|
@ -338,7 +587,20 @@ def main() -> None:
|
||||||
help="Environment variable containing SMTP password (optional).",
|
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)
|
remote_host: Optional[str] = getattr(args, "remote_host", None)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue