diff --git a/prepare_remote_script.py b/prepare_remote_script.py new file mode 100755 index 0000000..600002a --- /dev/null +++ b/prepare_remote_script.py @@ -0,0 +1,196 @@ +#!/bin/python3 + +import pandoc +import pandoc.types as t +import sys +from dataclasses import dataclass +from typing import * + +# There are three parts: +# - Parse a given markdown and extract the commands +# - Also: keep track of the groups / ... +# - Let the user decide, what to run / edit / split / skip +# - Save the commands for later + +# Usage: $0 target_filename md1 ... + +target_filename: str +directory: str = None +default_user = 'root' + +def main(): + if len(sys.argv) < 3: + print(f"Usage: {sys.argv[0]} filename file1.md ...") + exit(1) + # Here I would use Haskell's pointfree functions, so that file is implicitly passed... + # Never mind, it will be a global variable NOTE THIS!!! + global target_filename + target_filename = sys.argv[1] + for file in sys.argv[2:]: + process_file(file) + +@dataclass +class Command: + user: str + directory: Union[str, None] + command: str + context: str + +def stringify(block) -> str: + """ + Take an object of some of pandoc.types and make the best of it. + """ + # The pandoc library has a fancy function for that... + return pandoc.write(block) + +def extract_snippets(pd: t.Pandoc) -> Sequence[Tuple[str, str]]: + """ + This extracts snippets from the code. The problem is, that we want context with it, so we return a list of tuples (previous_block_stringified, code_snippet). + """ + result = [] + last_block = "" + for block in pd[1]: + if isinstance(block, t.CodeBlock): + result.append((last_block, block[1])) + last_block = stringify(block) + return result + +def parse_snippet(s: Tuple[str, str]) -> Sequence[Command]: + # Will return always a list, even if it has only one element. + # This is where we handle blank lines, comments, cd's and sudo's, so that the Commands in the output are "runnable" + + # The original context should be mentioned for all hunks + context = s[0] + + snippet = s[1] + + # Fix line-breaks + # FIXME: nonsystematic and ad hoc + snippet = snippet.replace('\\\n', '') + + # We now check the separate lines and possibly tweak them (e.g. by removing sudo-s) + lines = snippet.split('\n') + global directory + result = [] + for line in lines: + user = None # This allows (in conjunction with the right order of arguments to 'ssh') to specify the default user in the hostname (as 'user@host.name') + line = line.strip() + if line.startswith('cd'): + parts = line.split() + assert len(parts) == 2 + directory = parts[1] + continue + elif line.startswith('sudo -u'): + parts = line.split(maxsplit=4) + assert parts[3] == '-H', "Insecure sudo?" + user = parts[2] + cmd = parts[4] + elif line.startswith('sudo'): + parts = line.split(maxsplit=1) + assert not parts[1].startswith('-'), "Weird sudo" + user = 'root' + cmd = parts[1] + elif line == '': + # Only add it to context for reference + context = f'{context}\n' + continue + else: + # No sudo or cd, just a command. + cmd = line + result.append(Command(user=user,directory=directory,command=cmd,context=context)) + # Let's have the previously processed commands in the context + context = f"{context}\nCMD: {user}:{directory if directory else '??'} $ {cmd}" + return result # I miss the right feature of Nim lang... + + + +def user_action(hunk: Sequence[Command], context=None) -> Sequence[Command]: + # Maybe modify the commands to run. + # This should have a simple UI: show the hunk with context (ideally in grey), ask what to do, return the wanted command + # Actions: yes(y): accept input; no(n): skip this command; edit(e): launch $EDITOR or vim on the hunk; quit(q): terminate; split(s): split hunks to individual lines and ask for each one. + + # FIXME: We don't implement splitting yet, because it's simple to comment / delete in the editor + # FIXME: We only show given context and don't care for the command's one; that's wrong. + + def stringify_hunk(hunk): + result = '' + for cmd in hunk: + global host + result += f'SSH: {cmd.user}@{host} {cmd.directory} $ {cmd.command}\n' + return result + + + # TODO: Print context in color (gray?) + print('-------------------------------------------') + print(context) + print() + print(stringify_hunk(hunk)) + + # TODO: Print prompt in color (green?) + answer = input("What to do: edit(e), run(y, default), skip(n)?") + if answer is None or answer == '': + # Default is run, for convenience + answer = 'Y' + if answer.startswith(('e', 'E')): + import tempfile + with tempfile.NamedTemporaryFile(mode='r+') as tmpfile: + # Fill the file with data + for cmd in hunk: + tmpfile.file.write(f"{cmd.user if cmd.user else ''}\t{cmd.directory if cmd.directory else ''}\t{cmd.command}\n") + tmpfile.file.flush() # Should not be needed, but better safe than sorry + # Let the user edit the file + import os + editor = os.getenv('EDITOR') + if editor is None: + # Fallback to vim + editor = 'vim' + import subprocess + subprocess.run([editor, tmpfile.name]) + # Read the file back + tmpfile.file.seek(0) + new_hunk = [] + for line in tmpfile.file.readlines(): + if line == '': + continue + line = line.rstrip('\n') + parts = line.split('\t', maxsplit=2) + # Change user to None if it is not specified + parts[0] = parts[0].strip() if parts[0].strip() != '' else None + # Change directory to None if it is not specified + parts[1] = parts[1].strip() if parts[1].strip() != '' else None + new_hunk.append(Command(user=parts[0],directory=parts[1],command=parts[2],context=None)) + # Python has no goto for restarting, but we can recurse + return user_action(new_hunk, context) + elif answer.startswith(('y', 'Y')): + return hunk + elif answer.startswith(('n', 'N')): + return [] + else: + # Bad choice or error. Notify user and try again + print("Bad choice, or maybe bug.") + # No goto, use recursion + return user_action(hunk, context) + + + +def run_command(cmd: Command): + # Save the SSH command. + global target_filename + with open(target_filename, "w+") as target: + target.write(f"{cmd.user if cmd.user else ''}\t{cmd.directory if cmd.directory else ''}\t{cmd.command}\n") + + +def process_file(fn: str): + """ + To be called upon each file on the command line. This function should perform all the steps needed in order to remotely run a parsed markdown, id est the three parts above. + """ + pd = pandoc.read(file=fn) + snippets = extract_snippets(pd) + for snip in snippets: + cmds = parse_snippet(snip) + cmds = user_action(cmds, context=cmds[0].context) + for cmd in cmds: + run_command(cmd) + +if __name__ == '__main__': + main() diff --git a/run_script.py b/run_script.py new file mode 100755 index 0000000..2e42309 --- /dev/null +++ b/run_script.py @@ -0,0 +1,222 @@ +#!/bin/python3 + +import pandoc +import pandoc.types as t +import sys +from dataclasses import dataclass +from typing import * + +# There are three parts: +# - Parse a given markdown and extract the commands +# - Also: keep track of the groups / ... +# - Let the user decide, what to run / edit / split / skip +# - Run the commands remotely + +# Usage: $0 remote.host.name script1 ... + +host: str +directory: str = None +default_user = 'root' + +def main(): + if len(sys.argv) < 3: + print(f"Usage: {sys.argv[0]} hostname file1.md ...") + exit(1) + # Here I would use Haskell's pointfree functions, so that host is implicitly passed... + # Never mind, it will be a global variable NOTE THIS!!! + global host + host = sys.argv[1] + for file in sys.argv[2:]: + process_file(file) + +@dataclass +class Command: + user: str + directory: Union[str, None] + command: str + context: str + +def stringify(block) -> str: + """ + Take an object of some of pandoc.types and make the best of it. + """ + # The pandoc library has a fancy function for that... + return pandoc.write(block) + +def extract_snippets(pd: t.Pandoc) -> Sequence[Tuple[str, str]]: + """ + This extracts snippets from the code. The problem is, that we want context with it, so we return a list of tuples (previous_block_stringified, code_snippet). + """ + result = [] + last_block = "" + for block in pd[1]: + if isinstance(block, t.CodeBlock): + result.append((last_block, block[1])) + last_block = stringify(block) + return result + +def parse_snippet(s: Tuple[str, str]) -> Sequence[Command]: + # Will return always a list, even if it has only one element. + # This is where we handle blank lines, comments, cd's and sudo's, so that the Commands in the output are "runnable" + + # The original context should be mentioned for all hunks + context = s[0] + + snippet = s[1] + + # Fix line-breaks + # FIXME: nonsystematic and ad hoc + snippet = snippet.replace('\\\n', '') + + # We now check the separate lines and possibly tweak them (e.g. by removing sudo-s) + lines = snippet.split('\n') + global directory + result = [] + for line in lines: + user = None # This allows (in conjunction with the right order of arguments to 'ssh') to specify the default user in the hostname (as 'user@host.name') + line = line.strip() + if line.startswith('cd'): + parts = line.split() + assert len(parts) == 2 + directory = parts[1] + continue + elif line.startswith('sudo -u'): + parts = line.split(maxsplit=4) + assert parts[3] == '-H', "Insecure sudo?" + user = parts[2] + cmd = parts[4] + elif line.startswith('sudo'): + parts = line.split(maxsplit=1) + assert not parts[1].startswith('-'), "Weird sudo" + user = 'root' + cmd = parts[1] + elif line == '': + # Only add it to context for reference + context = f'{context}\n' + continue + else: + # No sudo or cd, just a command. + cmd = line + result.append(Command(user=user,directory=directory,command=cmd,context=context)) + # Let's have the previously processed commands in the context + context = f"{context}\nCMD: {user}:{directory if directory else '??'} $ {cmd}" + return result # I miss the right feature of Nim lang... + + + +def user_action(hunk: Sequence[Command], context=None) -> Sequence[Command]: + # Maybe modify the commands to run. + # This should have a simple UI: show the hunk with context (ideally in grey), ask what to do, return the wanted command + # Actions: yes(y): accept input; no(n): skip this command; edit(e): launch $EDITOR or vim on the hunk; quit(q): terminate; split(s): split hunks to individual lines and ask for each one. + + # FIXME: We don't implement splitting yet, because it's simple to comment / delete in the editor + # FIXME: We only show given context and don't care for the command's one; that's wrong. + + def stringify_hunk(hunk): + result = '' + for cmd in hunk: + global host + result += f'SSH: {cmd.user}@{host} {cmd.directory} $ {cmd.command}\n' + return result + + + # TODO: Print context in color (gray?) + print('-------------------------------------------') + print(context) + print() + print(stringify_hunk(hunk)) + + # TODO: Print prompt in color (green?) + answer = input("What to do: edit(e), run(y, default), skip(n)?") + if answer is None or answer == '': + # Default is run, for convenience + answer = 'Y' + if answer.startswith(('e', 'E')): + import tempfile + with tempfile.NamedTemporaryFile(mode='r+') as tmpfile: + # Fill the file with data + for cmd in hunk: + tmpfile.file.write(f"{cmd.user if cmd.user else ''}\t{cmd.directory if cmd.directory else ''}\t{cmd.command}\n") + tmpfile.file.flush() # Should not be needed, but better safe than sorry + # Let the user edit the file + import os + editor = os.getenv('EDITOR') + if editor is None: + # Fallback to vim + editor = 'vim' + import subprocess + subprocess.run([editor, tmpfile.name]) + # Read the file back + tmpfile.file.seek(0) + new_hunk = [] + for line in tmpfile.file.readlines(): + if line == '': + continue + line = line.rstrip('\n') + parts = line.split('\t', maxsplit=2) + # Change user to None if it is not specified + parts[0] = parts[0].strip() if parts[0].strip() != '' else None + # Change directory to None if it is not specified + parts[1] = parts[1].strip() if parts[1].strip() != '' else None + new_hunk.append(Command(user=parts[0],directory=parts[1],command=parts[2],context=None)) + # Python has no goto for restarting, but we can recurse + return user_action(new_hunk, context) + elif answer.startswith(('y', 'Y')): + return hunk + elif answer.startswith(('n', 'N')): + return [] + else: + # Bad choice or error. Notify user and try again + print("Bad choice, or maybe bug.") + # No goto, use recursion + return user_action(hunk, context) + + + +def run_command(cmd: Command): + # Generate and run the SSH command. + global host + local_command = ['ssh'] + remote_command = cmd.command + if cmd.user is not None: + local_command.extend(['-l', cmd.user]) + else: + global default_user + local_command.extend(['-l', default_user]) + local_command.append(host) # SSH seems to honor the first specified parameter. This allows to have username as a part of the hostname, yet still be able to honor the sudo-s in the sinppets. + if cmd.directory is not None: + remote_command = f'cd {cmd.directory}; {remote_command}' + local_command.append(remote_command) + + # Run + import subprocess + process = subprocess.run(local_command) + if process.returncode != 0: + # TODO: Print in color (red?) + # FIXME: This kind-of implements 'set -e', but doesn't allow to skip commands in the same hunk + answer = input(f"Command {process.args} failed with return code {process.returncode}. Continue? ") + if not answer.startswith(('Y', 'y', 'a', 'A')): # We are Czech, so "ano" is a valid answer :-) + print("Bailing out") + sys.exit(1) + + +def process_file(fn: str): + """ + To be called upon each file on the command line. This function should perform all the steps needed in order to remotely run a parsed markdown, id est the three parts above. + """ + with open(fn, "r+") as script: + lines = script.readlines() + for line in lines: + if line == '': + continue + line = line.rstrip('\n') + parts = line.split('\t', maxsplit=2) + # Change user to None if it is not specified + parts[0] = parts[0].strip() if parts[0].strip() != '' else None + # Change directory to None if it is not specified + parts[1] = parts[1].strip() if parts[1].strip() != '' else None + cmd = Command(user=parts[0],directory=parts[1],command=parts[2],context=None) + run_command(cmd) + +if __name__ == '__main__': + main()