run everything in either local shell (shell=true), or remote shell (ssh). this it to allow external shell piping

This commit is contained in:
Edwin Eefting 2021-05-09 10:56:30 +02:00
parent 521d1078bd
commit 086cfe570b
4 changed files with 41 additions and 45 deletions

View File

@ -26,9 +26,9 @@ class TestExecuteNode(unittest2.TestCase):
with self.subTest("multiline tabsplit"): with self.subTest("multiline tabsplit"):
self.assertEqual(node.run(["echo","l1c1\tl1c2\nl2c1\tl2c2"], tab_split=True), [['l1c1', 'l1c2'], ['l2c1', 'l2c2']]) self.assertEqual(node.run(["echo","l1c1\tl1c2\nl2c1\tl2c2"], tab_split=True), [['l1c1', 'l1c2'], ['l2c1', 'l2c2']])
#escaping test (shouldnt be a problem locally, single quotes can be a problem remote via ssh) #escaping test
with self.subTest("escape test"): with self.subTest("escape test"):
s="><`'\"@&$()$bla\\/.*!#test _+-={}[]|" s="><`'\"@&$()$bla\\/.* !#test _+-={}[]| ${bla} $bla"
self.assertEqual(node.run(["echo",s]), [s]) self.assertEqual(node.run(["echo",s]), [s])
#return std err as well, trigger stderr by listing something non existing #return std err as well, trigger stderr by listing something non existing

View File

@ -1,6 +1,7 @@
import subprocess import subprocess
import os import os
import select import select
import shlex
class CmdPipe: class CmdPipe:
"""a pipe of one or more commands. also takes care of utf-8 encoding/decoding and line based parsing""" """a pipe of one or more commands. also takes care of utf-8 encoding/decoding and line based parsing"""
@ -17,31 +18,32 @@ class CmdPipe:
self.readonly = readonly self.readonly = readonly
self._should_execute = True self._should_execute = True
def add(self, cmd, readonly=False, stderr_handler=None, exit_handler=None): def add(self, cmd, readonly=False, stderr_handler=None, exit_handler=None, shell=False):
"""adds a command to pipe""" """adds a command to pipe"""
self.items.append({ self.items.append({
'cmd': cmd, 'cmd': cmd,
'stderr_handler': stderr_handler, 'stderr_handler': stderr_handler,
'exit_handler': exit_handler 'exit_handler': exit_handler,
'shell': shell
}) })
if not readonly and self.readonly: if not readonly and self.readonly:
self._should_execute = False self._should_execute = False
def __str__(self): def __str__(self):
"""transform into oneliner for debugging and testing """ """transform into oneliner for debugging and testing. this should generate a copy-pastable string for in a console """
#just one command?
if len(self.items)==1:
return " ".join(self.items[0]['cmd'])
#an actual pipe
ret = "" ret = ""
for item in self.items: for item in self.items:
if ret: if ret:
ret = ret + " | " ret = ret + " | "
if item['shell']:
#its already copy pastable for a shell:
ret = ret + "(" + " ".join(item['cmd']) + ")" ret = ret + "(" + " ".join(item['cmd']) + ")"
else:
#make it copy-pastable, will make a mess of quotes sometimes, but is correct
ret = ret + "(" + shlex.join(item['cmd']) + ")"
return ret return ret
@ -69,7 +71,7 @@ class CmdPipe:
encoded_cmd.append(arg.encode('utf-8')) encoded_cmd.append(arg.encode('utf-8'))
item['process'] = subprocess.Popen(encoded_cmd, env=os.environ, stdout=subprocess.PIPE, stdin=stdin, item['process'] = subprocess.Popen(encoded_cmd, env=os.environ, stdout=subprocess.PIPE, stdin=stdin,
stderr=subprocess.PIPE) stderr=subprocess.PIPE, shell=item['shell'])
selectors.append(item['process'].stderr) selectors.append(item['process'].stderr)

View File

@ -1,7 +1,7 @@
import os import os
import select import select
import subprocess import subprocess
import shlex
from zfs_autobackup.CmdPipe import CmdPipe from zfs_autobackup.CmdPipe import CmdPipe
from zfs_autobackup.LogStub import LogStub from zfs_autobackup.LogStub import LogStub
@ -48,30 +48,23 @@ class ExecuteNode(LogStub):
# else: # else:
# self.error("STDERR|> " + line.rstrip()) # self.error("STDERR|> " + line.rstrip())
def _remote_cmd(self, cmd): def _shell_cmd(self, cmd):
"""transforms cmd in correct form for remote over ssh, if needed""" """prefix specified ssh shell to command and escape shell characters"""
# use ssh? ret=[]
if self.ssh_to is not None:
encoded_cmd = [] #add remote shell
encoded_cmd.append("ssh") if not self.is_local():
ret=["ssh"]
if self.ssh_config is not None: if self.ssh_config is not None:
encoded_cmd.extend(["-F", self.ssh_config]) ret.extend(["-F", self.ssh_config])
encoded_cmd.append(self.ssh_to) ret.append(self.ssh_to)
for arg in cmd: ret.append(shlex.join(cmd))
# add single quotes for remote commands to support spaces and other weird stuff (remote commands are
# executed in a shell) and escape existing single quotes (bash needs ' to end the quoted string,
# then a \' for the actual quote and then another ' to start a new quoted string) (and then python
# needs the double \ to get a single \)
encoded_cmd.append(("'" + arg.replace("'", "'\\''") + "'"))
return encoded_cmd
else:
return(cmd)
return ret
def is_local(self): def is_local(self):
return self.ssh_to is None return self.ssh_to is None
@ -81,6 +74,8 @@ class ExecuteNode(LogStub):
return_stderr=False, pipe=False): return_stderr=False, pipe=False):
"""run a command on the node , checks output and parses/handle output and returns it """run a command on the node , checks output and parses/handle output and returns it
Either uses a local shell (sh -c) or remote shell (ssh) to execute the command. Therefore the command can have stuff like actual pipes in it, if you dont want to use pipe=True to pipe stuff.
:param cmd: the actual command, should be a list, where the first item is the command :param cmd: the actual command, should be a list, where the first item is the command
and the rest are parameters. and the rest are parameters.
:param pipe: return CmdPipe instead of executing it. :param pipe: return CmdPipe instead of executing it.
@ -121,9 +116,8 @@ class ExecuteNode(LogStub):
if (valid_exitcodes != []) and (exit_code not in valid_exitcodes): if (valid_exitcodes != []) and (exit_code not in valid_exitcodes):
raise (ExecuteError("Command '{}' returned exit code {} (valid codes: {})".format(" ".join(cmd), exit_code, valid_exitcodes))) raise (ExecuteError("Command '{}' returned exit code {} (valid codes: {})".format(" ".join(cmd), exit_code, valid_exitcodes)))
# add command to pipe #add shell command and handlers to pipe
encoded_cmd = self._remote_cmd(cmd) p.add(cmd=self._shell_cmd(cmd), readonly=readonly, stderr_handler=stderr_handler, exit_handler=exit_handler, shell=self.is_local())
p.add(cmd=encoded_cmd, readonly=readonly, stderr_handler=stderr_handler, exit_handler=exit_handler)
# return pipe instead of executing? # return pipe instead of executing?
if pipe: if pipe:

View File

@ -557,16 +557,16 @@ class ZfsDataset:
cmd.append(self.name) cmd.append(self.name)
# #add custom output pipes? # #add custom output pipes?
#local so do our own piping # #local so do our own piping
if self.zfs_node.is_local(): # if self.zfs_node.is_local():
output_pipe = self.zfs_node.run(cmd, pipe=True, readonly=True) # output_pipe = self.zfs_node.run(cmd, pipe=True, readonly=True)
for pipe_cmd in output_pipes: # for pipe_cmd in output_pipes:
output_pipe=self.zfs_node.run(pipe_cmd.split(" "), inp=output_pipe, pipe=True, readonly=False) # output_pipe=self.zfs_node.run(pipe_cmd.split(" "), inp=output_pipe, pipe=True, readonly=False)
#remote, so add with actual | and let remote shell handle it # #remote, so add with actual | and let remote shell handle it
else: # else:
for pipe_cmd in output_pipes: # for pipe_cmd in output_pipes:
cmd.append("|") # cmd.append("|")
cmd.extend(pipe_cmd.split(" ")) # cmd.extend(pipe_cmd.split(" "))
output_pipe = self.zfs_node.run(cmd, pipe=True, readonly=True) output_pipe = self.zfs_node.run(cmd, pipe=True, readonly=True)
return output_pipe return output_pipe