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",
Comment thread
SimonThalvorsen marked this conversation as resolved.
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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't the whole point with CFEngine CLI that it should not be used in scripts? I feel options like --non-interactive kind of works against that. Do we already have these options for other sub commands?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not for most commands, only init and for build/deploy. I added them as niceties when testing a module, that way I could just do cfengine build --host HOST --non-interactive and wait for output rather than pressing enter every 5 seconds.

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
Comment thread
SimonThalvorsen marked this conversation as resolved.
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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ref. comment above. Will this run the agent again?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, thats why a deploy without run would be nice. The automatic run after deployment catches the output unless you specify log-level DEBUG

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't you just check of you are inside a cfbs-project, rather than catching all exceptions? This would hide bugs like out of bound array access, etc.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you can create a follow-up ticket for this?

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":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At some point, these should be a dictionary look-ups with functions. I think it's also possible to add functions directly into the argument parser. However, this is probably out of scope for this PR.

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