feat: simplify node selection, enhance gRPC execution logic, and improve CLI aggregate summaries

This commit is contained in:
2026-04-30 14:18:41 -03:00
parent 96049b4028
commit 7967c413c9
71 changed files with 1762 additions and 524 deletions
+2 -7
View File
@@ -153,9 +153,7 @@ tasks:
nodes: #List of nodes to work on. Mandatory
- 'router1@office' #You can add specific nodes
- '@aws' #entire folders or subfolders
- '@office': #or filter inside a folder or subfolder
- 'router2'
- 'router7'
- 'router.*@office' #or use regex to filter inside a folder
commands: #List of commands to send, use {name} to pass variables
- 'term len 0'
@@ -181,7 +179,7 @@ tasks:
vrouterN@aws:
id: 5
output: /home/user/logs #Type of output, if null you only get Connection and test result. Choices are: null,stdout,/path/to/folder. Folder path only works on 'run' action.
output: /home/user/logs #Type of output, if null you only get Connection and test result. Choices are: null,stdout,/path/to/folder. Folder path works on both 'run' and 'test' actions.
options:
prompt: r'>$|#$|\$$|>.$|#.$|\$.$' #Optional prompt to check on your devices, default should work on most devices.
@@ -193,9 +191,6 @@ tasks:
nodes:
- 'router1@office'
- '@aws'
- '@office':
- 'router2'
- 'router7'
commands:
- 'ping 10.100.100.{id}'
expected: '!' #Expected text to find when running test action. Mandatory for 'test'
+78 -31
View File
@@ -1,6 +1,7 @@
import os
import sys
import yaml
import threading
from rich.rule import Rule
from .. import printer
from ..services.exceptions import ConnpyError
@@ -9,6 +10,7 @@ from .help_text import get_instructions
class RunHandler:
def __init__(self, app):
self.app = app
self.print_lock = threading.Lock()
def dispatch(self, args):
if len(args.data) > 1:
@@ -19,22 +21,43 @@ class RunHandler:
def node_run(self, args):
nodes_filter = args.data[0]
commands = [" ".join(args.data[1:])]
try:
header_printed = False
# Inline execution with streaming results
def _on_node_complete(unique, node_output, node_status):
nonlocal header_printed
if not header_printed:
printer.console.print(Rule("OUTPUT", style="header"))
header_printed = True
printer.node_panel(unique, node_output, node_status)
self.app.services.execution.run_commands(
nodes_filter=nodes_filter,
commands=commands,
on_node_complete=_on_node_complete
)
if hasattr(args, 'test_expected') and args.test_expected:
# Mode: Test
def _on_node_complete(unique, node_output, node_status, node_result):
nonlocal header_printed
with self.print_lock:
if not header_printed:
printer.console.print(Rule("OUTPUT", style="header"))
header_printed = True
printer.test_panel(unique, node_output, node_status, node_result)
results = self.app.services.execution.test_commands(
nodes_filter=nodes_filter,
commands=commands,
expected=args.test_expected,
on_node_complete=_on_node_complete
)
printer.test_summary(results)
else:
# Mode: Normal Run
def _on_node_complete(unique, node_output, node_status):
nonlocal header_printed
with self.print_lock:
if not header_printed:
printer.console.print(Rule("OUTPUT", style="header"))
header_printed = True
printer.node_panel(unique, node_output, node_status)
results = self.app.services.execution.run_commands(
nodes_filter=nodes_filter,
commands=commands,
on_node_complete=_on_node_complete
)
printer.run_summary(results)
except ConnpyError as e:
printer.error(str(e))
@@ -55,36 +78,44 @@ class RunHandler:
try:
with open(path, "r") as f:
playbook = yaml.load(f, Loader=yaml.FullLoader)
for task in playbook.get("tasks", []):
self.cli_run(task)
except Exception as e:
printer.error(f"Failed to run playbook {path}: {e}")
sys.exit(10)
def cli_run(self, script):
name = script.get("name", "Task")
try:
action = script["action"]
nodelist = script["nodes"]
commands = script["commands"]
variables = script.get("variables")
output_cfg = script["output"]
name = script.get("name", "Task")
options = script.get("options", {})
except KeyError as e:
printer.error(f"'{e.args[0]}' is mandatory in script")
printer.error(f"[{name}] '{e.args[0]}' is mandatory in script")
sys.exit(11)
stdout = (output_cfg == "stdout")
folder = output_cfg if output_cfg not in [None, "stdout"] else None
prompt = options.get("prompt")
printer.header(name.upper())
try:
header_printed = False
if action == "run":
# If stdout is true, we stream results as they arrive
on_complete = printer.node_panel if stdout else None
def _on_run_complete(unique, node_output, node_status):
nonlocal header_printed
if stdout:
with self.print_lock:
if not header_printed:
printer.console.print(Rule(name.upper(), style="header"))
header_printed = True
printer.node_panel(unique, node_output, node_status)
results = self.app.services.execution.run_commands(
nodes_filter=nodelist,
commands=commands,
@@ -93,16 +124,31 @@ class RunHandler:
timeout=options.get("timeout", 10),
folder=folder,
prompt=prompt,
on_node_complete=on_complete
on_node_complete=_on_run_complete
)
# If not streaming, we could print a summary table here if needed
if not stdout:
for unique, output in results.items():
# Final Summary
if not stdout and not folder:
with self.print_lock:
printer.console.print(Rule(name.upper(), style="header"))
for unique, data in results.items():
output = data["output"] if isinstance(data, dict) else data
printer.node_panel(unique, output, 0)
# ALWAYS show the aggregate execution summary at the end
printer.run_summary(results)
elif action == "test":
expected = script.get("expected", [])
on_complete = printer.test_panel if stdout else None
# Show test_panel per node ONLY if stdout is True
def _on_test_complete(unique, node_output, node_status, node_result):
nonlocal header_printed
if stdout:
with self.print_lock:
if not header_printed:
printer.console.print(Rule(name.upper(), style="header"))
header_printed = True
printer.test_panel(unique, node_output, node_status, node_result)
results = self.app.services.execution.test_commands(
nodes_filter=nodelist,
commands=commands,
@@ -110,11 +156,12 @@ class RunHandler:
variables=variables,
parallel=options.get("parallel", 10),
timeout=options.get("timeout", 10),
folder=folder,
prompt=prompt,
on_node_complete=on_complete
on_node_complete=_on_test_complete
)
if not stdout:
printer.test_summary(results)
# ALWAYS show the aggregate summary at the end
printer.test_summary(results)
except ConnpyError as e:
printer.error(str(e))
+19 -4
View File
@@ -147,12 +147,27 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
"__extra__": lambda w: get_cwd(w, "import")
})
run_dict = {"--generate": None, "--help": None, "-g": None, "-h": None}
run_dict.update({
"*": run_dict,
"__extra__": lambda w: get_cwd(w, "run") + list(nodes)
# --- Run Loop ---
# After the first positional argument (Node filter or YAML file),
# we stop suggesting nodes and only allow flags or commands.
run_after_node = {"--help": None, "-h": None}
run_after_node.update({
"--test": {"*": run_after_node},
"-t": {"*": run_after_node},
"*": run_after_node # Consume commands
})
run_dict = {
"--generate": {"__extra__": lambda w: get_cwd(w, "--generate")},
"-g": {"__extra__": lambda w: get_cwd(w, "-g")},
"--test": {"*": None},
"-t": {"*": None},
"--help": None,
"-h": None,
"__extra__": lambda w: get_cwd(w, "run") + list(nodes),
"*": run_after_node
}
# State Machine Definitions
ai_dict = {"__exclude_used__": True, "--help": None, "-h": None}
for opt in ["--engineer-model", "--engineer-api-key", "--architect-model", "--architect-api-key"]:
+16 -17
View File
@@ -400,15 +400,7 @@ class configfile:
if isinstance(uniques, str):
uniques = [uniques]
for i in uniques:
if isinstance(i, dict):
name = list(i.keys())[0]
mylist = i[name]
if not self.config["case"]:
name = name.lower()
mylist = [item.lower() for item in mylist]
this = self.getitem(name, mylist, extract = extract)
nodes.update(this)
elif i.startswith("@"):
if i.startswith("@"):
if not self.config["case"]:
i = i.lower()
this = self.getitem(i, extract = extract)
@@ -487,13 +479,17 @@ class configfile:
layer3 = [k + "@" + s + "@" + f for k,v in self.connections[f][s].items() if isinstance(v, dict) and v.get("type") == "connection"]
nodes.extend(layer3)
if filter:
flat_filter = []
if isinstance(filter, str):
nodes = [item for item in nodes if re.search(filter, item)]
flat_filter = [filter]
elif isinstance(filter, list):
nodes = [item for item in nodes if any(re.search(pattern, item) for pattern in filter)]
for item in filter:
if isinstance(item, str):
flat_filter.append(item)
else:
printer.error("Invalid filter: must be a string or a list of strings.")
printer.error("Filter must be a string or a list of strings")
sys.exit(1)
nodes = [item for item in nodes if any(re.search(pattern, item) for pattern in flat_filter)]
return nodes
@MethodHook
@@ -511,15 +507,18 @@ class configfile:
layer3 = {k + "@" + s + "@" + f:v for k,v in self.connections[f][s].items() if isinstance(v, dict) and v.get("type") == "connection"}
nodes.update(layer3)
if filter:
flat_filter = []
if isinstance(filter, str):
filter = "^(?!.*@).+$" if filter == "@" else filter
nodes = {k: v for k, v in nodes.items() if re.search(filter, k)}
flat_filter = [filter]
elif isinstance(filter, list):
filter = ["^(?!.*@).+$" if item == "@" else item for item in filter]
nodes = {k: v for k, v in nodes.items() if any(re.search(pattern, k) for pattern in filter)}
for item in filter:
if isinstance(item, str):
flat_filter.append(item)
else:
printer.error("Invalid filter: must be a string or a list of strings.")
printer.error("Filter must be a string or a list of strings")
sys.exit(1)
flat_filter = ["^(?!.*@).+$" if item == "@" else item for item in flat_filter]
nodes = {k: v for k, v in nodes.items() if any(re.search(pattern, k) for pattern in flat_filter)}
if extract:
for node, keys in nodes.items():
for key, value in keys.items():
+1
View File
@@ -289,6 +289,7 @@ class connapp:
runparser = subparsers.add_parser("run", help="Run scripts or commands on nodes", description="Run scripts or commands on nodes", formatter_class=RichHelpFormatter)
runparser.error = self._custom_error
runparser.add_argument("run", nargs='+', action=self._store_type, help=get_help("run"), default="run").completer = nodes_completer
runparser.add_argument("-t", "--test", dest="test_expected", nargs='+', help="Expected text(s) to validate in output. Converts the action from 'run' to 'test'")
runparser.add_argument("-g","--generate", dest="action", action="store_const", help="Generate yaml file template", const="generate", default="run")
runparser.set_defaults(func=self._run.dispatch)
#APIPARSER
+50 -8
View File
@@ -148,6 +148,10 @@ class node:
self.jumphost = f"-o ProxyCommand=\"{jumphost_cmd}\""
else:
self.jumphost = ""
self.output = ""
self.status = 1
self.result = {}
@MethodHook
def _passtx(self, passwords, *, keyfile=None):
@@ -548,7 +552,12 @@ class node:
self.child.logfile_read = self.mylog
for c in commands:
if vars is not None:
c = c.format(**vars)
try:
c = c.format(**vars)
except KeyError as e:
self.output = f"Error: Variable {e} not defined in task or inventory"
self.status = 1
return self.output
result = self.child.expect(expects, timeout = timeout)
self.child.sendline(c)
if result == 2:
@@ -582,7 +591,7 @@ class node:
return connect
@MethodHook
def test(self, commands, expected, vars = None,*, prompt = r'>$|#$|\$$|>.$|#.$|\$.$', timeout = 10, logger = None):
def test(self, commands, expected, vars = None,*, folder = '', prompt = r'>$|#$|\$$|>.$|#.$|\$.$', timeout = 10, logger = None):
'''
Run a command or list of commands on the node, then check if expected value appears on the output after the last command.
@@ -608,6 +617,9 @@ class node:
### Optional Named Parameters:
- folder (str): Path where output log should be stored, leave
empty to not store logs.
- prompt (str): Prompt to be expected after a command is finished
running. Usually linux uses ">" or EOF while
routers use ">" or "#". The default value should
@@ -622,6 +634,7 @@ class node:
false if prompt is found before.
'''
now = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
connect = self._connect(timeout = timeout, logger = logger)
if connect == True:
if logger:
@@ -639,6 +652,7 @@ class node:
if "prompt" in self.tags:
prompt = self.tags["prompt"]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
output = ''
if not isinstance(commands, list):
commands = [commands]
@@ -650,7 +664,12 @@ class node:
self.child.logfile_read = self.mylog
for c in commands:
if vars is not None:
c = c.format(**vars)
try:
c = c.format(**vars)
except KeyError as e:
self.output = f"Error: Variable {e} not defined in task or inventory"
self.status = 1
return self.output
result = self.child.expect(expects, timeout = timeout)
self.child.sendline(c)
if result == 2:
@@ -659,6 +678,12 @@ class node:
result = self.child.expect(expects, timeout = timeout)
self.child.close()
output = self._logclean(self.mylog.getvalue().decode(), True)
if logger:
logger("output", output)
if folder != '':
with open(folder + "/" + self.unique + "_" + now + ".txt", "w") as f:
f.write(output)
f.close()
self.output = output
if result in [0, 1]:
# lastcommand = commands[-1]
@@ -1020,8 +1045,15 @@ class nodes:
nodesargs[n.unique]["vars"] = {}
if "__global__" in vars.keys():
nodesargs[n.unique]["vars"].update(vars["__global__"])
if n.unique in vars.keys():
nodesargs[n.unique]["vars"].update(vars[n.unique])
for var_key, var_val in vars.items():
if var_key == "__global__":
continue
try:
if re.search(var_key, n.unique, re.IGNORECASE):
nodesargs[n.unique]["vars"].update(var_val)
except re.error:
if var_key == n.unique:
nodesargs[n.unique]["vars"].update(var_val)
# Pass the logger to the node
nodesargs[n.unique]["logger"] = logger
@@ -1046,7 +1078,7 @@ class nodes:
return output
@MethodHook
def test(self, commands, expected, vars = None,*, prompt = None, parallel = 10, timeout = None, on_complete = None, logger = None):
def test(self, commands, expected, vars = None,*, folder = None, prompt = None, parallel = 10, timeout = None, on_complete = None, logger = None):
'''
Run a command or list of commands on all the nodes in nodelist, then check if expected value appears on the output after the last command.
@@ -1101,6 +1133,9 @@ class nodes:
nodesargs = {}
args["commands"] = commands
args["expected"] = expected
if folder != None:
args["folder"] = folder
Path(folder).mkdir(parents=True, exist_ok=True)
if prompt != None:
args["prompt"] = prompt
if timeout != None:
@@ -1122,8 +1157,15 @@ class nodes:
nodesargs[n.unique]["vars"] = {}
if "__global__" in vars.keys():
nodesargs[n.unique]["vars"].update(vars["__global__"])
if n.unique in vars.keys():
nodesargs[n.unique]["vars"].update(vars[n.unique])
for var_key, var_val in vars.items():
if var_key == "__global__":
continue
try:
if re.search(var_key, n.unique, re.IGNORECASE):
nodesargs[n.unique]["vars"].update(var_val)
except re.error:
if var_key == n.unique:
nodesargs[n.unique]["vars"].update(var_val)
nodesargs[n.unique]["logger"] = logger
if on_complete:
File diff suppressed because one or more lines are too long
+1 -2
View File
@@ -2,8 +2,7 @@
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
import connpy_pb2 as connpy__pb2
from . import connpy_pb2 as connpy__pb2
from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2
GRPC_GENERATED_VERSION = '1.80.0'
+17 -5
View File
@@ -388,14 +388,20 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
def _worker():
try:
# Set task name in thread state for printer if available
if request.name:
printer.console.print(f"[debug][DEBUG][/debug] Executing task: [bold cyan]{request.name}[/bold cyan]")
self.service.run_commands(
nodes_filter=nodes_filter,
commands=list(request.commands),
folder=request.folder if request.folder else None,
prompt=request.prompt if request.prompt else None,
parallel=request.parallel,
timeout=request.timeout if request.timeout > 0 else 10,
variables=from_struct(request.vars) if request.HasField("vars") else None,
on_node_complete=_on_complete
on_node_complete=_on_complete,
name=request.name if request.name else None
)
except Exception as e:
# Optionally pass error to stream, but handle_errors decorator covers top-level.
@@ -428,20 +434,26 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
q = queue.Queue()
def _on_complete(unique, output, status, result):
q.put({"unique_id": unique, "output": output, "status": status, "result": result})
def _on_complete(unique, node_output, node_status, node_result):
q.put({"unique_id": unique, "output": node_output, "status": node_status, "result": node_result})
def _worker():
try:
# Set task name in thread state for printer if available
if request.name:
printer.console.print(f"[debug][DEBUG][/debug] Executing task: [bold cyan]{request.name}[/bold cyan]")
self.service.test_commands(
nodes_filter=nodes_filter,
commands=list(request.commands),
expected=request.expected,
expected=list(request.expected),
folder=request.folder if request.folder else None,
prompt=request.prompt if request.prompt else None,
parallel=request.parallel,
timeout=request.timeout if request.timeout > 0 else 10,
variables=from_struct(request.vars) if request.HasField("vars") else None,
on_node_complete=_on_complete
on_node_complete=_on_complete,
name=request.name if request.name else None
)
except Exception as e:
q.put(e)
+9 -4
View File
@@ -420,9 +420,9 @@ class ExecutionStub:
folder=folder or "",
prompt=prompt or "",
parallel=parallel,
timeout=timeout,
name=kwargs.get("name", "")
)
# Note: 'timeout', 'on_node_complete', and 'logger' are currently not
# sent over gRPC in the current proto definition.
if variables is not None:
req.vars.CopyFrom(to_struct(variables))
@@ -432,7 +432,10 @@ class ExecutionStub:
for response in self.stub.run_commands(req):
if on_complete:
on_complete(response.unique_id, response.output, response.status)
final_results[response.unique_id] = response.output
final_results[response.unique_id] = {
"output": response.output,
"status": response.status
}
return final_results
@@ -442,10 +445,12 @@ class ExecutionStub:
req = connpy_pb2.TestRequest(
nodes=nodes_list,
commands=commands,
expected=expected,
expected=expected if isinstance(expected, list) else [expected],
folder=kwargs.get("folder", ""),
prompt=prompt or "",
parallel=parallel,
timeout=timeout,
name=kwargs.get("name", "")
)
if variables is not None:
req.vars.CopyFrom(to_struct(variables))
+89 -19
View File
@@ -317,7 +317,7 @@ def test_panel(unique, output, status, result):
_get_console().print(Panel(Group(Text(), code_block, test_results), title=title_line, width=cols, border_style=border))
def test_summary(results):
"""Print an aggregate summary of multiple test results."""
"""Print an aggregate summary of multiple test results in a single panel."""
from rich.panel import Panel
from rich.text import Text
from rich.console import Group
@@ -328,26 +328,96 @@ def test_summary(results):
except OSError:
cols = 80
for node, test_result in results.items():
status_code = 0 if test_result and all(test_result.values()) else 1
if status_code == 0:
status_str = "[pass]✓ PASS[/pass]"
border = "pass"
else:
status_str = f"[fail]✗ FAIL[/fail]"
border = "fail"
summary_content = Text()
total_passed = 0
total_failed = 0
total_partial = 0
if not results:
summary_content.append(" No test results found.\n", style="error")
else:
for node, test_result in results.items():
summary_content.append(f"", style="border")
summary_content.append(f"{node.ljust(40)}", style="bold")
title_line = f"[bold]{node}[/bold] — {status_str}"
test_output = Text()
test_output.append("TEST RESULTS:\n", style="header")
max_key_len = max(len(k) for k in test_result.keys()) if test_result else 0
for k, v in (test_result.items() if test_result else []):
mark = "" if v else ""
style = "success" if v else "error"
test_output.append(f" {k.ljust(max_key_len)} {mark}\n", style=style)
if test_result:
passed_count = sum(1 for v in test_result.values() if v)
total_count = len(test_result)
if passed_count == total_count:
total_passed += 1
node_style = "success"
mark = "✓ PASS"
elif passed_count > 0:
total_partial += 1
node_style = "warning"
mark = f"⚠ PARTIAL ({passed_count}/{total_count})"
else:
total_failed += 1
node_style = "error"
mark = "✗ FAIL"
summary_content.append(f" {mark}\n", style=node_style)
for k, v in test_result.items():
res_mark = "" if v else ""
res_style = "success" if v else "error"
summary_content.append(f" {k.ljust(38)} {res_mark}\n", style=res_style)
else:
total_failed += 1
summary_content.append(" ✗ FAIL\n", style="error")
summary_content.append(" No results (execution failed)\n", style="error")
status_parts = []
if total_passed: status_parts.append(f"[pass]{total_passed} PASSED[/pass]")
if total_partial: status_parts.append(f"[warning]{total_partial} PARTIAL[/warning]")
if total_failed: status_parts.append(f"[fail]{total_failed} FAILED[/fail]")
status_str = " | ".join(status_parts) if status_parts else "[error]NO RESULTS[/error]"
title_line = f"AGGREGATE TEST SUMMARY — {status_str}"
_get_console().print(Panel(Group(Text(), summary_content), title=title_line, width=cols, border_style="border"))
def run_summary(results):
"""Print an aggregate summary of multiple execution results in a single panel."""
from rich.panel import Panel
from rich.text import Text
from rich.console import Group
import os
try:
cols, _ = os.get_terminal_size()
except OSError:
cols = 80
summary_content = Text()
total_ok = 0
total_err = 0
if not results:
summary_content.append(" No execution results found.\n", style="error")
else:
for node, data in results.items():
summary_content.append(f"", style="border")
summary_content.append(f"{node.ljust(40)}", style="bold")
_get_console().print(Panel(Group(Text(), test_output), title=title_line, width=cols, border_style=border))
# Check if we have a status dict or just output (for backward compatibility)
status = data.get("status", 0) if isinstance(data, dict) else 0
if status == 0:
total_ok += 1
summary_content.append(f" ✓ DONE\n", style="success")
else:
total_err += 1
summary_content.append(f" ✗ FAIL({status})\n", style="error")
status_parts = []
if total_ok: status_parts.append(f"[success]{total_ok} DONE[/success]")
if total_err: status_parts.append(f"[error]{total_err} FAILED[/error]")
status_str = " | ".join(status_parts) if status_parts else "[error]NO RESULTS[/error]"
title_line = f"AGGREGATE EXECUTION SUMMARY — {status_str}"
_get_console().print(Panel(Group(Text(), summary_content), title=title_line, width=cols, border_style="border"))
def header(text):
"""Print a section header."""
+5 -1
View File
@@ -176,16 +176,20 @@ message RunRequest {
string prompt = 4;
int32 parallel = 5;
google.protobuf.Struct vars = 6;
int32 timeout = 7;
string name = 8;
}
message TestRequest {
repeated string nodes = 1;
repeated string commands = 2;
string expected = 3;
repeated string expected = 3;
string folder = 4;
string prompt = 5;
int32 parallel = 6;
google.protobuf.Struct vars = 7;
int32 timeout = 8;
string name = 9;
}
message ScriptRequest {
+53 -26
View File
@@ -18,7 +18,8 @@ class ExecutionService(BaseService):
folder: Optional[str] = None,
prompt: Optional[str] = None,
on_node_complete: Optional[Callable] = None,
logger: Optional[Callable] = None
logger: Optional[Callable] = None,
name: Optional[str] = None
) -> Dict[str, str]:
"""Execute commands on a set of nodes."""
@@ -42,7 +43,15 @@ class ExecutionService(BaseService):
logger=logger
)
return results
# Combine output and status for the caller
full_results = {}
for unique in results:
full_results[unique] = {
"output": results[unique],
"status": executor.status.get(unique, 1)
}
return full_results
except Exception as e:
raise ConnpyError(f"Execution failed: {e}")
@@ -54,9 +63,11 @@ class ExecutionService(BaseService):
variables: Optional[Dict[str, Any]] = None,
parallel: int = 10,
timeout: int = 10,
folder: Optional[str] = None,
prompt: Optional[str] = None,
on_node_complete: Optional[Callable] = None,
logger: Optional[Callable] = None
logger: Optional[Callable] = None,
name: Optional[str] = None
) -> Dict[str, Dict[str, bool]]:
"""Run commands and verify expected output on a set of nodes."""
@@ -75,6 +86,7 @@ class ExecutionService(BaseService):
vars=variables,
parallel=parallel,
timeout=timeout,
folder=folder,
prompt=prompt,
on_complete=on_node_complete,
logger=logger
@@ -96,37 +108,52 @@ class ExecutionService(BaseService):
return self.run_commands(nodes_filter, commands, parallel=parallel)
def run_yaml_playbook(self, playbook_path: str, parallel: int = 10) -> Dict[str, Any]:
"""Run a structured Connpy YAML automation playbook."""
if not os.path.exists(playbook_path):
raise ConnpyError(f"Playbook file not found: {playbook_path}")
try:
with open(playbook_path, "r") as f:
playbook = yaml.load(f, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f"Failed to load playbook {playbook_path}: {e}")
def run_yaml_playbook(self, playbook_data: str, parallel: int = 10) -> Dict[str, Any]:
"""Run a structured Connpy YAML automation playbook (from path or content)."""
playbook = None
if playbook_data.startswith("---YAML---\n"):
try:
content = playbook_data[len("---YAML---\n"):]
playbook = yaml.load(content, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f"Failed to parse YAML content: {e}")
else:
if not os.path.exists(playbook_data):
raise ConnpyError(f"Playbook file not found: {playbook_data}")
try:
with open(playbook_data, "r") as f:
playbook = yaml.load(f, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f"Failed to load playbook {playbook_data}: {e}")
# Basic validation
if not isinstance(playbook, dict) or "nodes" not in playbook or "commands" not in playbook:
raise ConnpyError("Invalid playbook format: missing 'nodes' or 'commands' keys.")
action = playbook.get("action", "run")
options = playbook.get("options", {})
# Extract all fields similar to RunHandler.cli_run
exec_args = {
"nodes_filter": playbook["nodes"],
"commands": playbook["commands"],
"variables": playbook.get("variables"),
"parallel": options.get("parallel", parallel),
"timeout": playbook.get("timeout", options.get("timeout", 10)),
"prompt": options.get("prompt"),
"name": playbook.get("name", "Task")
}
# Map 'output' field to folder path if it's not stdout/null
output_cfg = playbook.get("output")
if output_cfg not in [None, "stdout"]:
exec_args["folder"] = output_cfg
if action == "run":
return self.run_commands(
nodes_filter=playbook["nodes"],
commands=playbook["commands"],
parallel=parallel,
timeout=playbook.get("timeout", 10)
)
return self.run_commands(**exec_args)
elif action == "test":
return self.test_commands(
nodes_filter=playbook["nodes"],
commands=playbook["commands"],
expected=playbook.get("expected", []),
parallel=parallel,
timeout=playbook.get("timeout", 10)
)
exec_args["expected"] = playbook.get("expected", [])
return self.test_commands(**exec_args)
else:
raise ConnpyError(f"Unsupported playbook action: {action}")