From 99f5bff2d8bae968b98cc606e9d916a096d44636 Mon Sep 17 00:00:00 2001 From: Cassowary Date: Tue, 12 Aug 2025 11:20:21 -0700 Subject: [PATCH] Add the start of output logging! bumped to version 0.5.0. - Fix some typos. - Make fabtools HostSet save the command output to itself. - Make the multiball runner save the most recent output. - (command: /save) Add command to save most recent output to file. - Add a debug command for development purposes. --- multiball/__init__.py | 2 +- multiball/__main__.py | 43 ++++++++++++++++++++++++++++++++++++++----- multiball/fabtools.py | 19 +++++++++++++------ 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/multiball/__init__.py b/multiball/__init__.py index 6c8e6b9..3d18726 100644 --- a/multiball/__init__.py +++ b/multiball/__init__.py @@ -1 +1 @@ -__version__ = "0.0.0" +__version__ = "0.5.0" diff --git a/multiball/__main__.py b/multiball/__main__.py index c2d2c83..2198318 100644 --- a/multiball/__main__.py +++ b/multiball/__main__.py @@ -25,6 +25,7 @@ from prompt_toolkit.completion import WordCompleter from prompt_toolkit.history import InMemoryHistory, FileHistory from prompt_toolkit.shortcuts import PromptSession +from . import __version__ from .fabtools import HostSet from .tools import load_sshconfig_server_list, load_bare_server_list, load_config @@ -64,17 +65,19 @@ COMMANDS = { 'clearenv': (('/clearenv',), "Clear environment (entire or single variable)"), 'safety': (('/safety', 'safety'), "Turn on safety or turn off with `off`."), 'safe': (('/safe', ), "Run this command as if it were safe even if safety is on."), - 'confirm': (('/confirm',), "[scriptpfing] Prompt for confirmation"), + 'confirm': (('/confirm',), "[scripting] Prompt for confirmation"), 'echo': (('/echo', ), "[scripting] print string"), 'arguments': (('/arguments',), "[scripting] Abort if no arguments are specified."), # fixme add interpolation - 'unsafe': (('/unsafe', ), "[scriptiong] Abort command if safety is on."), + 'unsafe': (('/unsafe', ), "[scripting] Abort command if safety is on."), + 'save': (('/save', ),"Save last run log to file."), + 'debug': (('/////debug', ), "[devel] enter PDB"), # 'history': (('/history', '/h'), "Show the last 10 commands in the history."), # 'filteralias': (('/filteralias',), "Create a filter alias which allows quick filtering."), # 'undo': (('/undo',), "Undo last host or environment command."), # 'alias': (('/alias', ), "Create an alias interactively.") ## Fixme unambiguate the above commands which list things into this command so things are more consistent - # 'list': (('/list',), "List various things (environmet, allhosts, hosts, targets)") + # 'list': (('/list',), "List various things (environment, allhosts, hosts, targets)") # '!': (('!', '/local'), "Run a local command and capture its output.") } @@ -90,6 +93,7 @@ class Multiball: 'safety': self.command_safety, 'safe': self.command_safe, 'clearenv': self.command_clearenv, + 'save': self.command_save, } configdict = load_config(Path("multiball.cfg")) self.allhosts: set = set() @@ -97,6 +101,7 @@ class Multiball: self.environment = {} self.safety = True self.ssh_config = None + self.last_run = {} if config := configdict.get('config', {}): if 'history' in config: @@ -236,6 +241,27 @@ class Multiball: print("cleared environment") self.environment = dict() + def command_save(self, command, args): + # FIXME: We need to make aliases store multiple entries, and save should take the whole alias not just + # the last command. + if (not self.last_run): + print("Nothing to log.") + return + tstamp = datetime.datetime.now().strftime("%Y-%m-%d-%H%M%Z") + outfilename = f"{tstamp}.multiball.log" + if (args): + outfilename = args[0] + if (not outfilename.endswith(".log")): + outfilename = outfilename + ".log" + + print(f"Writing {outfilename} for command `{self.last_run['cmd']}`.") + with (open(outfilename, 'w')) as out: + out.writelines([f"command: {self.last_run['cmd']}\n", + "-------------------------------------------\n" + ""]) + out.write(self.last_run['results'].getvalue()) + out.write("\n") + def _print_targetlist(self, tlist=None, keyword='Targeting'): if not tlist: tlist = self.targethosts @@ -267,8 +293,11 @@ class Multiball: if abst == 'exit': raise MBExit - if abst == 'help': + elif abst == 'help': self._print_help() + elif abst == 'debug': + print("==== DEBUGGING ====") + __import__("pdb").set_trace() elif abst in self.handlers: self.handlers[abst](command[0], cargs) elif abst == 'clear': @@ -342,6 +371,10 @@ class Multiball: remotecmd.append(command) h = HostSet(self.targethosts, self.ssh_config) h.run(';'.join(remotecmd)) + self.last_run = { + 'cmd': remotecmd, + 'results': h.results + } def do_cmd(self, command): try: @@ -376,7 +409,7 @@ class Multiball: def multiball(*args, **kwargs): - print("Welcome to Multiball. type /help for help.") + print(f"Welcome to Multiball {__version__}. type /help for help.") mb = Multiball() # loop on prompt while (True): diff --git a/multiball/fabtools.py b/multiball/fabtools.py index 7dee436..f8ee1d8 100644 --- a/multiball/fabtools.py +++ b/multiball/fabtools.py @@ -1,3 +1,4 @@ +import io import socket import time from enum import Enum @@ -37,7 +38,13 @@ def thread_run(connection, command, result_lock, result_queue): # A set of hosts we can target with a series of commands and also can collect output of each command class HostSet: + def _lprint(self, *args): + s = " ".join([str(a) for a in args]) + '\n' + self.results.write(s) + print(*args) + def __init__(self, hostlist: list, ssh_config_path: Path=None): + self.results = io.StringIO() if ssh_config_path is None: ssh_config_path = Path("~/.ssh/config") ssh_config_path = ssh_config_path.expanduser() @@ -99,13 +106,13 @@ class HostSet: # display results for result, connections in gathered.items(): - print('-----> [{}]'.format(' '.join(connection.original_host for connection in connections))) - print(result) + self._lprint('-----> [{}]'.format(' '.join(connection.original_host for connection in connections))) + self._lprint(result) - print('#####> RUN SUMMARY <#####') + self._lprint('#####> RUN SUMMARY <#####') if (success): - print(' Succeeded: ', ', '.join(connection.original_host for connection in success)) + self._lprint(' Succeeded: ', ', '.join(connection.original_host for connection in success)) if (commandfail): - print(' Command fail: ', ', '.join(connection.original_host for connection in commandfail)) + self._lprint(' Command fail: ', ', '.join(connection.original_host for connection in commandfail)) if (error): - print('Failed with Error: ', ','.join(connection.original_host for connection in error)) + self._lprint('Failed with Error: ', ','.join(connection.original_host for connection in error))