Skip to content

Commit b4fb82a

Browse files
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 <name|ip> 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 <simon.halvorsen@northern.tech>
1 parent eb9d501 commit b4fb82a

6 files changed

Lines changed: 384 additions & 45 deletions

File tree

src/cfengine_cli/cfengine_wrapper/__init__.py

Whitespace-only changes.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from cfbs.commands import build_command
2+
from cf_remote.commands import deploy as deploy_command
3+
from cf_remote.commands import install as install_command
4+
from cf_remote.commands import spawn as spawn_command
5+
from cf_remote.commands import destroy as destroy_command
6+
7+
from cfengine_cli.cfengine_wrapper.cfengine_utils import (
8+
require_executable,
9+
require_installation,
10+
)
11+
12+
13+
def report(target: str | None = None) -> int: # TODO? ENT-14122
14+
installation = require_installation(target)
15+
rc = installation.agent.run("-KIf update.cf", "-KI")
16+
if rc != 0:
17+
return rc
18+
return installation.hub.run(
19+
"--query rebase -H 127.0.0.1", "--query delta -H 127.0.0.1"
20+
)
21+
22+
23+
def run(*args, target: str | None = None) -> int:
24+
agent = require_executable("cf-agent", target)
25+
if args:
26+
return agent.run(*args)
27+
return agent.run("-KIf update.cf", "-KI")
28+
29+
30+
def install() -> int: # TODO ENT-14117
31+
return install_command(None, None)
32+
33+
34+
def spawn() -> int: # TODO ENT-14118
35+
return spawn_command(None, None, None, None)
36+
37+
38+
def destroy() -> int: # TODO ENT-14118
39+
return destroy_command(None)
40+
41+
42+
def build() -> int: # TODO ENT-14119
43+
return build_command()
44+
45+
46+
def deploy() -> int: # TODO ENT-14119
47+
return deploy_command(None, None)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
from dataclasses import dataclass
2+
from cf_remote.remote import run_command
3+
import subprocess
4+
import logging
5+
import os
6+
7+
8+
def _ensure_default_agent_flags(command: str) -> str:
9+
"""
10+
cf-agent needs -K (no-lock), -I (inform), and -f (specify
11+
file) to actually run against a policy file the way people expect.
12+
If `command` has no flags at all -- e.g. someone just ran
13+
`cfengine run mypolicy.cf` -- prepend "-KIf" so a bare filename works.
14+
15+
Deliberately does NOT try to detect and fill in individual missing
16+
letters (e.g. leaving -f out of "-KI somefile.cf")
17+
If any flag is present at all, we assume the invocation was deliberate and
18+
leave it exactly as given.
19+
"""
20+
tokens = command.split()
21+
has_flags = any(t.startswith("-") for t in tokens)
22+
if has_flags:
23+
return command
24+
return f"-KIf {command}".strip()
25+
26+
27+
class Executable:
28+
"""
29+
A single binary (cf-agent or cf-hub) at a known location -- either
30+
"local" or a remote host identifier ("user@ip"). Knows its own path
31+
and how to run a command against itself, whether that means a local
32+
subprocess or an SSH call via cf-remote.
33+
"""
34+
35+
def __init__(self, name: str, location: str, path: str, aliases=None) -> None:
36+
self.name = name # "cf-agent" / "cf-hub"
37+
self.location = location # "local" or "user@ip"
38+
self.path = path # absolute path to the binary at that location
39+
self.aliases = (
40+
aliases or []
41+
) # friendly names from cf-remote's saved state, e.g. "hub", "local", "remote"
42+
43+
@property
44+
def is_local(self) -> bool:
45+
return self.location == "local"
46+
47+
@property
48+
def label(self) -> str:
49+
"""Best human-facing identifier: a friendly alias if we have one, else the location."""
50+
if self.aliases:
51+
return f"{self.aliases[0]} ({self.location})"
52+
return self.location
53+
54+
def run(self, *commands) -> int:
55+
rc = 0
56+
for command in commands:
57+
rc = self._run_one(command)
58+
if rc != 0:
59+
return rc
60+
return rc
61+
62+
def _run_one(self, command: str) -> int:
63+
if self.name == "cf-agent":
64+
command = _ensure_default_agent_flags(command)
65+
66+
if self.is_local:
67+
args = [self.path] + command.split()
68+
# cf-agent picks its workdir based on privilege: as root it uses
69+
# /var/cfengine/inputs (where policy actually lives); as a
70+
# regular user it falls back to ~/.cfagent/inputs instead,
71+
# which won't have update.cf. Elevate to match the remote path,
72+
# which already runs everything via sudo.
73+
if os.geteuid() != 0:
74+
args = ["sudo"] + args
75+
result = subprocess.run(args)
76+
return result.returncode
77+
78+
full_command = f"{self.path} {command}"
79+
logging.warning(f"Executing command {full_command} on {self.location}")
80+
output = run_command(self.location, full_command, sudo=True)
81+
if (
82+
output is None
83+
): # TODO: Test this, I've had some policy failing but returning output instead or error-code (I think)
84+
# Error already logged in run_command
85+
return 1
86+
if output:
87+
print(output)
88+
return 0
89+
90+
def __repr__(self) -> str:
91+
return f"<Executable {self.name} @ {self.location}>"
92+
93+
94+
@dataclass
95+
class Installation:
96+
"""
97+
One coherent system that has BOTH cf-agent and cf-hub, so callers
98+
that need both (e.g. report()) can pick one location and get a
99+
matched pair, rather than resolving each binary independently.
100+
"""
101+
102+
location: str
103+
agent: Executable
104+
hub: Executable
105+
106+
@property
107+
def is_local(self) -> bool:
108+
return self.location == "local"
109+
110+
@property
111+
def aliases(self) -> list:
112+
# agent and hub are always built from the same alias list for a
113+
# given host (see _find_all_paired), so either would do here.
114+
return self.agent.aliases
115+
116+
@property
117+
def label(self) -> str:
118+
if self.aliases:
119+
return f"{self.aliases[0]} ({self.location})"
120+
return self.location
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import os
2+
import shutil
3+
import logging
4+
import sys
5+
6+
from collections.abc import Iterator
7+
from cfengine_cli.paths import bin
8+
from cfengine_cli.utils import UserError
9+
from cfengine_cli.cfengine_wrapper.cfengine_objects import Executable, Installation
10+
from cf_remote.remote import get_info
11+
from cf_remote.paths import CLOUD_STATE_FPATH
12+
from cf_remote.utils import read_json
13+
14+
15+
def _find_local_path(binary_name: str) -> str | None:
16+
candidate = bin(binary_name)
17+
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
18+
return candidate
19+
return shutil.which(binary_name)
20+
21+
22+
def _known_hosts(role_filter=None) -> Iterator[tuple[str, list[str]]]:
23+
"""
24+
Yields (host_id, aliases) from cf-remote's saved/spawned VM state --
25+
host_id is "user@ip" (same source _get_hubs() in cf_remote.commands
26+
reads from); aliases are the friendly names cf-remote knows this host
27+
by: the group name it was saved/spawned under (e.g. "hub", "hub2")
28+
and its per-host key within that group. Optionally filter by
29+
vm["role"] (e.g. "hub" or "client").
30+
"""
31+
if not os.path.exists(CLOUD_STATE_FPATH):
32+
return
33+
vms_info = read_json(CLOUD_STATE_FPATH)
34+
if not vms_info:
35+
return
36+
for group_name, group in vms_info.items():
37+
alias_group = group_name.lstrip("@")
38+
for host_key, vm in group.items():
39+
if host_key == "meta":
40+
continue
41+
if role_filter and vm.get("role") != role_filter:
42+
continue
43+
host_id = "{}@{}".format(vm["user"], vm["public_ips"][0])
44+
aliases = [alias_group]
45+
if host_key != alias_group:
46+
aliases.append(host_key)
47+
yield host_id, aliases
48+
49+
50+
def _find_all(binary_name: str) -> list[Executable]:
51+
"""Every location -- local, plus every matching remote host -- with `binary_name` installed."""
52+
executables = []
53+
54+
local_path = _find_local_path(binary_name)
55+
if local_path:
56+
executables.append(Executable(binary_name, "local", local_path))
57+
58+
role_filter = "hub" if binary_name == "cf-hub" else None
59+
key = "agent" if binary_name == "cf-agent" else "hub"
60+
for host, aliases in _known_hosts(role_filter=role_filter):
61+
try:
62+
data = get_info(host)
63+
except (Exception, SystemExit) as e:
64+
"""Need to catch SystemExit as cf-remote's get_info() will SystemExit if
65+
any ssh-connections does not work, for our case we still want to fetch
66+
the ones that are up in case the user wants to use a different host"""
67+
logging.warning(f"Skipping {host}: {e}")
68+
continue
69+
if not data:
70+
continue
71+
binary_path = data.get(key)
72+
if binary_path:
73+
executables.append(
74+
Executable(binary_name, host, binary_path, aliases=aliases)
75+
)
76+
77+
return executables
78+
79+
80+
def _find_all_paired() -> list[Installation]:
81+
"""Every location -- local or remote -- that has BOTH cf-agent and cf-hub."""
82+
installations = []
83+
84+
local_agent_path = _find_local_path("cf-agent")
85+
local_hub_path = _find_local_path("cf-hub")
86+
if local_agent_path and local_hub_path:
87+
installations.append(
88+
Installation(
89+
location="local",
90+
agent=Executable("cf-agent", "local", local_agent_path),
91+
hub=Executable("cf-hub", "local", local_hub_path),
92+
)
93+
)
94+
95+
for host, aliases in _known_hosts(role_filter="hub"):
96+
try:
97+
data = get_info(host)
98+
except Exception:
99+
continue
100+
if not data:
101+
continue
102+
agent_path = data.get("agent")
103+
hub_path = data.get("hub")
104+
if agent_path and hub_path:
105+
installations.append(
106+
Installation(
107+
location=host,
108+
agent=Executable("cf-agent", host, agent_path, aliases=aliases),
109+
hub=Executable("cf-hub", host, hub_path, aliases=aliases),
110+
)
111+
)
112+
113+
return installations
114+
115+
116+
def _prompt_choice(candidates, description):
117+
if not sys.stdin.isatty():
118+
labels = ", ".join(c.label for c in candidates)
119+
raise UserError(
120+
f"Multiple installations of {description} found ({labels}) "
121+
f"and no terminal to prompt on. Specify one with --host."
122+
)
123+
print(f"Multiple installations of {description} found:")
124+
for i, c in enumerate(candidates, 1):
125+
print(f" {i}) {c.label}")
126+
while True:
127+
choice = input(f"Select which to use [1-{len(candidates)}]: ").strip()
128+
if choice.isdigit() and 1 <= int(choice) <= len(candidates):
129+
return candidates[int(choice) - 1]
130+
print("Invalid selection, try again.")
131+
132+
133+
def _exact_match(candidate, target: str) -> bool:
134+
return target == candidate.location or target in candidate.aliases
135+
136+
137+
def _loose_match(candidate, target: str) -> bool:
138+
"""Substring match, for when nothing matched exactly."""
139+
if target in candidate.location:
140+
return True
141+
return any(target in alias for alias in candidate.aliases)
142+
143+
144+
def _select(candidates, description, target: str | None = None):
145+
"""
146+
Picks one candidate (an Executable or Installation) out of a list:
147+
- none found -> UserError
148+
- `target` given -> match against location OR any alias (exact, or
149+
substring -- an IP, username, or friendly name like "hub"),
150+
UserError on no match
151+
- exactly one -> use it, no prompt
152+
- multiple, no target -> interactive prompt
153+
"""
154+
if not candidates:
155+
raise UserError(
156+
f"Could not find {description} locally or on any configured remote host."
157+
)
158+
159+
if target:
160+
matches = [c for c in candidates if _exact_match(c, target)]
161+
if not matches:
162+
matches = [c for c in candidates if _loose_match(c, target)]
163+
if not matches:
164+
available = ", ".join(c.label for c in candidates)
165+
raise UserError(
166+
f"No installation of {description} matches '{target}'. Available: {available}"
167+
)
168+
if len(matches) == 1:
169+
return matches[0]
170+
return _prompt_choice(matches, description)
171+
172+
if len(candidates) == 1:
173+
return candidates[0]
174+
175+
return _prompt_choice(candidates, description)
176+
177+
178+
def require_executable(name: str, target: str | None = None) -> Executable:
179+
chosen = _select(_find_all(name), name, target)
180+
logging.warning(
181+
f"Using {'local' if chosen.is_local else 'remote'} installation of {name} ({chosen.label})"
182+
)
183+
return chosen
184+
185+
186+
def require_installation(target: str | None = None) -> Installation:
187+
chosen = _select(_find_all_paired(), "cf-agent + cf-hub", target)
188+
logging.warning(
189+
f"Using {'local' if chosen.is_local else 'remote'} installation of cf-agent and cf-hub ({chosen.label})"
190+
)
191+
return chosen

0 commit comments

Comments
 (0)