From b4fb82a9acaad2017b57d2a82031b3831889ee85 Mon Sep 17 00:00:00 2001 From: Simon Halvorsen Date: Fri, 10 Jul 2026 15:09:00 +0200 Subject: [PATCH] Added cfengine run, a "smart" wrapper around cf-remote run with some added niceties `cfengine run` is a wrapper around the run functionality from cf-remote. Allows the user to run CFEngine policy on remote/local installations with the same command. - Auto-discovers cf-agent locally and across all hosts known to cf-remote (via its saved/spawned state), instead of requiring the user to know or specify where it's installed. - When multiple installations are found, prompts interactively for which one to use; add --host to skip the prompt and select one directly, non-interactively. - Installations can be selected by cf-remote's own friendly names (e.g. --host hub), not just IP address. - Runs cf-agent as root automatically when needed (both locally via sudo and remotely via cf-remote), so cf-agent picks up policy from /var/cfengine instead of falling back to the invoking user's home directory. - Bare filenames are accepted directly (`cfengine run mypolicy.cf`); -KIf is inserted automatically unless flags are already present. Has some added classes and utils to easily adapt the rest of the wanted future features such as: report, spawn/destroy, etc. Ticket: ENT-14120 Changelog: None Signed-off-by: Simon Halvorsen --- src/cfengine_cli/cfengine_wrapper/__init__.py | 0 .../cfengine_wrapper/cfengine_commands.py | 47 +++++ .../cfengine_wrapper/cfengine_objects.py | 120 +++++++++++ .../cfengine_wrapper/cfengine_utils.py | 191 ++++++++++++++++++ src/cfengine_cli/commands.py | 39 ---- src/cfengine_cli/main.py | 32 ++- 6 files changed, 384 insertions(+), 45 deletions(-) create mode 100644 src/cfengine_cli/cfengine_wrapper/__init__.py create mode 100644 src/cfengine_cli/cfengine_wrapper/cfengine_commands.py create mode 100644 src/cfengine_cli/cfengine_wrapper/cfengine_objects.py create mode 100644 src/cfengine_cli/cfengine_wrapper/cfengine_utils.py diff --git a/src/cfengine_cli/cfengine_wrapper/__init__.py b/src/cfengine_cli/cfengine_wrapper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py new file mode 100644 index 0000000..ba8327b --- /dev/null +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py @@ -0,0 +1,47 @@ +from cfbs.commands import build_command +from cf_remote.commands import deploy as deploy_command +from cf_remote.commands import install as install_command +from cf_remote.commands import spawn as spawn_command +from cf_remote.commands import destroy as destroy_command + +from cfengine_cli.cfengine_wrapper.cfengine_utils import ( + require_executable, + require_installation, +) + + +def report(target: str | None = None) -> int: # TODO? ENT-14122 + installation = require_installation(target) + rc = installation.agent.run("-KIf update.cf", "-KI") + if rc != 0: + return rc + return installation.hub.run( + "--query rebase -H 127.0.0.1", "--query delta -H 127.0.0.1" + ) + + +def run(*args, target: str | None = None) -> int: + agent = require_executable("cf-agent", target) + if args: + return agent.run(*args) + return agent.run("-KIf update.cf", "-KI") + + +def install() -> int: # TODO ENT-14117 + return install_command(None, None) + + +def spawn() -> int: # TODO ENT-14118 + return spawn_command(None, None, None, None) + + +def destroy() -> int: # TODO ENT-14118 + return destroy_command(None) + + +def build() -> int: # TODO ENT-14119 + return build_command() + + +def deploy() -> int: # TODO ENT-14119 + return deploy_command(None, None) diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_objects.py b/src/cfengine_cli/cfengine_wrapper/cfengine_objects.py new file mode 100644 index 0000000..854674c --- /dev/null +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_objects.py @@ -0,0 +1,120 @@ +from dataclasses import dataclass +from cf_remote.remote import run_command +import subprocess +import logging +import os + + +def _ensure_default_agent_flags(command: str) -> str: + """ + cf-agent needs -K (no-lock), -I (inform), and -f (specify + file) to actually run against a policy file the way people expect. + If `command` has no flags at all -- e.g. someone just ran + `cfengine run mypolicy.cf` -- prepend "-KIf" so a bare filename works. + + Deliberately does NOT try to detect and fill in individual missing + letters (e.g. leaving -f out of "-KI somefile.cf") + If any flag is present at all, we assume the invocation was deliberate and + leave it exactly as given. + """ + tokens = command.split() + has_flags = any(t.startswith("-") for t in tokens) + if has_flags: + return command + return f"-KIf {command}".strip() + + +class Executable: + """ + A single binary (cf-agent or cf-hub) at a known location -- either + "local" or a remote host identifier ("user@ip"). Knows its own path + and how to run a command against itself, whether that means a local + subprocess or an SSH call via cf-remote. + """ + + def __init__(self, name: str, location: str, path: str, aliases=None) -> None: + self.name = name # "cf-agent" / "cf-hub" + self.location = location # "local" or "user@ip" + self.path = path # absolute path to the binary at that location + self.aliases = ( + aliases or [] + ) # friendly names from cf-remote's saved state, e.g. "hub", "local", "remote" + + @property + def is_local(self) -> bool: + return self.location == "local" + + @property + def label(self) -> str: + """Best human-facing identifier: a friendly alias if we have one, else the location.""" + if self.aliases: + return f"{self.aliases[0]} ({self.location})" + return self.location + + def run(self, *commands) -> int: + rc = 0 + for command in commands: + rc = self._run_one(command) + if rc != 0: + return rc + return rc + + def _run_one(self, command: str) -> int: + if self.name == "cf-agent": + command = _ensure_default_agent_flags(command) + + if self.is_local: + args = [self.path] + command.split() + # cf-agent picks its workdir based on privilege: as root it uses + # /var/cfengine/inputs (where policy actually lives); as a + # regular user it falls back to ~/.cfagent/inputs instead, + # which won't have update.cf. Elevate to match the remote path, + # which already runs everything via sudo. + if os.geteuid() != 0: + args = ["sudo"] + args + result = subprocess.run(args) + return result.returncode + + full_command = f"{self.path} {command}" + logging.warning(f"Executing command {full_command} on {self.location}") + output = run_command(self.location, full_command, sudo=True) + if ( + output is None + ): # TODO: Test this, I've had some policy failing but returning output instead or error-code (I think) + # Error already logged in run_command + return 1 + if output: + print(output) + return 0 + + def __repr__(self) -> str: + return f"" + + +@dataclass +class Installation: + """ + One coherent system that has BOTH cf-agent and cf-hub, so callers + that need both (e.g. report()) can pick one location and get a + matched pair, rather than resolving each binary independently. + """ + + location: str + agent: Executable + hub: Executable + + @property + def is_local(self) -> bool: + return self.location == "local" + + @property + def aliases(self) -> list: + # agent and hub are always built from the same alias list for a + # given host (see _find_all_paired), so either would do here. + return self.agent.aliases + + @property + def label(self) -> str: + if self.aliases: + return f"{self.aliases[0]} ({self.location})" + return self.location diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py b/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py new file mode 100644 index 0000000..0870550 --- /dev/null +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py @@ -0,0 +1,191 @@ +import os +import shutil +import logging +import sys + +from collections.abc import Iterator +from cfengine_cli.paths import bin +from cfengine_cli.utils import UserError +from cfengine_cli.cfengine_wrapper.cfengine_objects import Executable, Installation +from cf_remote.remote import get_info +from cf_remote.paths import CLOUD_STATE_FPATH +from cf_remote.utils import read_json + + +def _find_local_path(binary_name: str) -> str | None: + candidate = bin(binary_name) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return shutil.which(binary_name) + + +def _known_hosts(role_filter=None) -> Iterator[tuple[str, list[str]]]: + """ + Yields (host_id, aliases) from cf-remote's saved/spawned VM state -- + host_id is "user@ip" (same source _get_hubs() in cf_remote.commands + reads from); aliases are the friendly names cf-remote knows this host + by: the group name it was saved/spawned under (e.g. "hub", "hub2") + and its per-host key within that group. Optionally filter by + vm["role"] (e.g. "hub" or "client"). + """ + if not os.path.exists(CLOUD_STATE_FPATH): + return + vms_info = read_json(CLOUD_STATE_FPATH) + if not vms_info: + return + for group_name, group in vms_info.items(): + alias_group = group_name.lstrip("@") + for host_key, vm in group.items(): + if host_key == "meta": + continue + if role_filter and vm.get("role") != role_filter: + continue + host_id = "{}@{}".format(vm["user"], vm["public_ips"][0]) + aliases = [alias_group] + if host_key != alias_group: + aliases.append(host_key) + yield host_id, aliases + + +def _find_all(binary_name: str) -> list[Executable]: + """Every location -- local, plus every matching remote host -- with `binary_name` installed.""" + executables = [] + + local_path = _find_local_path(binary_name) + if local_path: + executables.append(Executable(binary_name, "local", local_path)) + + role_filter = "hub" if binary_name == "cf-hub" else None + key = "agent" if binary_name == "cf-agent" else "hub" + for host, aliases in _known_hosts(role_filter=role_filter): + try: + data = get_info(host) + except (Exception, SystemExit) as e: + """Need to catch SystemExit as cf-remote's get_info() will SystemExit if + any ssh-connections does not work, for our case we still want to fetch + the ones that are up in case the user wants to use a different host""" + logging.warning(f"Skipping {host}: {e}") + continue + if not data: + continue + binary_path = data.get(key) + if binary_path: + executables.append( + Executable(binary_name, host, binary_path, aliases=aliases) + ) + + return executables + + +def _find_all_paired() -> list[Installation]: + """Every location -- local or remote -- that has BOTH cf-agent and cf-hub.""" + installations = [] + + local_agent_path = _find_local_path("cf-agent") + local_hub_path = _find_local_path("cf-hub") + if local_agent_path and local_hub_path: + installations.append( + Installation( + location="local", + agent=Executable("cf-agent", "local", local_agent_path), + hub=Executable("cf-hub", "local", local_hub_path), + ) + ) + + for host, aliases in _known_hosts(role_filter="hub"): + try: + data = get_info(host) + except Exception: + continue + if not data: + continue + agent_path = data.get("agent") + hub_path = data.get("hub") + if agent_path and hub_path: + installations.append( + Installation( + location=host, + agent=Executable("cf-agent", host, agent_path, aliases=aliases), + hub=Executable("cf-hub", host, hub_path, aliases=aliases), + ) + ) + + return installations + + +def _prompt_choice(candidates, description): + if not sys.stdin.isatty(): + labels = ", ".join(c.label for c in candidates) + raise UserError( + f"Multiple installations of {description} found ({labels}) " + f"and no terminal to prompt on. Specify one with --host." + ) + print(f"Multiple installations of {description} found:") + for i, c in enumerate(candidates, 1): + print(f" {i}) {c.label}") + while True: + choice = input(f"Select which to use [1-{len(candidates)}]: ").strip() + if choice.isdigit() and 1 <= int(choice) <= len(candidates): + return candidates[int(choice) - 1] + print("Invalid selection, try again.") + + +def _exact_match(candidate, target: str) -> bool: + return target == candidate.location or target in candidate.aliases + + +def _loose_match(candidate, target: str) -> bool: + """Substring match, for when nothing matched exactly.""" + if target in candidate.location: + return True + return any(target in alias for alias in candidate.aliases) + + +def _select(candidates, description, target: str | None = None): + """ + Picks one candidate (an Executable or Installation) out of a list: + - none found -> UserError + - `target` given -> match against location OR any alias (exact, or + substring -- an IP, username, or friendly name like "hub"), + UserError on no match + - exactly one -> use it, no prompt + - multiple, no target -> interactive prompt + """ + if not candidates: + raise UserError( + f"Could not find {description} locally or on any configured remote host." + ) + + if target: + matches = [c for c in candidates if _exact_match(c, target)] + if not matches: + matches = [c for c in candidates if _loose_match(c, target)] + if not matches: + available = ", ".join(c.label for c in candidates) + raise UserError( + f"No installation of {description} matches '{target}'. Available: {available}" + ) + if len(matches) == 1: + return matches[0] + return _prompt_choice(matches, description) + + if len(candidates) == 1: + return candidates[0] + + return _prompt_choice(candidates, description) + + +def require_executable(name: str, target: str | None = None) -> Executable: + chosen = _select(_find_all(name), name, target) + logging.warning( + f"Using {'local' if chosen.is_local else 'remote'} installation of {name} ({chosen.label})" + ) + return chosen + + +def require_installation(target: str | None = None) -> Installation: + chosen = _select(_find_all_paired(), "cf-agent + cf-hub", target) + logging.warning( + f"Using {'local' if chosen.is_local else 'remote'} installation of cf-agent and cf-hub ({chosen.label})" + ) + return chosen diff --git a/src/cfengine_cli/commands.py b/src/cfengine_cli/commands.py index 5d41724..e10e423 100644 --- a/src/cfengine_cli/commands.py +++ b/src/cfengine_cli/commands.py @@ -5,28 +5,14 @@ from cfengine_cli.profile import profile_cfengine, generate_callstack from cfengine_cli.dev import dispatch_dev_subcommand from cfengine_cli.lint import lint_args -from cfengine_cli.shell import user_command -from cfengine_cli.paths import bin from cfengine_cli.version import cfengine_cli_version_string from cfengine_cli.format import format_paths from cfengine_cli.utils import UserError from cfengine_cli.up import validate_config, up_do, resolve_templates -from cfbs.commands import build_command -from cf_remote.commands import deploy as deploy_command from cf_remote.paths import cf_remote_dir from pydantic import ValidationError -def _require_cfagent(): - if not os.path.exists(bin("cf-agent")): - raise UserError(f"cf-agent not found at {bin('cf-agent')}") - - -def _require_cfhub(): - if not os.path.exists(bin("cf-hub")): - raise UserError(f"cf-hub not found at {bin('cf-hub')}") - - def help() -> int: print("Example usage:") print("cfengine run") @@ -38,16 +24,6 @@ def version() -> int: return 0 -def build() -> int: - r = build_command() - return r - - -def deploy() -> int: - r = deploy_command(None, None) - return r - - def format(names, line_length, check) -> int: return format_paths(names, line_length, check) @@ -69,21 +45,6 @@ def lint(files, strict, syntax_path) -> int: return errors -def report() -> int: - _require_cfhub() - _require_cfagent() - user_command(f"{bin('cf-agent')} -KIf update.cf && {bin('cf-agent')} -KI") - user_command(f"{bin('cf-hub')} --query rebase -H 127.0.0.1") - user_command(f"{bin('cf-hub')} --query delta -H 127.0.0.1") - return 0 - - -def run() -> int: - _require_cfagent() - user_command(f"{bin('cf-agent')} -KIf update.cf && {bin('cf-agent')} -KI") - return 0 - - def dev(subcommand, args) -> int: return dispatch_dev_subcommand(subcommand, args) diff --git a/src/cfengine_cli/main.py b/src/cfengine_cli/main.py index 9cffc12..d0bccf7 100644 --- a/src/cfengine_cli/main.py +++ b/src/cfengine_cli/main.py @@ -6,6 +6,7 @@ import subprocess from cf_remote import log +from cfengine_cli.cfengine_wrapper import cfengine_commands from cfengine_cli.version import cfengine_cli_version_string from cfengine_cli import commands from cfengine_cli.utils import UserError @@ -65,13 +66,32 @@ def _get_arg_parser(): help="Lint based on a user given syntax description", ) lnt.add_argument("files", nargs="*", help="Files to lint") - subp.add_parser( + report_parser = subp.add_parser( "report", help="Run the agent and hub commands necessary to get new reporting data", ) - subp.add_parser( + report_parser.add_argument( + "--host", + type=str, + default=None, + help="Select which installation to use by name/IP (e.g. 'local' or '192.168.56.90'). " + "If omitted and multiple installations of cf-agent+cf-hub are found, you'll be prompted.", + ) + run_parser = subp.add_parser( "run", help="Run the CFEngine agent, fetching, evaluating, and enforcing policy" ) + run_parser.add_argument( + "run_args", + nargs="*", + help="Command(s) to run with cf-agent", + ) + run_parser.add_argument( + "--host", + type=str, + default=None, + help="Select which installation of cf-agent to use by name/IP (e.g. 'local' or '192.168.56.90'). " + "If omitted and multiple installations are found, you'll be prompted.", + ) profile_parser = subp.add_parser( "profile", help="Parse CFEngine profiling output (cf-agent -Kp)" @@ -187,9 +207,9 @@ def run_command_with_args(args) -> int: return commands.version() # The real commands: if args.command == "build": - return commands.build() + return cfengine_commands.build() if args.command == "deploy": - return commands.deploy() + return cfengine_commands.deploy() if args.command == "format": return commands.format(args.files, args.line_length, args.check) if args.command == "lint": @@ -199,9 +219,9 @@ def run_command_with_args(args) -> int: args.syntax_description, ) if args.command == "report": - return commands.report() + return cfengine_commands.report(target=args.host) if args.command == "run": - return commands.run() + return cfengine_commands.run(*args.run_args, target=args.host) if args.command == "dev": return commands.dev(args.dev_command, args) if args.command == "profile":