Skip to content
Open
108 changes: 101 additions & 7 deletions src/cfengine_cli/cfengine_wrapper/arg_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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",
Expand Down Expand Up @@ -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`

Expand Down
95 changes: 87 additions & 8 deletions src/cfengine_cli/cfengine_wrapper/cfengine_commands.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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)
74 changes: 59 additions & 15 deletions src/cfengine_cli/cfengine_wrapper/cfengine_utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from collections import namedtuple
import os
import shutil
import random
Expand All @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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})"
)
Expand Down
22 changes: 20 additions & 2 deletions src/cfengine_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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}'")


Expand Down