diff --git a/src/cfengine_cli/cfengine_wrapper/arg_parse.py b/src/cfengine_cli/cfengine_wrapper/arg_parse.py index 3211243..a00df17 100644 --- a/src/cfengine_cli/cfengine_wrapper/arg_parse.py +++ b/src/cfengine_cli/cfengine_wrapper/arg_parse.py @@ -7,10 +7,93 @@ add_uninstall_args, add_spawn_args, add_destroy_args, + add_connect_args, ) def parse_wrapper_args(subp: argparse._SubParsersAction): + update_parser = subp.add_parser( + "update", + help="Updates the current cfbs project", + description="A wrapper around the cfbs `update` function", + ) + update_parser.add_argument( + "to_update", + nargs="*", + help="Directory of cfbs-project to update", + ) + remove_parser = subp.add_parser( + "remove", + help="Removes the specified module(s) from cfbs project", + description="A wrapper around the cfbs `remove` function", + ) + remove_parser.add_argument( + "module", + nargs="+", + help="Module(s) for which to remove", + ) + + add_parser = subp.add_parser( + "add", + help="Adds the specified module(s) to cfbs project", + description="A wrapper around the cfbs `add` function", + ) + add_parser.add_argument( + "module", + nargs="+", + help="Module(s) for which to add", + ) + search_parser = subp.add_parser( + "search", + help="Searches the build-index for specified module(s)", + description="A wrapper around the cfbs `search` function", + ) + search_parser.add_argument( + "module", + nargs="+", + help="Module(s) for which to lookup", + ) + + input_parser = subp.add_parser( + "input", + help="Sets/updates input.json for selected module(s)", + description="A wrapper around the cfbs `input` function", + ) + input_parser.add_argument( + "module", + nargs="+", + help="Module(s) for which to set input", + ) + + add_connect_args( + subp.add_parser( + "connect", + help="Opens interactive ssh shell", + description="A wrapper around cf-remote `connect` function", + ) + ) + + moduleinfo_parser = subp.add_parser( + "moduleinfo", + help="Shows information about your cfbs-project or a specific module", + description="A wrapper around the cfbs `status` function", + ) + + moduleinfo_parser.add_argument( + "modules", + nargs="*", + help="Module(s) for which you would like more info, utilizes cfbs `info` function", + ) + + show_parser = subp.add_parser( + "show", help="Shows your saved host-groups or info about a specified host" + ) + show_parser.add_argument( + "--hosts", + "--host", + "-H", + help="Shows more specific information about specific host(s)", + ) add_save_args( subp.add_parser( @@ -29,18 +112,29 @@ def parse_wrapper_args(subp: argparse._SubParsersAction): default=None, ) - subp.add_parser( + sp = subp.add_parser( "build", - help="""Build a policy set from a CFEngine Build project. -A wrapper around the cfbs `build`-function.""", + help="Build a policy set from a CFEngine Build project", + description="A wrapper around the cf-remote `build`-function with some added niceties", + ) + sp.add_argument( + "--non-interactive", + help="Non-interactive mode (picks the default for all prompts)", + action="store_true", ) + sp.add_argument("--hub", help="Hub(s) to deploy to after building", type=str) deploy_parser = subp.add_parser( "deploy", - help="""Deploy policy-set (masterfiles) to hub. -A wrapper around the cf-remote `deploy`-function with some added niceties.""", + help="Deploy policy-set (masterfiles) to hub.", + description="A wrapper around the cf-remote `deploy`-function with some added niceties.", ) add_deploy_args(deploy_parser) + deploy_parser.add_argument( + "--non-interactive", + help="Non-interactive mode (picks the default for all prompts)", + action="store_true", + ) install_parser = subp.add_parser( "install", @@ -81,8 +175,8 @@ def parse_wrapper_args(subp: argparse._SubParsersAction): run_parser = subp.add_parser( "run", - description="Run the CFEngine agent, fetching, evaluating, and enforcing policy.\n\ -A wrapper around the cf-remote `run`-function with some added niceties", + help="Run the CFEngine agent, fetching, evaluating, and enforcing policy.", + description="A wrapper around the cf-remote `run`-function with some added niceties", epilog="""Examples: `cfengine run` defaults to use `cf-agent -KIf update.cf && cf-agent -KI` diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py index a688739..3447b19 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py @@ -1,11 +1,20 @@ import os -from cfbs.commands import build_command +from cfbs.commands import build_command, info_command, status_command from cf_remote import log -from cf_remote.commands import deploy as deploy_command +from cf_remote.commands import deploy as deploy_command, info from cf_remote.commands import destroy as destroy_command from cf_remote.commands import save as save_command +from cf_remote.commands import show as show_command from cf_remote.remote import run_command, transfer_file +from cf_remote.commands import connect_cmd +from cfbs.commands import ( + input_command, + add_command, + remove_command, + update_command, + search_command, +) from cfengine_cli.utils import UserError from cfengine_cli.cfengine_wrapper.cfengine_objects import ( @@ -228,17 +237,87 @@ def destroy(groupname, del_all=False) -> int: return destroy_command(groupname) -def build() -> int: +def build(hub=None, non_interactive=False) -> int: rc = build_command() if rc != 0: return rc - if prompt_yes_no("Deploy the built policy set now?", default=True): - return deploy(None, None) + if prompt_yes_no( + "Deploy the built policy set now?", + default=True, + non_interactive=non_interactive, + ): + return deploy(hub, None, non_interactive) return 0 -def deploy(target: str | list[str] | None, masterfiles: str | None = None) -> int: +def deploy( + target: str | list[str] | None, + masterfiles: str | None = None, + non_interactive: bool = False, +) -> int: + error = 0 if isinstance(target, str): target = [target] - hubs = [require_executable("cf-agent", h).location for h in (target or [])] or None - return deploy_command(hubs, masterfiles) + hubs = { + x.location: x + for h in (target or []) + for x in [require_executable("cf-agent", h)] + } or None + + # TODO/WOULD be nice: Deploy without run + if hubs: + error = deploy_command(hubs.keys(), masterfiles) + else: + return deploy_command(hubs, masterfiles) + + if prompt_yes_no( + "Run policy set now?", default=True, non_interactive=non_interactive + ): + for hub in hubs: + hubs[hub].run("-KIf update.cf", "-KI") + return error + + +def show(target: list[str] | None = None) -> int: + if target == [] or target is None: + return show_command(False) + if isinstance(target, str): + target = [target] + return info(target) + + +def moduleinfo(modules: list[str]) -> int: + if modules == []: + try: + status_command() + except Exception as e: + log.error( + f"Failed to validate cfbs-status, make sure you are inside a cfbs-project: {e}" + ) + return -1 + + return info_command(modules) + + +def connect(host) -> int: + return connect_cmd(host) + + +def cfbs_input(modules: list[str]) -> int: + return input_command(modules, "cfengine input") + + +def cfbs_add(modules: list[str]) -> int: + return add_command(modules, "cfengine input") + + +def cfbs_remove(modules: list[str] | None = None) -> int: + return remove_command(modules, "cfengine input") + + +def cfbs_update(to_update) -> int: + return update_command(to_update) + + +def cfbs_search(modules: list[str]) -> int: + return search_command(modules) diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py b/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py index 4b16ee0..17e5ca3 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py @@ -1,3 +1,4 @@ +from collections import namedtuple import os import shutil import random @@ -16,7 +17,9 @@ DEFAULT_MAX_REPORT_HOSTS = 25 -def prompt_yes_no(prompt: str, default: bool = True) -> bool: +def prompt_yes_no(prompt: str, default: bool = True, non_interactive=False) -> bool: + if non_interactive: + return default suffix = "[Y/n]" if default else "[y/N]" answer = input(f"{prompt} {suffix} ").strip().lower() if not answer: @@ -103,21 +106,34 @@ def _hosts_with_info(role_filter=None): yield host, aliases, data -def _find_all(binary_name: str) -> list[Executable]: - """Every location -- local, plus every matching remote host -- with `binary_name` installed.""" - executables = [] +_Id = namedtuple("_Id", "location aliases") + + +def _identities(binary_name: str) -> Iterator[_Id]: + """local + every known host as (location, aliases), without connecting.""" + yield _Id("local", []) + for host, aliases in _known_hosts(None if binary_name == "cf-agent" else "hub"): + yield _Id(host, aliases) + + +def _resolve(binary_name: str, ident: _Id) -> Executable | None: + """Connect (if remote) and build an Executable, or None if unavailable.""" + if ident.location == "local": + path = _find_local_path(binary_name) + return Executable(binary_name, "local", path) if path else None + data = _host_info(ident.location) + if not data: + return None + # band-aid: hostinfo has no path for cf-hub, so assume it's on PATH + path = data.get("agent") if binary_name == "cf-agent" else "cf-hub" + return ( + Executable(binary_name, ident.location, path, ident.aliases) if path else None + ) - local_path = _find_local_path(binary_name) - if local_path: - executables.append(Executable(binary_name, "local", local_path)) - is_agent = binary_name == "cf-agent" - for host, aliases, data in _hosts_with_info(None if is_agent else "hub"): - # band-aid: hostinfo has no path for cf-hub, so assume it's on PATH - path = data.get("agent") if is_agent else "cf-hub" - if path: - executables.append(Executable(binary_name, host, path, aliases)) - return executables +def _find_all(binary_name: str) -> list[Executable]: + """Every location with `binary_name` installed (connects to all).""" + return [e for i in _identities(binary_name) if (e := _resolve(binary_name, i))] def _find_all_paired() -> list[Installation]: @@ -195,7 +211,35 @@ def _select(candidates, description, target: str | None = None): def require_executable(name: str, target: str | None = None) -> Executable: - chosen = _select(_find_all(name), name, target) + if isinstance(target, list): + if len(target) > 1: + raise UserError( + f"Expected a single {name}, but got {len(target)}: {', '.join(target)}." + ) + target = target[0] if target else None + + if target: + idents = list(_identities(name)) + matched = [i for i in idents if _exact_match(i, target)] or [ + i for i in idents if _loose_match(i, target) + ] + if not matched: + raise UserError( + f"No installation of {name} matches '{target}'. " + f"Known: {', '.join(i.location for i in idents)}." + ) + candidates = [e for i in matched if (e := _resolve(name, i))] + if not candidates: + raise UserError( + f"'{target}' matches a known host for {name}, but it is " + f"unreachable or lacks {name}." + ) + chosen = ( + candidates[0] if len(candidates) == 1 else _prompt_choice(candidates, name) + ) + else: + chosen = _select(_find_all(name), name) + log.info( f"Using {'local' if chosen.is_local else 'remote'} installation of {name} ({chosen.label})" ) diff --git a/src/cfengine_cli/main.py b/src/cfengine_cli/main.py index 1f4e9a2..c8e8c5a 100644 --- a/src/cfengine_cli/main.py +++ b/src/cfengine_cli/main.py @@ -222,9 +222,21 @@ def run_command_with_args(args) -> int: if args.command == "init": return commands.init(args) if args.command == "build": - return cfengine_commands.build() + return cfengine_commands.build(args.hub, args.non_interactive) if args.command == "deploy": - return cfengine_commands.deploy(args.hub, args.masterfiles) + return cfengine_commands.deploy( + args.hub, args.masterfiles, args.non_interactive + ) + if args.command == "input": + return cfengine_commands.cfbs_input(args.module) + if args.command == "add": + return cfengine_commands.cfbs_add(args.module) + if args.command == "remove": + return cfengine_commands.cfbs_remove(args.module) + if args.command == "search": + return cfengine_commands.cfbs_search(args.module) + if args.command == "update": + return cfengine_commands.cfbs_update(args.to_update) if args.command == "format": return commands.format(args.files, args.line_length, args.check) if args.command == "lint": @@ -325,6 +337,12 @@ def run_command_with_args(args) -> int: return commands.profile(args) if args.command == "up": return commands.up(args) + if args.command == "show": + return cfengine_commands.show(args.hosts) + if args.command == "moduleinfo": + return cfengine_commands.moduleinfo(args.modules) + if args.command == "connect": + return cfengine_commands.connect(args.hosts) raise UserError(f"Unknown command: '{args.command}'")