Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
47 changes: 47 additions & 0 deletions src/cfengine_cli/cfengine_wrapper/cfengine_commands.py
Original file line number Diff line number Diff line change
@@ -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)
120 changes: 120 additions & 0 deletions src/cfengine_cli/cfengine_wrapper/cfengine_objects.py
Original file line number Diff line number Diff line change
@@ -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"<Executable {self.name} @ {self.location}>"


@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
191 changes: 191 additions & 0 deletions src/cfengine_cli/cfengine_wrapper/cfengine_utils.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading