├── examples ├── helloworld-local.py ├── tmux.py ├── 001-packages-ensure.py ├── helloworld-remote.py ├── helloworld-remote-parallel-ssh.py └── helloworld-remote-mitogen.py ├── tests ├── command-cd.py ├── primitive-reporting.py ├── connection-tmux.py ├── repl.py ├── utils-quotes.py ├── connection-local.py ├── connection-mitogen.py ├── connection-paramiko.py ├── connection-parallelssh.py ├── bugs │ └── 124-file_write.py ├── connection-streaming.py ├── transport-parallelssh.py ├── transport-paramiko.py ├── transport-tmux.py ├── api-user.py ├── connection-context.py ├── transport-sshcommand.py ├── transport-mitogen.py ├── harness.py └── linux │ └── all.py ├── .gitignore ├── src └── py │ └── cuisine │ ├── __init__.py │ ├── __main__.py │ ├── api │ ├── logging.py │ ├── locale.py │ ├── system.py │ ├── env.py │ ├── tools.py │ ├── __main__.py │ ├── tmux.py │ ├── util │ │ └── config.py │ ├── command.py │ ├── process.py │ ├── config.py │ ├── dir.py │ ├── ssh.py │ ├── packages_python.py │ ├── text.py │ ├── __init__.py │ ├── user.py │ ├── connection.py │ ├── group.py │ ├── file.py │ ├── package.py │ └── _stub.py │ ├── connection │ ├── parallelssh.py │ ├── local.py │ ├── mitogen.py │ ├── paramiko.py │ ├── tmux.py │ └── __init__.py │ ├── decorators.py │ ├── utils.py │ └── logging.py ├── .appenv ├── docs ├── repl.md ├── configuration.md ├── persistent-sessions.md ├── sessions.md ├── variants.md ├── api.md ├── internals.md ├── reporting.md └── connections.md ├── TODO.md ├── setup.py ├── LICENSE ├── Makefile └── README.md /examples/helloworld-local.py: -------------------------------------------------------------------------------- 1 | from cuisine import * 2 | print(run("echo 'Hello, World!'")) 3 | -------------------------------------------------------------------------------- /tests/command-cd.py: -------------------------------------------------------------------------------- 1 | from cuisine import * 2 | 3 | cd("/") 4 | assert pwd() == "/" 5 | # EOF 6 | -------------------------------------------------------------------------------- /tests/primitive-reporting.py: -------------------------------------------------------------------------------- 1 | from cuisine import * 2 | 3 | run("tar fxz package.tar.gz") 4 | # EOF 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | api/.doctrees 2 | .buildinfo 3 | src/cuisine.egg-info 4 | *.pyc 5 | build/* 6 | dist/* 7 | .vagrant 8 | .bundle/ 9 | -------------------------------------------------------------------------------- /src/py/cuisine/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from cuisine.api._repl import * 3 | except ModuleNotFoundError as e: 4 | pass 5 | -------------------------------------------------------------------------------- /tests/connection-tmux.py: -------------------------------------------------------------------------------- 1 | from cuisine import * 2 | connect_tmux(session="cuisine", window="new") 3 | print(run("echo 'Hello, World'")) 4 | -------------------------------------------------------------------------------- /tests/repl.py: -------------------------------------------------------------------------------- 1 | from cuisine import * 2 | # session = record("myscript.log") 3 | # connect("localhost") 4 | user = run("whoami") 5 | print(user) 6 | # s ession.end() 7 | -------------------------------------------------------------------------------- /.appenv: -------------------------------------------------------------------------------- 1 | # See 2 | appenv_declare cuisine 3 | appenv_prepend PYTHONPATH $APPENV_DIR/src/py 4 | appenv_prepend PATH bin 5 | # EOF 6 | -------------------------------------------------------------------------------- /examples/tmux.py: -------------------------------------------------------------------------------- 1 | from cuisine import connect_tmux, run 2 | with connect_tmux(session="cuisine", window="0"): 3 | print("Run on TMUX:", run('echo "$TMUX"')) 4 | print("Run locally:", run('echo "$TMUX"')) 5 | 6 | # EOF 7 | -------------------------------------------------------------------------------- /tests/utils-quotes.py: -------------------------------------------------------------------------------- 1 | from cuisine import run 2 | from cuisine.utils import quoted 3 | 4 | 5 | command = "test -e '/dev/null' && echo 'TRUE'" 6 | quoted_command = quoted(command) 7 | print(command, quoted_command) 8 | run(f"sh -c {quoted_command}") 9 | -------------------------------------------------------------------------------- /tests/connection-local.py: -------------------------------------------------------------------------------- 1 | from cuisine.connection.local import LocalConnection 2 | c = LocalConnection().connect() 3 | assert c.run("echo 'Hello, World!'").value == "Hello, World!" 4 | assert c.run("echo -n 'Hello, World!'").value == "Hello, World!" 5 | print("OK") 6 | print("END") 7 | # EOF 8 | -------------------------------------------------------------------------------- /src/py/cuisine/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | sys.stdout.write( 4 | """ 5 | _ _ 6 | _______ __(_)____(_)___ ___ 7 | / ___/ / / / / ___/ / __ \/ _ \\ 8 | / /__/ /_/ / (__ ) / / / / __/ 9 | \___/\__,_/_/____/_/_/ /_/\___/ 10 | 11 | """ 12 | ) 13 | # EOF 14 | -------------------------------------------------------------------------------- /tests/connection-mitogen.py: -------------------------------------------------------------------------------- 1 | from cuisine.connection.mitogen import MitogenConnection 2 | c = MitogenConnection().connect("localhost") 3 | assert c.run("echo 'Hello, World!'").value == "Hello, World!" 4 | assert c.run("echo -n 'Hello, World!'").value == "Hello, World!" 5 | print("OK") 6 | print("END") 7 | # EOF 8 | -------------------------------------------------------------------------------- /examples/001-packages-ensure.py: -------------------------------------------------------------------------------- 1 | from cuisine import * 2 | 3 | # We're going to see if the package TMux is installed 4 | if not package_available("tmux"): 5 | fail("Package tmux not available in your distribution") 6 | 7 | if not package_installed("tmux"): 8 | package_install("tmux") 9 | 10 | require_package("tmux") 11 | -------------------------------------------------------------------------------- /tests/connection-paramiko.py: -------------------------------------------------------------------------------- 1 | print("=== TEST ParamikoConnection connection") 2 | from cuisine.connection.paramiko import ParamikoConnection 3 | 4 | c = ParamikoConnection().connect("localhost") 5 | assert c.run("echo 'Hello, World!'").value == "Hello, World!" 6 | assert c.run("echo -n 'Hello, World!'").value == "Hello, World!" 7 | print("<.. EOK") 8 | # EOF 9 | -------------------------------------------------------------------------------- /tests/connection-parallelssh.py: -------------------------------------------------------------------------------- 1 | print("=== TEST ParallelSSH connection") 2 | from cuisine.connection.parallelssh import ParallelSSHConnection 3 | 4 | c = ParallelSSHConnection().connect("localhost") 5 | assert c.run("echo 'Hello, World!'").value == "Hello, World!" 6 | assert c.run("echo -n 'Hello, World!'").value == "Hello, World!" 7 | print("<.. EOK") 8 | # EOF 9 | -------------------------------------------------------------------------------- /examples/helloworld-remote.py: -------------------------------------------------------------------------------- 1 | from cuisine import * 2 | 3 | # To do this, you will need to 1) have an ssh server running on 4 | # your current host, and 2) to have `parallel-ssh` installed. 5 | connect(host="localhost", user=run_local("whoami").last_line) 6 | # This will now be run through the SSH connection 7 | print ("cuisine got:", run("echo 'Hello, World'!").value, f"through {connection().host} via {connection().type}") 8 | # EOF 9 | -------------------------------------------------------------------------------- /examples/helloworld-remote-parallel-ssh.py: -------------------------------------------------------------------------------- 1 | from cuisine import * 2 | 3 | # To do this, you will need to 1) have an ssh server running on 4 | # your current host, and 2) to have `parallel-ssh` installed. 5 | connect(host="localhost", user=run_local("whoami").last_line, transport="parallel-ssh") 6 | # This will now be run through the SSH connection 7 | print ("cuisine got:", run("echo 'Hello, World'!").value, f"through {connection().host} via {connection().type}") 8 | # EO 9 | -------------------------------------------------------------------------------- /tests/bugs/124-file_write.py: -------------------------------------------------------------------------------- 1 | import cuisine 2 | import os 3 | 4 | TEXT = "file_write is broken" 5 | PATH = "/tmp/cuisine-bug-124.txt" 6 | if os.path.exists(PATH): 7 | os.unlink(PATH) 8 | 9 | cuisine.connect("localhost") 10 | cuisine.file_write(PATH, TEXT) 11 | assert os.path.exists(PATH), "file_write did not create file %s" % (PATH) 12 | text = file(PATH).read() 13 | assert TEXT == text, "Expected: %s, got %s" % (repr(TEXT), repr(text)) 14 | print "OK" 15 | # EOF 16 | -------------------------------------------------------------------------------- /src/py/cuisine/api/logging.py: -------------------------------------------------------------------------------- 1 | from ..api import APIModule as API 2 | from ..decorators import expose 3 | from ..logging import LoggingContext 4 | 5 | 6 | class LoggingAPI(API): 7 | 8 | def init(self): 9 | self.log = LoggingContext() 10 | 11 | @expose 12 | def info(self, message: str) -> None: 13 | self.log.info(message) 14 | 15 | @expose 16 | def error(self, message: str) -> None: 17 | self.log.error(message) 18 | 19 | 20 | # EOF 21 | -------------------------------------------------------------------------------- /examples/helloworld-remote-mitogen.py: -------------------------------------------------------------------------------- 1 | from cuisine import * 2 | 3 | 4 | # To do this, you will need to 1) have an ssh server running on 5 | # your current host, and 2) to have `parallel-ssh` installed. 6 | connect(host="localhost", user=run_local( 7 | "whoami").last_line, transport="mitogen") 8 | # This will now be run through the SSH connection 9 | print("cuisine got:", run("echo 'Hello, World'!").value, 10 | f"through {connection().host} via {connection().type}") 11 | # EOF 12 | -------------------------------------------------------------------------------- /docs/repl.md: -------------------------------------------------------------------------------- 1 | # REPL / Interactive Session 2 | 3 | Cuisine is designed to be used both as an API and as an interactive session, 4 | similar to what you would do if you were SSH'ing to a server. The key 5 | feature with an interactive session is that Cuisine is able to keep track 6 | of the API calls so that you can capture the history as a script, 7 | replay it and edit if necessary. 8 | 9 | ```python 10 | from cuisine import * 11 | session = record("myscript.log") 12 | connect("localhost") 13 | user = run("whoami") 14 | session.end() 15 | 16 | 17 | ``` 18 | -------------------------------------------------------------------------------- /tests/connection-streaming.py: -------------------------------------------------------------------------------- 1 | from cuisine import * 2 | import threading 3 | import time 4 | 5 | print("=== TEST Connection: Streaming output") 6 | print("--- EXPECT timeout=15") 7 | 8 | 9 | def writer(): 10 | for _ in range(10): 11 | run("date '+%T' >> streaming.txt") 12 | time.sleep(1) 13 | 14 | 15 | t = threading.Thread(target=writer) 16 | t.start() 17 | 18 | run("touch streaming.txt") 19 | res = run("timeout 10 tail -f streaming.txt") 20 | run("unlink streaming.txt") 21 | print(res.lines) 22 | 23 | t.join() 24 | 25 | print("EOK") 26 | # EOF 27 | -------------------------------------------------------------------------------- /tests/transport-parallelssh.py: -------------------------------------------------------------------------------- 1 | from pssh.clients import SSHClient 2 | from datetime import datetime 3 | 4 | host = "localhost" 5 | cmds = [ 6 | "echo first command", 7 | "echo second command", 8 | "sleep 1; echo third command took one second", 9 | ] 10 | # FIXME: This does not work either 11 | client = SSHClient(host, pkey="~/.ssh/id_rsa") 12 | 13 | start = datetime.now() 14 | for cmd in cmds: 15 | out = client.run_command(cmd) 16 | for line in out.stdout: 17 | print(line) 18 | end = datetime.now() 19 | print("Took %s seconds" % (end - start).total_seconds()) 20 | print("<.. EOK") 21 | # EOF 22 | -------------------------------------------------------------------------------- /tests/transport-paramiko.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | import paramiko 4 | 5 | # SEE: With keys 6 | try: 7 | client = paramiko.SSHClient() 8 | client.load_system_host_keys() 9 | client.set_missing_host_key_policy(paramiko.WarningPolicy) 10 | # FIXME: Paramiko does not work out of the box there 11 | client.connect(hostname="127.0.0.1", username="spierre", look_for_keys=True, key_filename="/home/spierre/.ssh/id_rsa.pub") 12 | stdin, stdout, stderr = client.exec_command("echo 'Hello, World'") 13 | print (stdout.read()) 14 | finally: 15 | client.close() 16 | -------------------------------------------------------------------------------- /src/py/cuisine/api/locale.py: -------------------------------------------------------------------------------- 1 | # ============================================================================= 2 | # 3 | # LOCALE 4 | # 5 | # ============================================================================= 6 | 7 | 8 | def locale_check(locale): 9 | locale_data = sudo("locale -a | egrep '^%s$' ; true" % (locale,)) 10 | return locale_data == locale 11 | 12 | 13 | def locale_ensure(locale): 14 | if not locale_check(locale): 15 | with fabric.context_managers.settings(warn_only=True): 16 | sudo("/usr/share/locales/install-language-pack %s" % (locale,)) 17 | sudo("dpkg-reconfigure locales") 18 | 19 | 20 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Cuisine can be configured to change its 4 | 5 | ## Enabling and disabling options at runtime 6 | 7 | `enable_{option}` 8 | 9 | `disable_{option}` 10 | 11 | ## Enabling and disabling options locally at runtime 12 | 13 | As with any other function of the Cuisine API, you calling them 14 | with the context of a connection will make them local to the current 15 | connection/session. 16 | 17 | ## Enabling and disabling options before runtime 18 | 19 | `CUISINE_{OPTION}` 20 | 21 | ## Temporarily switching an option 22 | 23 | `with_{option}` 24 | 25 | 26 | ## Querying the state of an option 27 | 28 | `get_{option}` 29 | -------------------------------------------------------------------------------- /tests/transport-tmux.py: -------------------------------------------------------------------------------- 1 | from cuisine.connection.local import LocalConnection 2 | from cuisine.connection.tmux import Tmux 3 | import time 4 | 5 | new_session = f"XXX_{time.time()}".replace('.', '_') 6 | 7 | tmux = Tmux(LocalConnection()) 8 | original_sessions = tmux.session_list() 9 | assert new_session not in original_sessions 10 | assert not tmux.session_has(new_session) 11 | 12 | tmux.session_ensure(new_session) 13 | assert new_session in tmux.session_list() 14 | 15 | tmux.session_kill(new_session) 16 | assert new_session not in tmux.session_list() 17 | 18 | # This is only true if there is no concurrent test 19 | assert tmux.session_list() == original_sessions 20 | 21 | # EOF 22 | -------------------------------------------------------------------------------- /tests/api-user.py: -------------------------------------------------------------------------------- 1 | import os 2 | from cuisine import * 3 | import getpass 4 | import tempfile 5 | user = getpass.getuser() 6 | path = tempfile.mkdtemp() 7 | if not os.path.exists(path): 8 | os.mkdir(path) 9 | cuisine.user_exists(user) 10 | print("-- PREP test user does not exist") 11 | assert not cuisine.user_exists("cuisine-demo"), "!! FAIL" 12 | print(".. OK") 13 | print("-- TEST Creating user") 14 | cuisine.user_ensure("cuisine-demo", home=path, passwd="secret") 15 | assert cuisine.user_exists("cuisine-demo"), "!! FAIL" 16 | print(".. OK") 17 | print("-- TEST Deleting user") 18 | cuisine.user_remove("cuisine-demo", remove_home=True) 19 | assert not cuisine.user_exists("cuisine-demo"), "!! FAIL" 20 | print(".. OK") 21 | # EOF 22 | -------------------------------------------------------------------------------- /src/py/cuisine/api/system.py: -------------------------------------------------------------------------------- 1 | # ============================================================================= 2 | # 3 | # SYSTEM 4 | # 5 | # ============================================================================= 6 | 7 | def system_uuid_alias_add(): 8 | """Adds system UUID alias to /etc/hosts. 9 | Some tools/processes rely/want the hostname as an alias in 10 | /etc/hosts e.g. `127.0.0.1 localhost `. 11 | """ 12 | with mode_sudo(): 13 | old = "127.0.0.1 localhost" 14 | new = old + " " + system_uuid() 15 | file_update('/etc/hosts', lambda x: text_replace_line(x, old, new)[0]) 16 | 17 | 18 | def system_uuid(): 19 | """Gets a machines UUID (Universally Unique Identifier).""" 20 | return sudo('dmidecode -s system-uuid | tr "[A-Z]" "[a-z]"') 21 | 22 | 23 | -------------------------------------------------------------------------------- /docs/persistent-sessions.md: -------------------------------------------------------------------------------- 1 | # Persistent Sessions 2 | 3 | Cuisine can run commands by layering connections. For instance, you can 4 | interact with a local screen sessions: 5 | 6 | ```python 7 | # Local screen connection 8 | with connect_screen("my-session") as s: 9 | print(s.run("Hello, from $(hostname)")) 10 | # Remote screen connection 11 | with connect("my-remote-host") as c: 12 | with c.connect_screen("my-session") as s: 13 | print(s.run("Hello, from $(hostname)")) 14 | 15 | ``` 16 | 17 | When connecting to persistent sessions, you'll need to make sure that 18 | the corresponding commands are available on the remote host, for instance 19 | by doing `package_ensure("screen")`. 20 | 21 | ## Screen 22 | 23 | `connect_screen` 24 | 25 | ## Tmux 26 | 27 | `connect_tmux` 28 | 29 | ## Mosh 30 | 31 | `connect_mosh` 32 | -------------------------------------------------------------------------------- /tests/connection-context.py: -------------------------------------------------------------------------------- 1 | from cuisine import connect_tmux, run, disconnect 2 | # -- 3 | # Tests if the connection context works properly. 4 | # -- 5 | # # Using the context 6 | # We're using the connection context here and running two 7 | # commands, one in the Tmux environment, one in the local environment. 8 | # -- 9 | assert not (run('echo "$TMUX"').value) 10 | with connect_tmux(session="cuisine", window="0"): 11 | assert run('echo "$TMUX"').value 12 | assert not run('echo "$TMUX"').value, "Should be in the local environment" 13 | # -- 14 | # # Not using the context 15 | # If we're not using the context, then any command will be ran 16 | # inside the connection until we disconnect. 17 | # -- 18 | connect_tmux(session="cuisine", window="0") 19 | assert run('echo "$TMUX"').value 20 | disconnect() 21 | assert not run('echo "$TMUX"').value 22 | # EOF 23 | -------------------------------------------------------------------------------- /tests/transport-sshcommand.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import subprocess 4 | import getpass 5 | from typing import List 6 | 7 | # FROM: https://acrisel.github.io/posts/2017/08/ssh-made-easy-using-python/ 8 | 9 | 10 | def run_ssh(command: List[str], host="localhost", user=getpass.getuser(), port=22, stdin=None, check=False): 11 | result = subprocess.run(["ssh", f"{user}@{host}"] + [_ for _ in command], 12 | shell=False, 13 | stdin=stdin, 14 | stdout=subprocess.PIPE, 15 | stderr=subprocess.PIPE, 16 | check=check) 17 | return (result.stdout, result.stderr) 18 | 19 | 20 | # SEE: With keys 21 | stdout, stderr = run_ssh(["echo", "Hello, World!"]) 22 | print(stdout, stderr) 23 | -------------------------------------------------------------------------------- /docs/sessions.md: -------------------------------------------------------------------------------- 1 | # Sessions 2 | 3 | Sessions are high-level connections that make it easier to interact 4 | with the host. 5 | 6 | ```python 7 | with session() as s: 8 | print (s.echo("Hello, World")) 9 | with s.open("/etc/users", "r") as f: 10 | print (f.read()) 11 | ``` 12 | 13 | Sessions provide primitive commands: 14 | 15 | - `open(path)` to open files for read/write 16 | - `cd(path)` to change the directory 17 | - `exec(command, *args)` to run a given command 18 | - `conf` is the session configuration 19 | - `env` is the session environment 20 | 21 | and any attribute that is not part of that will be resolved as a command, 22 | using the following process: 23 | 24 | - Is the command found in the session configuration? Then the configuration 25 | value will be used. 26 | - Is the command found in the session environment as `COMMAND_{COMMAND:upper}`? Then the value 27 | value will be used. 28 | - Otherwise the command name will be used as-is 29 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ## Refactor 2 | 3 | - Connection: support streaming, and paralell ssh 4 | 5 | ## Features 6 | 7 | - Options: global options that can be used as defaults, such as the SSH transport, etc. 8 | 9 | - File: when using file\_write, it seems that the existing attributes 10 | are not always preserved 11 | - Commands run through API functions should be silenced unless we're debugging. 12 | Basically, we need some kind of command tracing. 13 | 14 | - Have the command status as one line (single reporting), or like it is now (multiplexing) 15 | 16 | - We need the multiplexing support 17 | 18 | - Tmux sessions are nested within the same connection, and we need to have 19 | some kind of stateful part so that we absorb the detail by default. It's 20 | an interesting use case for logging. 21 | 22 | Connection: 23 | 24 | - Automatically clears `paramiko.ssh_exception.BadHostKeyException: Host key for server '3.104.147.76' does not match: got 'AAAAC3NzaC1lZDI1NTE5AAAAIFekt+4ctGPiY4PKB3V5okAOSdwRfHBfOmiYrX5/I8lk', expected 'AAAAC3NzaC1lZDI1NTE5AAAAIKAoGchYME9rbBRTfwq1upldReXfh7oAiLa4BgBBem5n'` 25 | -------------------------------------------------------------------------------- /src/py/cuisine/api/env.py: -------------------------------------------------------------------------------- 1 | from ..api import APIModule 2 | from ..decorators import expose 3 | from typing import Optional 4 | import os 5 | 6 | 7 | class Environment(APIModule): 8 | """Manages connections to local and remote hosts.""" 9 | 10 | @expose 11 | def env_get(self, variable: str, default: Optional[str] = None) -> str: 12 | """Returns the given `variable` from the connection's environment, returning 13 | `default` if not found.""" 14 | return os.environ[variable] if variable in os.environ else default or "" 15 | 16 | @expose 17 | def env_set(self, variable: str, value: str) -> str: 18 | """Sets the given `variable` in the connection's environment, returning 19 | `default` if not found.""" 20 | previous = self.env_get(variable) 21 | os.environ[variable] = value 22 | return previous 23 | 24 | @expose 25 | def env_clear(self, variable: str) -> str: 26 | """Clears the given `variable` from connection's environment. 27 | `default` if not found.""" 28 | raise NotImplementedError 29 | 30 | # EOF 31 | -------------------------------------------------------------------------------- /src/py/cuisine/api/tools.py: -------------------------------------------------------------------------------- 1 | 2 | # ============================================================================= 3 | # 4 | # RSYNC 5 | # 6 | # ============================================================================= 7 | 8 | 9 | def rsync(local_path: str, remote_path: str, compress: bool = True, progress: bool = False, verbose: bool = True, owner: bool = None, group: bool = None): 10 | """Rsyncs local to remote, using the connection's host and user.""" 11 | options = "-a" 12 | if compress: 13 | options += "z" 14 | if verbose: 15 | options += "v" 16 | if progress: 17 | options += " --progress" 18 | if owner or group: 19 | assert owner and group or not owner 20 | options += " --chown={0}{1}".format(owner or "", 21 | ":" + group if group else "") 22 | with mode_local(): 23 | run("rsync {options} {local} {user}@{host}:{remote}".format( 24 | options=options, 25 | host=host(), 26 | user=user(), 27 | local=local_path, 28 | remote=remote_path, 29 | )) 30 | -------------------------------------------------------------------------------- /tests/transport-mitogen.py: -------------------------------------------------------------------------------- 1 | import os 2 | import mitogen 3 | try: 4 | from cuisine import run_local, run_local_raw 5 | 6 | except ImportError as e: 7 | print("You need 'cuisine' in your $PYTHONPATH, or run 'appenv' ") 8 | 9 | 10 | def run(cmd): 11 | os.system(cmd) 12 | 13 | 14 | @mitogen.main() 15 | def main(router): 16 | context = router.ssh(hostname="localhost") 17 | # TODO: We need (stdout, stderr, exit) 18 | res = context.call(run, "echo 'Hello, World!'") 19 | print(f"OK: mitogen.run()={res}") 20 | res = context.call(run_local_raw, "echo 'Hello, World!'") 21 | print(f"OK: mitogen.run_local_raw()={res}") 22 | try: 23 | res = context.call(run_local, "echo 'Hello, World!'") 24 | print(f"OK: mitogen.run_local()={res}") 25 | except mitogen.core.StreamError as e: 26 | # The takeaway here is that Mitogen is good for communicating raw data (ie. something 27 | # that can be sent as JSON), but not for complex objects. Here's another 28 | # argument for data-oriented programming. 29 | print(f"FAIL: mitogen.run_local()={e}") 30 | print("END") 31 | # EOF 32 | -------------------------------------------------------------------------------- /src/py/cuisine/api/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import argparse 4 | from ..api import toInterface, toImplementation, toNamespace 5 | 6 | __doc__ = """ 7 | Command-line tool to generate stubs/implementation for the flat-file 8 | Cuisine API. 9 | """ 10 | 11 | parser = argparse.ArgumentParser( 12 | prog=os.path.basename(__file__.split(".")[0]), 13 | description="Generate Cuisine API stubs and implementation" 14 | ) 15 | parser.add_argument("-o", "--output", type=str, dest="output", default="-", 16 | help="Specifies an output file") 17 | parser.add_argument("-t", "--type", type=str, dest="type", default="stub", 18 | help="The type of output, either stub or impl") 19 | args = parser.parse_args(args=sys.argv[1:]) 20 | if args.type == "impl": 21 | processor = toImplementation 22 | elif args.type == "repl": 23 | processor = toNamespace 24 | elif args.type == "stub": 25 | processor = toInterface 26 | else: 27 | raise ValueError("Expected impl, repl or stub") 28 | out = sys.stdout if args.output == "-" else open(args.output, "wt") 29 | for line in processor(): 30 | out.write(line) 31 | out.write("\n") 32 | # EOF 33 | -------------------------------------------------------------------------------- /src/py/cuisine/api/tmux.py: -------------------------------------------------------------------------------- 1 | from ..api import APIModule 2 | from ..decorators import logged, dispatch, variant, expose 3 | from ..connection.tmux import Tmux, TmuxConnection 4 | from typing import List, Optional 5 | 6 | 7 | class TmuxAPI(APIModule): 8 | 9 | @property 10 | def _tmux(self) -> Tmux: 11 | c = self.api.connection_like( 12 | lambda _: not isinstance(_, TmuxConnection)) 13 | assert c, "Could not find a suitable connection for creating a Tmux instance" 14 | return Tmux(c) 15 | 16 | @expose 17 | def tmux_session_list(self) -> List[str]: 18 | return self._tmux.session_list() 19 | 20 | @expose 21 | def tmux_window_list(self, session: str) -> List[int]: 22 | return self._tmux.window_list(session) 23 | 24 | @expose 25 | def tmux_has(self, session: str, window: Optional[int]) -> bool: 26 | if window is None: 27 | return self._tmux.session_has(session) 28 | else: 29 | return self._tmux.window_has(session, window) 30 | 31 | @expose 32 | def tmux_is_responsive(self, session: str, window: int) -> Optional[bool]: 33 | return self._tmux.is_responsive(session, window) 34 | 35 | 36 | # EOF 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Encoding: utf-8 3 | # See: 4 | try: 5 | from setuptools import setup 6 | except ImportError: 7 | from distutils.core import setup 8 | 9 | VERSION = eval(filter(lambda _:_.startswith("VERSION"), 10 | file("src/cuisine.py").readlines())[0].split("=")[1]) 11 | 12 | setup( 13 | name = "cuisine", 14 | version = VERSION, 15 | description = "Chef-like functionality for Fabric", 16 | author = "Sébastien Pierre", 17 | author_email = "sebastien.pierre@gmail.com", 18 | url = "http://github.com/sebastien/cuisine", 19 | download_url = "https://github.com/sebastien/cuisine/tarball/%s" % (VERSION), 20 | keywords = ["fabric", "chef", "ssh",], 21 | install_requires = ["fabric",], 22 | package_dir = {"":"src"}, 23 | py_modules = ["cuisine"], 24 | license = "License :: OSI Approved :: BSD License", 25 | classifiers = [ 26 | "Programming Language :: Python", 27 | "Development Status :: 3 - Alpha", 28 | "Natural Language :: English", 29 | "Environment :: Web Environment", 30 | "Intended Audience :: Developers", 31 | "Operating System :: OS Independent", 32 | "Topic :: Utilities" 33 | ], 34 | ) 35 | # EOF - vim: ts=4 sw=4 noet 36 | -------------------------------------------------------------------------------- /docs/variants.md: -------------------------------------------------------------------------------- 1 | # Variants 2 | 3 | Cuisine supports **variants**, which are typically common functionality, such 4 | as installing packages or managing users, which might have different 5 | implementations based on the context. 6 | 7 | Variant functions are all suffixed by their variants and have a corresponding 8 | dispatcher function. For instance, the `package_install` dispatches its 9 | arguments to `package_install_apt` (*apt variant*) or `package_install_yum` 10 | (*yum variant*). 11 | 12 | Selecting the current default variant is done using detection functions 13 | such as `detect_package`, or can be manually set using `select_package` 14 | 15 | In general, here are how variant work: 16 | 17 | - `select_` (eg. `select_package("yum")`), selects the default 18 | variant for the given group of API functions. 19 | - `detect_` (eg. `detect_package()`), detects the recommended/preferred 20 | variant for the given group of API. The behaviour will change based 21 | on the host and system. 22 | - `__` (eg. `package_install_yum`) is the variant-specific 23 | implementation of the functionality. 24 | - `_` (eg. `package_install`) dispatches to the variant-specific 25 | based on the currently selected default. 26 | 27 | In short, unless you explicitly want to use a specific variant implementation, 28 | you can use the generic dispatching functions. 29 | -------------------------------------------------------------------------------- /src/py/cuisine/api/util/config.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, TypeVar, Optional, Union, Dict 2 | import os 3 | 4 | T = TypeVar("T") 5 | 6 | 7 | class KeyValueStore(Generic[T]): 8 | 9 | def set(self, key: str, value: T) -> Optional[T]: 10 | pass 11 | 12 | def has(self, key: str) -> bool: 13 | pass 14 | 15 | def get(self, key: str) -> T: 16 | pass 17 | 18 | 19 | class DictKeyValueStore(KeyValueStore[T]): 20 | 21 | def __init__(self, source: Optional[Dict[str, T]] = None): 22 | self.values: Dict[str, T] = source or {} 23 | 24 | def set(self, key: str, value: T) -> Optional[T]: 25 | previous = self.values.get(key) 26 | self.values[key] = value 27 | return previous 28 | 29 | def has(self, key: str) -> bool: 30 | return key in self.values 31 | 32 | def get(self, key: str) -> T: 33 | return self.values.get(str) 34 | 35 | 36 | class EnvironKeyValueStore(KeyValueStore[str]): 37 | 38 | def __init__(self): 39 | pass 40 | 41 | def set(self, key: str, value: str) -> Optional[str]: 42 | previous = os.environ[key] if key in os.environ else None 43 | os.environ[key] = str(value) 44 | return previous 45 | 46 | def has(self, key: str) -> bool: 47 | return key in os.environ 48 | 49 | def get(self, key: str) -> Optiona[str]: 50 | return os.environ[key] if key in os.environ else None 51 | 52 | # EO 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2013, Sébastien Pierre 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL SEBASTIEN PIERRE BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | Cuisine's API is designed to be used both interactively and non-interactively 4 | using an editor that support type annotations, which translates into the API 5 | being flat-namespaced and fully discoverable from the cuisine top-level module, 6 | and which also comes with full type annotations. 7 | 8 | For instance, typing `cuisine.file_` folowed by a *TAB* from the Python 9 | interactive prompt should show you *all the functions* you can do for files. 10 | 11 | 12 | ## Groups 13 | 14 | The API is decomposed in functional groups that make it easy to see what 15 | can be done: 16 | 17 | - `file` 18 | - `dir` 19 | - `user` 20 | - `group` 21 | - `package` 22 | - `command` 23 | - `text` 24 | 25 | with additional API to manage the configuration and the environments: 26 | 27 | - `connect_` 28 | - `enable_` 29 | 30 | ## Variants 31 | 32 | - `select_` 33 | 34 | ## Internals 35 | 36 | Under the hood, the API is implemented as classes that implement a common contract. 37 | For instance, users and groups can be added, removed. Files can be read and written, 38 | packages can be installed and uninstalled. 39 | 40 | ``` 41 | User.add → user_add 42 | User.remove → user_remove 43 | ``` 44 | 45 | Variants are supported in the same way. For instance, RPM and DEB packages 46 | are each implemented in their separated modules and mapped to specific 47 | flat names: 48 | 49 | 'RPM.add → packages_add_rpm' 50 | 'DEV.add → packages_add_deb' 51 | 52 | in parallel, the `Packages.add` API, mapped to ` packages_add` will dispatch 53 | to the corresponding package type. 54 | -------------------------------------------------------------------------------- /src/py/cuisine/api/command.py: -------------------------------------------------------------------------------- 1 | import re 2 | from ..api import APIModule 3 | from ..decorators import requires, expose 4 | 5 | RE_COMMAND = re.compile(r"\s*([A-Za-z0-9\-]+)(.*)") 6 | 7 | DEFAULT_COMMANDS = { 8 | "pip": "python -m pip", 9 | "python": "python3", 10 | } 11 | 12 | 13 | class CommandAPI(APIModule): 14 | 15 | @expose 16 | def command(self, name: str) -> str: 17 | """Returns the normalized command name. This first tries to find a match 18 | in `DEFAULT_COMMANDS` and extract it, and then look for a `COMMAND_{name}` 19 | in the environment.""" 20 | if match := RE_COMMAND.match(name): 21 | cmd = match.group(1) 22 | params = match.group(2) or "" 23 | if cmd in DEFAULT_COMMANDS: 24 | cmd = self.command(DEFAULT_COMMANDS[cmd]) 25 | cmd_env = cmd.replace("-", "_").upper() 26 | return self.api.env_get(f"COMMAND_{cmd_env}", cmd) + params 27 | else: 28 | return name 29 | 30 | @expose 31 | @requires("which") 32 | def command_check(self, command: str) -> bool: 33 | """Tests if the given command is available on the system.""" 34 | return self.api.run("which '{command}' >& /dev/null && echo OK ; true").endswith("OK") 35 | 36 | @expose 37 | def command_ensure(self, command: str, package=None) -> bool: 38 | """Ensures that the given command is present, if not installs the 39 | package with the given name, which is the same as the command by 40 | default.""" 41 | if package is None: 42 | package = command 43 | if not self.command_check(command): 44 | self.api.package_install(package) 45 | assert self.command_check(command), \ 46 | "Command was not installed, check for errors: %s" % (command) 47 | return True 48 | 49 | 50 | # EOF 51 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SOURCES_PY =$(filter-out $(BUILD_PY),$(wildcard *.py src/*.py src/py/cuisine/*.py src/py/cuisine/*/*.py)) 2 | SOURCES =$(SOURCES_PY) $(BUILD_PY) 3 | BUILD =$(BUILD_PY) 4 | BUILD_PY :=src/py/cuisine/api/_impl.py src/py/cuisine/api/_stub.py src/py/cuisine/api/_repl.py 5 | MANIFEST =$(SOURCES) 6 | VERSION :=2.0.0 7 | PRODUCT :=MANIFEST doc $(BUILD) 8 | PYTHON :=python3 9 | OS :=$(shell )uname -s | tr A-Z a-z) 10 | 11 | readonly-pre=if [ -e "$1" ]; then chmod +w "$1" ; fi 12 | readonly-post=if [ -e "$1" ]; then chmod -w "$1" ; fi 13 | 14 | .PHONY: all doc clean check tests 15 | 16 | all: $(PRODUCT) 17 | 18 | release: $(PRODUCT) 19 | @git commit -a -m "Release $(VERSION)" ; true 20 | git tag $(VERSION) ; true 21 | git push --all ; true 22 | python setup.py clean sdist register upload 23 | 24 | tests: 25 | @PYTHONPATH=src/py:$(PYTHONPATH) python tests/$(OS)/all.py 26 | 27 | build-live: 28 | @echo $(SOURCES_PY) | xargs -n1 echo | entr make 29 | 30 | clean: 31 | @for FILE in $(PRODUCT); do if [ -f "$$FILE" ]; then unlink "$$FILE"; fi; done 32 | 33 | check: 34 | @pychecker -100 $(SOURCES) 35 | 36 | test: 37 | @python tests/all.py 38 | 39 | MANIFEST: $(MANIFEST) 40 | @echo $(MANIFEST) | xargs -n1 | sort | uniq > $@ 41 | 42 | # # Specific 43 | 44 | src/py/cuisine/api/_stub.py: $(filter src/py/cuisine/api/%,$(SOURCES_PY)) 45 | @$(call readonly-pre,$@) 46 | PYTHONPATH=src/py $(PYTHON) -m cuisine.api -t stub -o "$@" 47 | $(call readonly-post,$@) 48 | 49 | src/py/cuisine/api/_impl.py: $(filter src/py/cuisine/api/%,$(SOURCES_PY)) 50 | @$(call readonly-pre,$@) 51 | PYTHONPATH=src/py $(PYTHON) -m cuisine.api -t impl -o "$@" 52 | $(call readonly-post,$@) 53 | 54 | src/py/cuisine/api/_repl.py: $(filter src/py/cuisine/api/%,$(SOURCES_PY)) 55 | @$(call readonly-pre,$@) 56 | PYTHONPATH=src/py $(PYTHON) -m cuisine.api -t repl -o "$@" 57 | $(call readonly-post,$@) 58 | 59 | #EOF 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | _ _ 3 | _______ __(_)____(_)___ ___ 4 | / ___/ / / / / ___/ / __ \/ _ \ 5 | / /__/ /_/ / (__ ) / / / / __/ 6 | \___/\__,_/_/____/_/_/ /_/\___/ 7 | 8 | ``` 9 | 10 | Cuisine is a task automation tool written in Python that provides a platform 11 | neutral abstraction over your operating system. It is designed as a simple 12 | flat API to interact with one or more servers, making it easy to do remote 13 | scripting piloted by Python. 14 | 15 | 16 | # FAQ 17 | 18 | ## Why should I use Cuisine? 19 | 20 | Here are a few reasons why you would use Cuisine: 21 | 22 | - You prefer to use Python rather than shell scripts for automation 23 | - You prefer a simple solution to a complex framework 24 | - You want to have full control over your automation process 25 | 26 | ## How does Cuisine compare to others? 27 | 28 | Overall, Cuisine offers a simple abstraction layer over fundamental OS operations that make it easier to automate 29 | administration, building, provisioning, deployments and other devops-related tasks. 30 | 31 | - [Fabric](https://www.fabfile.org/): Fabric provides a way to run arbitrary 32 | commands across hosts, and sits at a lower level than Cuisine. In fact, the 33 | previous version of Cuisine was built on top of Fabric. 34 | - [Salt](https://docs.saltproject.io/en/latest/): Salt provides a high-level 35 | declarative interface to systems, while Cuisine offers a lower level API that 36 | you can use to write your own scripts or logic. 37 | 38 | ## Which systems are supported by Cuisine? 39 | 40 | Currently, Cuisine is only intended to work on UNIX systems, and has specialised functions 41 | for the following systems: 42 | 43 | - Packages: apt (Debian,Ubuntu), yum (Redhat, Fedora), pkg (FreeBSD) 44 | 45 | 46 | # References 47 | 48 | - [Mitogen](https://mitogen.networkgenomics.com/) 49 | - Paramiko 50 | - ParallelSSH 51 | - [SSHPipe](https://github.com/Acrisel/sshpipe) 52 | -------------------------------------------------------------------------------- /docs/internals.md: -------------------------------------------------------------------------------- 1 | # Internals 2 | 3 | 4 | ## API 5 | 6 | The `cuisine.api` is implemented in a modular way, where specific groups 7 | of functions (eg. *file*, *dir*, *user*, etc) are implemented as separate modules. 8 | 9 | The first version of Cuisine had a single-file flat-namespace designed 10 | specifically for being compact and easily discoverable from the REPL using 11 | tab completion. We wanted to keep this discoverability with Cuisine 2, but as the number 12 | of functions grew, we decided to wrap functions in API subclassess. This makes 13 | the code more modular, while also making it possible to manage concurrent sessions 14 | with different configurations and variants. 15 | 16 | The `cuisine.api` module is able to introspect its submodules and to 17 | generate a *stub* that contains a flat-namespace version that is able 18 | to dispatch the method calls to the proper subclasses, supporting variants 19 | and options transparently. 20 | 21 | The actual cuisine API interface is defined as `cuisine.api._stub.API` class, 22 | and the implementation in `cuisine.api._impl.API` class. Both of these 23 | files can be generated using the `cuisine.api.toSource()` function, or running 24 | directly the `cuisine.api` module: 25 | 26 | ```shell 27 | # Outputs the Python source code for the API stub. 28 | python3 -m cuisine.api 29 | ``` 30 | 31 | ## API Modules 32 | 33 | As a result of this slightly unusual approach, any `cuisine.api` submodule 34 | needs to subclass `cuisine.api.APIModule` and invoke Cuisine API functions 35 | using `self.api`. 36 | 37 | Each Cuisine API module needs also to decorate its exported API methods 38 | using the `@expose` decorator: 39 | 40 | ```python 41 | class Date(APIModule): 42 | @expose 43 | @requires("date") 44 | def date_now( self ) -> str: 45 | self.api.run("data +'%Y-%M-%d'").value 46 | ``` 47 | 48 | The `@requires` decorator makes it clear that this function requires the `date` 49 | command. 50 | -------------------------------------------------------------------------------- /docs/reporting.md: -------------------------------------------------------------------------------- 1 | # Reporting 2 | 3 | Cuisine's reporting is designed for full observability of the process, supporting 4 | the following: 5 | 6 | - Each session should be able to report the actions (command run, output, errors) 7 | - Each operation in the session should detect failures, and failure should either 8 | halt the session, or issue a warning. By default, it should halt on failure. 9 | - In tracing mode, the session output is communicated back 10 | - By default, only the errors are communicated back up 11 | 12 | ## Error handling 13 | 14 | Cuisine by default won't stop when there is a command error, but will stop 15 | in the following cases: 16 | 17 | - A connection error, in which case an exception will be raised. 18 | 19 | ``` 20 | with mode_strict(): 21 | # Any error in the commands will stop the current session 22 | ``` 23 | ## Error reporting 24 | 25 | 26 | Errors are reported in a way that is both easy to read (for humans), and 27 | where structured data can be easily extracted when automating cuisine from 28 | the CLI. 29 | 30 | ```python 31 | run("tar fxz package.tar.gz") 32 | ``` 33 | 34 | will yield the following: 35 | 36 | ``` 37 | ⫼ /bin/sh: line 1: cd: /home/service/dist: No such file or directory 38 | ⫼ tar (child): package.tar.gz: Cannot open: No such file or directory 39 | ⫼ tar (child): Error is not recoverable: exiting now 40 | ⫼ tar: Child returned status 2 41 | ⫼ tar: Error is not recoverable: exiting now 42 | ``` 43 | 44 | 45 | ## Trace reporting 46 | 47 | The `enable_trace()`, `disable_trace()` and `with tracing()` functions all 48 | support granular tracing, which can also be defined using the `CUISINE_TRACING` 49 | environment variable. 50 | 51 | ```python 52 | enable_trace() 53 | run("du -hs /etc") 54 | ``` 55 | 56 | will return the command, input, output and err: 57 | 58 | ``` 59 | TRA user@localhost[local] 60 | command: du -hs /etc 61 | err: asdasdsa 62 | err: … 63 | out: asdasdsa 64 | ``` 65 | -------------------------------------------------------------------------------- /src/py/cuisine/api/process.py: -------------------------------------------------------------------------------- 1 | from ..api import APIModule 2 | from ..decorators import logged 3 | 4 | # ============================================================================= 5 | # 6 | # PROCESS OPERATIONS 7 | # 8 | # ============================================================================= 9 | 10 | 11 | @logged 12 | def process_find(name, exact=False): 13 | """Returns the pids of processes with the given name. If exact is `False` 14 | it will return the list of all processes that start with the given 15 | `name`.""" 16 | is_string = isinstance(name, str) or isinstance(name, unicode) 17 | # NOTE: ps -A seems to be the only way to not have the grep appearing 18 | # as well 19 | if is_string: 20 | processes = run("ps -A | grep {0} ; true".format(name)) 21 | else: 22 | processes = run("ps -A") 23 | res = [] 24 | for line in processes.split("\n"): 25 | if not line.strip(): 26 | continue 27 | line = RE_SPACES.split(line.strip(), 3) 28 | # 3010 pts/1 00:00:07 gunicorn 29 | # PID TTY TIME CMD 30 | # 0 1 2 3 31 | # We skip lines that are not like we expect them (sometimes error 32 | # message creep up the output) 33 | if len(line) < 4: 34 | continue 35 | pid, tty, time, command = line 36 | if is_string: 37 | if pid and ((exact and command == name) or (not exact and command.find(name) >= 0)): 38 | res.append(pid) 39 | elif name(line) and pid: 40 | res.append(pid) 41 | return res 42 | 43 | 44 | @logged 45 | def process_kill(name, signal=9, exact=False): 46 | """Kills the given processes with the given name. If exact is `False` 47 | it will return the list of all processes that start with the given 48 | `name`.""" 49 | for pid in process_find(name, exact): 50 | run("kill -s {0} {1} ; true".format(signal, pid)) 51 | -------------------------------------------------------------------------------- /src/py/cuisine/api/config.py: -------------------------------------------------------------------------------- 1 | from cuisine.api import APIModule 2 | from typing import Optional, Union 3 | from ..decorators import expose 4 | import os 5 | import re 6 | 7 | RE_INT = re.compile(r"\s*\d+\s*") 8 | 9 | 10 | class Configuration(APIModule): 11 | """Manages connections to local and remote hosts.""" 12 | 13 | @expose 14 | def config_get(self, variable: str, default: Optional[str] = None) -> Optional[str]: 15 | """Returns the given `variable` from the connection's environment, returning 16 | `default` if not found.""" 17 | return os.environ[variable] if variable in os.environ else default 18 | 19 | @expose 20 | def config_set(self, variable: str, value: str) -> str: 21 | """Sets the given `variable` in the connection's environment, returning 22 | `default` if not found.""" 23 | if isinstance(value, str): 24 | env_value = value 25 | elif isinstance(value, bool): 26 | env_value = "1" if value else "0" 27 | else: 28 | env_value = str(value, "utf") 29 | os.environ[variable] = env_value 30 | return value 31 | 32 | @expose 33 | def config_has(self, variable: str) -> bool: 34 | """Sets the given `variable` in the connection's environment, returning 35 | `default` if not found.""" 36 | return variable in os.environ 37 | 38 | @expose 39 | def config_clear(self, variable: str) -> Optional[str]: 40 | """Clears the given `variable` from connection's environment. 41 | `default` if not found.""" 42 | if variable in os.environ: 43 | value = os.environ[variable] 44 | del os.environ[variable] 45 | return value 46 | else: 47 | return None 48 | 49 | @expose 50 | def config_get_variant(self, group: str) -> Optional[str]: 51 | detector_name = f"detect_{group}" 52 | if hasattr(self.api, detector_name): 53 | return getattr(self.api, detector_name)() 54 | else: 55 | return self.config_get(f"default.{group}") 56 | 57 | # EOF 58 | -------------------------------------------------------------------------------- /docs/connections.md: -------------------------------------------------------------------------------- 1 | # Connections 2 | 3 | Cuisine uses shell commands as the main communication channel to interact 4 | with (remote) systems. It does support different types of underlying transport, 5 | primarily through SSH. While the basic requirement is to execute shell 6 | commands, a connection should ideally support 7 | uploading files to speed things up. 8 | 9 | The basic contract of a channel is like so: 10 | 11 | - `connect()/disconnect()` to open/close the connection 12 | - `run()` to run a command 13 | 14 | Then there are some additional features that can make it more efficient: 15 | 16 | - `write(path,content)` to write binary content at the given path 17 | - `upload(path,local)` to upload a file to the remote location 18 | - `cd(path)` to change the current directory. 19 | 20 | Here's an example: 21 | 22 | ```python 23 | with cuisine.connect() as c: 24 | c.run("echo 'Hello, World!'") 25 | ``` 26 | 27 | ## Default connection 28 | 29 | By default, the connection is local and any command run using Cuisine's API 30 | will be *local*, represented by the `local()` call of the API. However, as 31 | soon as `connect()` is used, the connection will be added to the *connection 32 | stack* and will become the current active connection, which you can get using 33 | `connection().` 34 | 35 | ## Multiple connections 36 | 37 | You can manage multiple connections by referencing them and using them 38 | as a prefix, as they all implement the core Cuisine API: 39 | 40 | ```python 41 | # The latest `connect` sets the latest connection 42 | server_a = connect("server-a.domain.local") 43 | assert connection() == server_a 44 | server_b = connect("server-b.domain.local") 45 | assert connection() == server_b 46 | 47 | # We can still access/interact with individual connections by referring 48 | # to them directly. 49 | assert server_a.hostname() == "server-a.domain.local" 50 | assert server_b.hostname() == "server-b.domain.local" 51 | assert hostname() == "server-b.domain.local" 52 | ``` 53 | 54 | ## Transient connections 55 | 56 | The `connect()` primitive can be used to create temporary connections 57 | by using the `with` keyword. 58 | 59 | ```python 60 | with connect("server.domain.local") as c: 61 | # The new connection will become the current connection 62 | assert connection() is c 63 | # And as soon as we're out, the local connection becomes the current 64 | # connection. 65 | assert connection() is local() 66 | ``` 67 | -------------------------------------------------------------------------------- /tests/harness.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import subprocess 4 | import shutil 5 | import time 6 | 7 | # -- 8 | # # Harness 9 | # 10 | # Harness is a very simple test runner that follows the idea of the 11 | # [Test Anything Protocol](http://testanything.org/) with a focus on nicer 12 | # looking, more parseable output. 13 | # 14 | # Note that the implementation uses binary output as we never know what the 15 | # commands might spit out. 16 | 17 | ENCODING = sys.stdout.encoding 18 | QUIET = os.getenv("QUIET", "").lower() in ("1", "true") 19 | RUNNERS = { 20 | "py": os.getenv("PYTHON", "python"), 21 | "sh": os.getenv("BASH", "bash"), 22 | } 23 | 24 | 25 | TMPDIR = os.path.abspath(".tmp") 26 | 27 | 28 | def run(path: str, out, quiet=QUIET): 29 | out(b"-- TEST ") 30 | out(bytes(path, ENCODING)) 31 | out(b"\n") 32 | now = time.time() 33 | # We create a temporary directory 34 | if not os.path.exists(TMPDIR): 35 | os.mkdir(TMPDIR) 36 | os.environ["TESTDIR"] = TMPDIR 37 | res = subprocess.run( 38 | [RUNNERS[path.rsplit(".", 1)[-1]], path], stdout=subprocess.PIPE) 39 | shutil.rmtree(TMPDIR) 40 | oks = 0 41 | fails = 0 42 | if (success := res.returncode == 0): 43 | for line in res.stdout.split(b"\n"): 44 | if line.startswith(b"!! FAIL "): 45 | fails += 1 46 | if not quiet: 47 | out(b"!! \t") 48 | elif line.startswith(b".. OK "): 49 | oks += 1 50 | if not quiet: 51 | out(b".. \t") 52 | elif not quiet: 53 | out(b"\t") 54 | if not quiet: 55 | out(line) 56 | out(b"\n") 57 | if success and not fails: 58 | out(b".. OK") 59 | else: 60 | out(b"!! FAIL") 61 | out(bytes(f" TIME {time.time() - now:0.2f}s ", ENCODING)) 62 | out(bytes(path, ENCODING)) 63 | out(b"\n") 64 | return success and not fails 65 | 66 | 67 | def run_tests(args=sys.argv[1:], out=lambda _: None): 68 | success = True 69 | now = time.time() 70 | out(bytes(f"-- TEST Harness EXPECT {len(args)}\n", ENCODING)) 71 | for path in args: 72 | success = success and run(path, out) 73 | if not success: 74 | out(b"!! FAIL ") 75 | out(bytes(path, ENCODING)) 76 | return False 77 | out(bytes(f".. OK TIME {time.time() - now:0.2f}s\n", ENCODING)) 78 | return True 79 | 80 | 81 | if __name__ == "__main__": 82 | with open("/dev/stdout", "wb") as f: 83 | sys.exit(0 if run_tests(out=f.write) else 1) 84 | 85 | # EOF 86 | -------------------------------------------------------------------------------- /src/py/cuisine/connection/parallelssh.py: -------------------------------------------------------------------------------- 1 | from ..connection import Connection, CommandOutput 2 | from typing import Optional, Generator, Any 3 | import logging 4 | 5 | try: 6 | from pssh.output import HostOutput 7 | except ImportError: 8 | HostOutput = Any 9 | 10 | 11 | # TODO: We'll need to wrap the command output and find a way to manage 12 | # iterators/streams. 13 | class ParallelSSHCommandOutput(CommandOutput): 14 | def __init__(self, command: str, out: HostOutput): 15 | super().__init__((command, -1, b"", b"")) 16 | self.psshOut: HostOutput = out 17 | 18 | 19 | # NOTE: I can't get PSSH to work with keyfiles... 20 | class ParallelSSHConnection(Connection): 21 | 22 | TYPE = "parallelssh" 23 | 24 | def init(self): 25 | try: 26 | from pssh.clients import SSHClient 27 | from pssh.exceptions import AuthenticationError 28 | except ImportError as e: 29 | logging.error( 30 | "parallel-ssh is required: run 'python -m pip install --user parallel-ssh' or pick another transport: {transport_options}" 31 | ) 32 | raise e 33 | self.SSHClient = SSHClient 34 | self.AuthenticationError = AuthenticationError 35 | self.context: Optional[SSHClient] = None 36 | 37 | def _connect(self) -> "ParallelSSHConnection": 38 | try: 39 | client = self.SSHClient( 40 | host=self.host, 41 | port=self.port, 42 | user=self.user, 43 | password=self.password, 44 | pkey=self.key, 45 | ) 46 | except self.AuthenticationError as e: 47 | logging.error(f"Cannot connect to {self.user}@{self.host}:{self.port}: {e}") 48 | raise e 49 | self.context = client 50 | return self 51 | 52 | def _run(self, command: str) -> CommandOutput: 53 | if not self.context: 54 | logging.error(f"Connection failed, cannot run: {command}") 55 | return CommandOutput.Make(command=command, status=127, out=b"", err=b"") 56 | else: 57 | # TODO: Parallel SSH outputs UTF8 and also uses generators 58 | # for stdout/stderr 59 | output = self.context.run_command(command) 60 | for host, stream in output.items(): 61 | print(host, stream) 62 | # return CommandOutput.Make( 63 | # command=command, out=out.stdout, err=out.stderr, status=out.exit_code 64 | # ) 65 | 66 | def _disconnect(self): 67 | if self.context: 68 | self.context.disconnect() 69 | self.context = None 70 | 71 | 72 | # EOF 73 | -------------------------------------------------------------------------------- /src/py/cuisine/api/dir.py: -------------------------------------------------------------------------------- 1 | from ..api import APIModule 2 | from ..decorators import logged, expose, requires 3 | from ..utils import shell_safe, quoted 4 | import os 5 | from typing import Optional 6 | 7 | 8 | class DirAPI(APIModule): 9 | 10 | @expose 11 | @logged 12 | @requires(("chmod", "chgrp", "chown")) 13 | def dir_attribs(self, path: str, mode=None, owner=None, group=None, recursive=False): 14 | """Updates the mode/owner/group for the given remote directory.""" 15 | recursive = recursive and "-R " or "" 16 | if mode: 17 | self.api.run(f"chmod {recursive} '{mode}' {quoted(path)}") 18 | if owner: 19 | self.api.run(f"chown {recursive} '{owner}' {quoted(path)}") 20 | if group: 21 | self.api.run(f"chgrp {recursive} '{group}' {quoted(path)}") 22 | 23 | @expose 24 | @requires("test") 25 | def dir_exists(self, path: str) -> bool: 26 | """Tells if there is a remote directory at the given path.""" 27 | return self.api.run(f"test -d {quoted(path)} && echo OK").is_ok 28 | 29 | @expose 30 | @logged 31 | @requires("rm") 32 | def dir_remove(self, path: str, recursive=True) -> Optional[bool]: 33 | """ Removes a directory """ 34 | flag = "r" if recursive else "" 35 | if self.api.dir_exists(path): 36 | return self.api.run(f"rm -{flag}f {quoted(path)} && echo OK").is_ok 37 | else: 38 | return None 39 | 40 | @expose 41 | def dir_ensure_parent(self, path: str, recursive=True, mode=None, owner=None, group=None): 42 | """Ensures that the parent directory of the given path exists""" 43 | self.api.dir_ensure(os.path.dirname( 44 | path), recursive=recursive, mode=mode, owner=owner, group=group) 45 | return path 46 | 47 | @expose 48 | @requires(("mkdir")) 49 | def dir_ensure(self, path: str, recursive=True, mode=None, owner=None, group=None) -> str: 50 | """Ensures that there is a remote directory at the given path, 51 | optionally updating its mode/owner/group. 52 | 53 | If we are not updating the owner/group then this can be done as a single 54 | ssh call, so use that method, otherwise set owner/group after creation.""" 55 | if not self.dir_exists(path): 56 | if not self.api.run(f"mkdir {'-p' if recursive else ''} {quoted(path)}").is_success: 57 | 58 | raise RuntimeError( 59 | f"Could not create remote directory at: {path}") 60 | if owner or group or mode: 61 | self.api.dir_attribs(path, owner=owner, group=group, 62 | mode=mode, recursive=recursive) 63 | return path 64 | 65 | # EOF 66 | -------------------------------------------------------------------------------- /src/py/cuisine/connection/local.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import threading 3 | import shutil 4 | import os 5 | from pathlib import Path 6 | from ..connection import Connection, CommandOutput 7 | from typing import List, Tuple, Callable, Optional 8 | 9 | 10 | class LocalConnection(Connection): 11 | 12 | TYPE = "local" 13 | 14 | def _connect(self): 15 | # TODO: We should detect if we need to switch user or not. 16 | pass 17 | 18 | def _disconnect(self): 19 | pass 20 | 21 | def _run(self, command: str) -> Optional[CommandOutput]: 22 | cmd = self.cd_prefix + command if self.cd_prefix else command 23 | return CommandOutput( 24 | run_local_raw(cmd, on_out=self.log.out, on_err=self.log.err) 25 | ) 26 | 27 | def _upload(self, remote: str, source: Path): 28 | try: 29 | shutil.copy2(source, remote) 30 | except Exception as e: 31 | self.log.error(f"Cannot copy {source} to {remote}: {e}") 32 | 33 | def _cd(self, path: str): 34 | try: 35 | os.chdir(path) 36 | except FileNotFoundError as e: 37 | self.log.error(f"Path does not exist: {path}") 38 | 39 | 40 | def run_local_raw( 41 | command: str, 42 | cwd=".", 43 | encoding="utf8", 44 | shell=True, 45 | on_out: Optional[Callable[[bytes], None]] = None, 46 | on_err: Optional[Callable[[bytes], None]] = None, 47 | ) -> Tuple[str, int, bytes, bytes]: 48 | """Low-level command running function. This spawns a new subprocess with 49 | two reader threads (stdout and stderr). It's fairly heavyweight but it's OK, 50 | Cuisine is about automation, not high-performance.""" 51 | process = subprocess.Popen( 52 | command, shell=shell, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd 53 | ) 54 | # NOTE: This is not ideal, but works well. 55 | # See http://stackoverflow.com/questions/15654163/how-to-capture-streaming-output-in-python-from-subprocess-communicate 56 | # At some point, we should use a single thread. 57 | out: List[bytes] = [] 58 | err: List[bytes] = [] 59 | 60 | def reader(channel, output, handler): 61 | # FIXME: This does not seem to stream, should develop a test case for that 62 | for line in channel: 63 | line = line or b"" 64 | if line and handler: 65 | handler(line) 66 | output.append(line) 67 | 68 | t0 = threading.Thread(target=lambda: reader(process.stdout, out, on_out)) 69 | t1 = threading.Thread(target=lambda: reader(process.stderr, err, on_err)) 70 | t0.start() 71 | t1.start() 72 | try: 73 | process.wait() 74 | except KeyboardInterrupt as e: 75 | pass 76 | t0.join() 77 | t1.join() 78 | # We return the result 79 | return (command, process.returncode, b"".join(out), b"".join(err)) 80 | 81 | 82 | # EOF 83 | -------------------------------------------------------------------------------- /src/py/cuisine/api/ssh.py: -------------------------------------------------------------------------------- 1 | from ..api import APIModule as API 2 | from ..decorators import logged, expose, dispatch, requires, variant 3 | from ..utils import quoted, make_options_str 4 | from typing import Optional, cast 5 | from pathlib import Path 6 | import os 7 | 8 | # -- 9 | # ## SSH API 10 | # 11 | # The SSH API makes it easy to create keys, add and remove authorized keys 12 | # for created users. 13 | 14 | 15 | class SSHAPI(API): 16 | 17 | @expose 18 | @requires("ssh-keygen") 19 | def ssh_keygen(self, user: str, keytype="rsa") -> str: 20 | """Generates a pair of ssh keys in the user's home .ssh directory.""" 21 | assert self.api.user_exists(user), "User" 22 | home = self.api.user_get(user).get("home") 23 | assert home, f"User has no home field: {user}" 24 | key_file_priv = f"{home}/.ssh/id_{keytype}" 25 | key_file_pub = f"{keyfile}.pub" 26 | if not self.api.file_exists(key_file_priv): 27 | self.api.dir_ensure( 28 | f"{home}/.ssh", mode="0700", owner=user, group=user) 29 | run("ssh-keygen -q -t {keytype} -f {quoted(key_file_priv)} -N ''") 30 | # TODO: We might want to check the mode as well 31 | file_attribs(key_file_priv, owner=user, group=user) 32 | file_attribs(key_file_pub, owner=user, group=user) 33 | return key_file_priv 34 | else: 35 | return key_file_priv 36 | 37 | @expose 38 | def ssh_authorize(self, user: str, key: Optional[str] = None) -> bool: 39 | """Adds the given key to the '.ssh/authorized_keys' for the given 40 | user.""" 41 | profile = self.api.user_get(user) 42 | group = profile["gid"] 43 | authorized_keys = f"{profile['home']}/.ssh/authorized_keys" 44 | if not key: 45 | # TODO: Should probably look for other types of keys 46 | key = Path("~/.ssh/id_rsa.pub").expanduser().read_text() 47 | key = cast(str, f"{key}\n" if not key.endswith("\n") else key) 48 | if not self.api.file_exists(authorized_keys): 49 | # Make sure that .ssh directory exists, see #42 50 | self.api.dir_ensure(os.path.dirname( 51 | authorized_keys), owner=user, group=group, mode="700") 52 | self.api.file_write(authorized_keys, key, 53 | owner=user, group=group, mode="600") 54 | return True 55 | else: 56 | text = self.api.file_read_str(authorized_keys) 57 | if not next((_ for _ in text.split("\n") if _.startswith(key)), False): 58 | self.api.file_append(authorized_keys, key, 59 | owner=user, group=group, mode="0600") 60 | return True 61 | else: 62 | return False 63 | 64 | @expose 65 | def ssh_unauthorize(self, user: str, key: str): 66 | """Removes the given key to the remote '.ssh/authorized_keys' for the given 67 | user.""" 68 | key = key.strip() 69 | profile = self.api.user_get(user) 70 | group = profile["gid"] 71 | authorized_keys = f"{profile['home']}/.ssh/authorized_keys" 72 | if self.api.file_exists(authorized_keys): 73 | lines = [_ for _ in self.api.file_read( 74 | authorized_keys).split("\n")] 75 | filtered = [_ for _ in lines if _.strip != key] 76 | if len(lines) != (filtered): 77 | # We only write the file if we changed 78 | self.api.file_write(authorized_keys, "\n".join( 79 | filtered), owner=user, group=group, mode="600") 80 | return True 81 | else: 82 | return False 83 | else: 84 | return False 85 | 86 | # EOF 87 | -------------------------------------------------------------------------------- /src/py/cuisine/api/packages_python.py: -------------------------------------------------------------------------------- 1 | from ..api import APIModule 2 | from ..decorators import dispatch, expose 3 | 4 | 5 | class PythonPackageAPI(APIModule): 6 | 7 | @expose 8 | def select_python_package(self, type: str) -> bool: 9 | return True 10 | 11 | @expose 12 | def detect_python_package(self) -> str: 13 | """Automatically detects the type of package""" 14 | return "pip" 15 | 16 | @expose 17 | @dispatch('python_package', multiple=True) 18 | def python_package_upgrade(self, package): 19 | """Upgraded the given Python package""" 20 | 21 | @expose 22 | @dispatch('python_package', multiple=True) 23 | def python_package_install(self, package=None): 24 | """Installs the given python package/list of python packages.""" 25 | 26 | @expose 27 | @dispatch('python_package', multiple=True) 28 | def python_package_ensure(self, package): 29 | """Tests if the given python package is installed, and installs it in 30 | case it's not already there.""" 31 | 32 | @expose 33 | @dispatch('python_package', multiple=True) 34 | def python_package_remove(self, package): 35 | """Removes the given python package. """ 36 | 37 | 38 | class PythonPIPPackage(APIModule): 39 | 40 | @expose 41 | def python_package_upgrade_pip(self, package=None, local=True): 42 | pip = self.api.command("pip") 43 | self.api.run( 44 | f"{pip} install {'--user' if local else ''} --upgrade {package}") 45 | 46 | @expose 47 | def python_package_install_pip(self, package=None, local=True): 48 | pip = self.api.command("pip") 49 | self.api.run( 50 | f"{pip} install {'--user' if local else ''} --upgrade {package}") 51 | 52 | @expose 53 | def python_package_ensure_pip(self, package=None, local=True): 54 | pip = self.api.command("pip") 55 | self.api.run( 56 | f"{pip} install {'--user' if local else ''} --upgrade {package}") 57 | 58 | @expose 59 | def python_package_remove_pip(self, package, local=True): 60 | pip = self.api.command("pip") 61 | self.api.run( 62 | f"${pip} install {'--user' if local else ''} --upgrade {package}") 63 | 64 | 65 | class PythonEIPackage: 66 | 67 | @expose 68 | def python_package_upgrade_easy_install(self, package): 69 | ''' 70 | The "package" argument, defines the name of the package that will be upgraded. 71 | ''' 72 | self.api.run( 73 | f"{self.api.command('easy_install')} --upgrade '{package}") 74 | 75 | @expose 76 | def python_package_install_easy_install(self, package): 77 | ''' 78 | The "package" argument, defines the name of the package that will be installed. 79 | ''' 80 | self.api.run(f"{self.api.command('easy_install')} '{package}") 81 | 82 | @expose 83 | def python_package_ensure_easy_install(self, package): 84 | ''' 85 | The "package" argument, defines the name of the package that will be ensured. 86 | ''' 87 | # FIXME: At the moment, I do not know how to check for the existence of a py package and 88 | # I am not sure if this really makes sense, based on the easy_install built in functionality. 89 | # So I just call the install functions 90 | self.python_package_install_easy_install(package) 91 | 92 | @expose 93 | def python_package_remove_easy_install(self, package): 94 | ''' 95 | The "package" argument, defines the name of the package that will be removed. 96 | ''' 97 | # FIXME: this will not remove egg file etc. 98 | self.api.run( 99 | f"{self.api.command('easy_install')} -m '{package}") 100 | 101 | # EOF 102 | -------------------------------------------------------------------------------- /src/py/cuisine/decorators.py: -------------------------------------------------------------------------------- 1 | import types 2 | import functools 3 | import inspect 4 | 5 | IS_EXPOSED = "cuisine_is_exposed" 6 | IS_VARIANT = "cuisine_is_variant" 7 | 8 | 9 | def dispatch(name: str, multiple=False): 10 | """Dispatches the current function to specific implementation. The `prefix` 11 | parameter indicates the common option prefix, and the `select_[option]()` 12 | function will determine the function suffix. 13 | 14 | For instance the package functions are defined like this: 15 | 16 | ``` 17 | @dispatch("package") 18 | def package_ensure(...): 19 | ... 20 | def package_ensure_apt(...): 21 | ... 22 | def package_ensure_yum(...): 23 | ... 24 | ``` 25 | 26 | and then when a user does 27 | 28 | ``` 29 | cuisine.select_package("yum") 30 | cuisine.package_ensure(...) 31 | ``` 32 | 33 | then the `dispatch` function will dispatch `package_ensure` to 34 | `package_ensure_yum`. 35 | 36 | If your prefix is the first word of the function name before the 37 | first `_` then you can simply use `@dispatch` without parameters. 38 | """ 39 | def dispatch_wrapper(function): 40 | def wrapper(context: 'cuisine.api.APIModule', *args, **kwargs): 41 | function_name = function.__name__ 42 | variant = context.api.config_get_variant(name) 43 | assert variant, f"No variant defined for: {name.upper()}, call select_{name.lower().replace('.','_')}(\"\") to set it" 44 | function_name = function.__name__ + "_" + variant 45 | if not hasattr(context.api, function_name): 46 | raise ValueError( 47 | f"API implementation does not define method: {function_name}") 48 | specific = getattr(context.api, function_name) 49 | if specific: 50 | if inspect.isfunction(specific) or inspect.ismethod(specific): 51 | if multiple and args and isinstance(args[0], list): 52 | rest = args[1:] 53 | return [specific(_, *rest, **kwargs) for _ in args[0]] 54 | else: 55 | return specific(*args, **kwargs) 56 | else: 57 | raise Exception(f"Function expected for: {function_name}") 58 | else: 59 | raise Exception( 60 | f"Function variant not defined: {function_name}") 61 | # We copy name and docstring 62 | functools.update_wrapper(wrapper, function) 63 | return wrapper 64 | return dispatch_wrapper 65 | 66 | 67 | def logged(message=None): 68 | """Logs the invoked function name and arguments.""" 69 | # TODO: Options - prevent sub @logged to output anything 70 | # TODO: Message - allow to specify a message 71 | # TODO: Category - read/write/exec as well as mode 72 | # [2013-10-28T10:18:32] user@host [sudo|user] [R/W] cuinine.function(xx,xxx,xx) [time] 73 | # [2013-10-28T10:18:32] user@host [sudo|user] [!] Exception 74 | def logged_wrapper(function, message=message): 75 | def wrapper(*args, **kwargs): 76 | # TODO: Defines what we do with that. 77 | # log_call(function, args, kwargs) 78 | return function(*args, **kwargs) 79 | # We copy name and docstring 80 | functools.update_wrapper(wrapper, function) 81 | return wrapper 82 | if type(message) == types.FunctionType: 83 | return logged_wrapper(message, None) 84 | else: 85 | return logged_wrapper 86 | 87 | 88 | def requires(*commands: str): 89 | """Decorator that captures requirement metdata for operations.""" 90 | # TODO: Implement that 91 | def decorator(f): 92 | return f 93 | return decorator 94 | 95 | 96 | def expose(f): 97 | setattr(f, IS_EXPOSED, True) 98 | return f 99 | 100 | 101 | def variant(name): 102 | def decorator(f): 103 | setattr(f, IS_VARIANT, name) 104 | return f 105 | return decorator 106 | 107 | # EOF 108 | -------------------------------------------------------------------------------- /src/py/cuisine/connection/mitogen.py: -------------------------------------------------------------------------------- 1 | from ..connection import Connection, CommandOutput 2 | from .. import logging 3 | from .local import run_local_raw 4 | from ..utils import quoted 5 | from pathlib import Path 6 | from typing import Optional 7 | import tempfile 8 | import threading 9 | import os 10 | import sys 11 | 12 | 13 | def file_write(content: bytes): 14 | fd, path = tempfile.mkstemp() 15 | os.write(fd, content) 16 | os.close(fd) 17 | return path 18 | 19 | 20 | class MitogenConnection(Connection): 21 | """Manages a remote connection through Mitogen. 22 | See .""" 23 | 24 | TYPE = "mitogen" 25 | ACTIVE = 0 26 | # When using automated provisionning, the servers (and keys) can change, 27 | # so it makes sense to clear the host keys. 28 | CHECK_HOST_KEYS = False 29 | 30 | # -- 31 | # The mitogen *broker* and *router* are shared across connections, and 32 | # will be recycled. 33 | 34 | BROKER = None 35 | ROUTER = None 36 | 37 | def init(self): 38 | try: 39 | import mitogen 40 | import mitogen.utils as mitogen_utils 41 | import mitogen.master as mitogen_master 42 | import mitogen.ssh as mitogen_ssh 43 | except (ImportError, ModuleNotFoundError) as e: 44 | sys.stderr.write( 45 | "[!] Mitogen is required: python -m pip install --user mitogen\n") 46 | raise e 47 | self.mitogen = mitogen 48 | self.mitogen_utils = mitogen_utils 49 | self.mitogen_master = mitogen_master 50 | self.mitogen_ssh = mitogen_ssh 51 | 52 | def _connect(self) -> 'MitogenConnection': 53 | # NOTE: Connect will update self.{host,port} 54 | # NOTE: We reuse Brokers and Routers, and also cleans them up 55 | broker = MitogenConnection.BROKER = MitogenConnection.BROKER or self.mitogen_master.Broker() 56 | router = MitogenConnection.ROUTER = MitogenConnection.ROUTER or self.mitogen_master.Router( 57 | broker) 58 | try: 59 | # NOTE: See 60 | self.context = router.ssh( 61 | hostname=self.host, 62 | username=self.user, 63 | port=self.port, 64 | identity_file=self.key, 65 | connect_timeout=self.timeout, 66 | check_host_keys="accept" if self.CHECK_HOST_KEYS else "ignore", 67 | ) 68 | MitogenConnection.ACTIVE += 1 69 | except self.mitogen_ssh.PasswordError as e: 70 | logging.fatal( 71 | f"Cannot connect to {self.user}@{self.host}:{self.port} using {self.type}: {e}") 72 | self.context = None 73 | self.is_connected = False 74 | return self 75 | return self 76 | 77 | def _write(self, path: str, content: bytes) -> CommandOutput: 78 | temp_path = self.context.call(file_write, content) 79 | command = f"touch {quoted(path)}; cp --attributes-only {quoted(temp_path)} {quoted(path)}; mv -f {quoted(temp_path)} {quoted(path)}" 80 | return self.run(command) 81 | 82 | def _cd(self, path: str): 83 | self.context.call(os.chdir, path) 84 | 85 | def _run(self, command) -> CommandOutput: 86 | if not self.context: 87 | logging.error(f"Connection failed, cannot run: {command}") 88 | return CommandOutput((command, 127, b"", b"")) 89 | else: 90 | return CommandOutput(self.context.call(run_local_raw, command)) 91 | 92 | def _disconnect(self): 93 | MitogenConnection.ACTIVE -= 1 94 | self.context.shutdown(wait=True) 95 | # We do a final shutdown when we don't have any active connection 96 | if not MitogenConnection.ACTIVE: 97 | MitogenConnection.BROKER.shutdown() 98 | MitogenConnection.ROUTER = None 99 | MitogenConnection.BROKER = None 100 | self.context = None 101 | 102 | # EOF 103 | -------------------------------------------------------------------------------- /src/py/cuisine/api/text.py: -------------------------------------------------------------------------------- 1 | # ============================================================================= 2 | # 3 | # TEXT PROCESSING 4 | # 5 | # ============================================================================= 6 | 7 | def text_detect_eol(text): 8 | # FIXME: Should look at the first line 9 | if text.find("\r\n") != -1: 10 | return WINDOWS_EOL 11 | elif text.find("\n") != -1: 12 | return UNIX_EOL 13 | elif text.find("\r") != -1: 14 | return MAC_EOL 15 | else: 16 | return "\n" 17 | 18 | 19 | def text_get_line(text, predicate): 20 | """Returns the first line that matches the given predicate.""" 21 | for line in text.split("\n"): 22 | if predicate(line): 23 | return line 24 | return "" 25 | 26 | 27 | def text_normalize(text): 28 | """Converts tabs and spaces to single space and strips the text.""" 29 | return RE_SPACES.sub(" ", text).strip() 30 | 31 | 32 | def text_nospace(text): 33 | """Converts tabs and spaces to single space and strips the text.""" 34 | return RE_SPACES.sub("", text).strip() 35 | 36 | 37 | def text_replace_line(text, old, new, find=lambda old, new: old == new, process=lambda _: _): 38 | """Replaces lines equal to 'old' with 'new', returning the new 39 | text and the count of replacements. 40 | 41 | Returns: (text, number of lines replaced) 42 | 43 | `process` is a function that will pre-process each line (you can think of 44 | it as a normalization function, by default it will return the string as-is), 45 | and `find` is the function that will compare the current line to the 46 | `old` line. 47 | 48 | The finds the line using `find(process(current_line), process(old_line))`, 49 | and if this matches, will insert the new line instead. 50 | """ 51 | res = [] 52 | replaced = 0 53 | eol = text_detect_eol(text) 54 | for line in text.split(eol): 55 | if find(process(line), process(old)): 56 | res.append(new) 57 | replaced += 1 58 | else: 59 | res.append(line) 60 | return eol.join(res), replaced 61 | 62 | 63 | def text_replace_regex(text, regex, new, **kwargs): 64 | """Replace lines that match with the regex returning the new text 65 | 66 | Returns: text 67 | 68 | `kwargs` is for the compatibility with re.sub(), 69 | then we can use flags=re.IGNORECASE there for example. 70 | """ 71 | res = [] 72 | eol = text_detect_eol(text) 73 | for line in text.split(eol): 74 | res.append(re.sub(regex, new, line, **kwargs)) 75 | return eol.join(res) 76 | 77 | 78 | def text_ensure_line(text, *lines): 79 | """Ensures that the given lines are present in the given text, 80 | otherwise appends the lines that are not already in the text at 81 | the end of it.""" 82 | eol = text_detect_eol(text) 83 | res = list(text.split(eol)) 84 | if res[0] == '' and len(res) == 1: 85 | res = list() 86 | for line in lines: 87 | assert line.find(eol) == - \ 88 | 1, "No EOL allowed in lines parameter: " + repr(line) 89 | found = False 90 | for l in res: 91 | if l == line: 92 | found = True 93 | break 94 | if not found: 95 | res.append(line) 96 | return eol.join(res) 97 | 98 | 99 | def text_strip_margin(text, margin="|"): 100 | """Will strip all the characters before the left margin identified 101 | by the `margin` character in your text. For instance 102 | 103 | ``` 104 | |Hello, world! 105 | ``` 106 | 107 | will result in 108 | 109 | ``` 110 | Hello, world! 111 | ``` 112 | """ 113 | res = [] 114 | eol = text_detect_eol(text) 115 | for line in text.split(eol): 116 | l = line.split(margin, 1) 117 | if len(l) == 2: 118 | _, line = l 119 | res.append(line) 120 | return eol.join(res) 121 | 122 | 123 | def text_template(text, variables): 124 | """Substitutes '${PLACEHOLDER}'s within the text with the 125 | corresponding values from variables.""" 126 | template = string.Template(text) 127 | return template.safe_substitute(variables) 128 | 129 | 130 | -------------------------------------------------------------------------------- /src/py/cuisine/utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Union 3 | from typing import Dict, List 4 | import os 5 | import datetime 6 | import re 7 | import random 8 | 9 | 10 | # -- 11 | # # Utilities 12 | # 13 | # This module contains functions that make it easier to work with shell data, mainly 14 | # around quoting, escaping and normalizing. 15 | 16 | # FROM: https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python 17 | # 7-bit and 8-bit C1 ANSI sequences 18 | RE_ANSI_ESCAPE_8BIT = re.compile( 19 | br'(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])' 20 | ) 21 | 22 | RE_ANSI_ESCAPE = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') 23 | 24 | 25 | def strip_ansi_bytes(data: bytes) -> bytes: 26 | return RE_ANSI_ESCAPE_8BIT.sub(b'', data) 27 | 28 | 29 | def strip_ansi(data: str) -> str: 30 | return RE_ANSI_ESCAPE.sub('', data) 31 | 32 | 33 | def shell_safe(path: Union[Path, str]) -> str: 34 | """Makes sure that the given path/string is escaped and safe for shell""" 35 | return "".join([("\\" + _) if _ in " '\";`|" else _ for _ in str(path)]) 36 | 37 | 38 | QUOTE_ESCAPE = "'\"'\"'" 39 | QUOTE = "'" 40 | 41 | 42 | def normpath(path:Union[Path,str]): 43 | """Ensures that the given value is a string, this accepts a Path""" 44 | res = str(path) if isinstance(path, Path) else path 45 | assert isinstance(res,str) 46 | return res 47 | 48 | def quotable(line: str) -> str: 49 | """Returns a string that can be used in single quotes, but won't necessariy 50 | work without quotes.""" 51 | if "'" not in line: 52 | return line 53 | else: 54 | return line.replace(QUOTE_ESCAPE, QUOTE).replace(QUOTE, QUOTE_ESCAPE) 55 | 56 | 57 | def quoted(line: Union[Path,str]) -> str: 58 | # FIXME: https://unix.stackexchange.com/questions/30903/how-to-escape-quotes-in-shell#30904 59 | # FROM: https://stackoverflow.com/questions/1250079/how-to-escape-single-quotes-within-single-quoted-strings#1250279 60 | line = normpath(line) 61 | assert isinstance(line,str) 62 | if line and line[0] == line[-1] and line[0] == "'": 63 | return line 64 | else: 65 | return f"'{quotable(line)}'" 66 | 67 | 68 | def normalize_path(path: str) -> Path: 69 | """Normalizes the given path, expanding variables and user home.""" 70 | return Path(os.path.normpath(os.path.expanduser(os.path.expandvars(path)))) 71 | 72 | 73 | def make_options_str(options: Dict[str, Union[None, str, int, bool]]) -> str: 74 | """Like `make_options`, but returning a string""" 75 | return " ".join(make_options(options)) or "" 76 | 77 | 78 | def make_options(options: Dict[str, Union[None, str, int, bool]]) -> List[str]: 79 | """Converts a dict of options to a string.""" 80 | res: List[str] = [] 81 | for k, v in options.items(): 82 | if v in (None, False): 83 | continue 84 | if v is True: 85 | res.append(k) 86 | else: 87 | res.append(f"{k}{quoted(v)}") 88 | return res 89 | 90 | 91 | def prefix_command(command: str, prefix: str) -> str: 92 | if not command.startswith(prefix): 93 | return f"{prefix} {command}" 94 | else: 95 | return command 96 | 97 | 98 | def timestamp(): 99 | """Returns the current timestamp as an ISO-8601 time 100 | ("1977-04-22T01:00:00-05:00")""" 101 | n = datetime.datetime.now() 102 | return "%04d-%02d-%02dT%02d:%02d:%02d" % ( 103 | n.year, n.month, n.day, n.hour, n.minute, n.second 104 | ) 105 | 106 | 107 | def timenum(): 108 | """Like timestamp, but just the numbers.""" 109 | n = datetime.datetime.now() 110 | return "%04d%02d%02d%02d%02d%02d%02d" % ( 111 | n.year, n.month, n.day, n.hour, n.minute, n.second, random.randint( 112 | 0, 99) 113 | ) 114 | 115 | 116 | def ssh_remove_known_host(ips: Union[str, List[str]]) -> bool: 117 | known_hosts = Path("~/.ssh/known_hosts").expanduser() 118 | all_ips = [ips] if isinstance(ips, str) else ips 119 | if known_hosts.exists(): 120 | with open(known_hosts) as f: 121 | lines = list(f.readlines()) 122 | filtered = [_ for _ in lines if _.split()[0] not in all_ips] 123 | if len(lines) != len(filtered): 124 | with open(known_hosts, "wt") as f: 125 | f.write("".join(filtered)) 126 | return True 127 | return False 128 | 129 | 130 | # EOF 131 | -------------------------------------------------------------------------------- /src/py/cuisine/api/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List, Tuple, Iterable, Callable 3 | from ..decorators import IS_EXPOSED 4 | from typing import Any 5 | try: 6 | from ._stub import API 7 | except ImportError as e: 8 | API = Any 9 | import inspect 10 | import importlib.util 11 | 12 | 13 | # In this sad world, people prefer spaces to tabs 14 | TAB = " " 15 | 16 | 17 | class APIModule: 18 | """Defines the base class for Cuisine API modules. The given API is 19 | the stub, which is then implemented by the `cuisine.api._impl.API` 20 | class.""" 21 | 22 | def __init__(self, api: API): 23 | self.api = api 24 | self.init() 25 | 26 | def init(self): 27 | pass 28 | 29 | 30 | def introspect() -> Iterable[Tuple[str, str, str, Callable]]: 31 | for child in Path(__file__).parent.iterdir(): 32 | if child.name.endswith(".py") and not child.name.startswith("_"): 33 | module_name = child.name.split('.', 1)[0] 34 | module_full_name = f"cuisine.api.{module_name}" 35 | # We need to use importlib as we don't want to change the namespace 36 | spec = importlib.util.spec_from_file_location( 37 | module_full_name, child) 38 | module = importlib.util.module_from_spec(spec) 39 | spec.loader.exec_module(module) 40 | for class_name, value in ((_, getattr(module, _)) for _ in dir(module)): 41 | if value is APIModule or not (inspect.isclass(value) and issubclass(value, APIModule)): 42 | continue 43 | for method_name, method in ((_, getattr(value, _)) for _ in dir(value) if not _.startswith("_")): 44 | if not hasattr(method, IS_EXPOSED): 45 | continue 46 | yield (module_full_name, class_name, method_name, method) 47 | 48 | 49 | PREAMBLE = [ 50 | "from typing import Tuple, List, Dict, Optional, Union, ForwardRef, ContextManager", 51 | "import cuisine.connection", 52 | "import pathlib", 53 | ] 54 | 55 | 56 | def toInterface() -> Iterable[str]: 57 | data = list(introspect()) 58 | yield from (_ for _ in PREAMBLE) 59 | yield "# NOTE: This is automatically generated by `python -m cuisine.api -t stub`, do not edit" 60 | yield f"class API:" 61 | for _, method_name, method in sorted(set((_[1], _[2], _[3]) for _ in data)): 62 | # We erase the leading type 63 | sig = inspect.signature(method) 64 | sig_str = str(sig).replace(": cuisine.api.API", "") 65 | yield f"\n{TAB}def {method_name}{sig_str}:" 66 | yield f"{TAB}{TAB}\"\"\"{method.__doc__}\"\"\"" 67 | yield f"{TAB}{TAB}raise NotImplementedError" 68 | yield "\n# EOF" 69 | 70 | 71 | def toImplementation() -> Iterable[str]: 72 | data = list(introspect()) 73 | yield "# NOTE: This is automatically generated by `python -m cuisine.api -t impl`, do not edit" 74 | yield from (_ for _ in PREAMBLE) 75 | yield "from ._stub import API as Interface" 76 | yield f"class API(Interface):" 77 | yield f"\n{TAB}def __init__(self):" 78 | for module_name in sorted(set(_[0] for _ in data)): 79 | local_name = module_name.replace(".", "_") 80 | yield f"{TAB}{TAB}import {module_name} as {local_name}" 81 | for module_name, class_name in sorted(set((_[0], _[1]) for _ in data)): 82 | local_module_name = module_name.replace(".", "_") 83 | yield f"{TAB}{TAB}self._{class_name.replace('API','').lower()} = {local_module_name}.{class_name}(self)" 84 | for class_name, method_name, method in sorted(set((_[1], _[2], _[3]) for _ in data)): 85 | # We erase the leading type 86 | sig = inspect.signature(method) 87 | sig_str = str(sig).replace(": cuisine.api.API", "") 88 | yield f"\n{TAB}def {method_name}{sig_str}:" 89 | yield f"{TAB}{TAB}\"\"\"{method.__doc__}\"\"\"" 90 | args = ", ".join([_ for _ in sig.parameters][1:]) 91 | yield f"{TAB}{TAB}return self._{class_name.replace('API', '').lower()}.{method_name}({args})" 92 | yield "\n# EOF" 93 | 94 | 95 | def toNamespace() -> Iterable[str]: 96 | data = list(introspect()) 97 | yield "# NOTE: This is automatically generated by `python -m cuisine.api -t repl`, do not edit" 98 | yield from (_ for _ in PREAMBLE) 99 | yield "from ._impl import API" 100 | yield "__API = None" 101 | yield "def default_api():" 102 | yield f"{TAB}global __API" 103 | yield f"{TAB}if not __API: __API = API()" 104 | yield f"{TAB}return __API" 105 | yield f"" 106 | for method_name, method in sorted(set((_[2], _[3]) for _ in data)): 107 | # We erase the leading type 108 | sig = inspect.signature(method) 109 | sig_str = str(sig).replace("self,", "").replace("(self)", "()") 110 | yield f"\ndef {method_name}{sig_str}:" 111 | yield f"{TAB}\"\"\"{method.__doc__}\"\"\"" 112 | args = ", ".join([_ for _ in sig.parameters][1:]) 113 | yield f"{TAB}return default_api().{method_name}({args})" 114 | yield "\n# EOF" 115 | 116 | # EOF 117 | -------------------------------------------------------------------------------- /src/py/cuisine/connection/paramiko.py: -------------------------------------------------------------------------------- 1 | from ..connection import Connection, CommandOutput 2 | from ..utils import quoted 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | # SEE: https://gist.github.com/mlafeldt/841944 7 | # NOTE: Fedora still has a bug that prevents Paramiko to work https://bugzilla.redhat.com/show_bug.cgi?id=1775693 8 | 9 | 10 | class ParamikoConnection(Connection): 11 | """Manages a remote connection through Paramiko. 12 | See """ 13 | 14 | TYPE = "paramiko" 15 | 16 | def __init__( 17 | self, 18 | host: Optional[str] = None, 19 | port: Optional[int] = None, 20 | user: Optional[str] = None, 21 | password: Optional[str] = None, 22 | key: Optional[Path] = None, 23 | ): 24 | super().__init__(user=user, host=host, port=port, password=password, key=key) 25 | # SEE: https://docs.paramiko.org/en/stable/api/client.html 26 | try: 27 | import paramiko 28 | import paramiko.sftp_client 29 | import paramiko.ssh_exception as paramiko_exceptions 30 | except ImportError as e: 31 | self.log.error( 32 | "Paramiko is required: python -m pip install --user paramiko" 33 | ) 34 | raise e 35 | self.paramiko = paramiko 36 | self.paramiko_exceptions = paramiko_exceptions 37 | self._context: Optional[paramiko.SSHClient] = None 38 | self._sftp: Optional[paramiko.sftp_client.SFTPClient] = None 39 | 40 | @property 41 | def sftp(self): 42 | if not self._sftp: 43 | if not self._context: 44 | self.log.error("Cannot create SFTP client, connection failed") 45 | return self._sftp 46 | self._sftp = self._context.open_sftp() 47 | return self._sftp 48 | 49 | def _connect(self) -> "ParamikoConnection": 50 | # SEE: https://docs.paramiko.org/en/stable/api/client.html 51 | self._context = client = self.paramiko.SSHClient() 52 | client.load_system_host_keys() 53 | client.set_missing_host_key_policy(self.paramiko.AutoAddPolicy()) 54 | try: 55 | kwargs = dict( 56 | (k, v) 57 | for k, v in dict( 58 | hostname=self.host, 59 | username=self.user, 60 | port=self.port, 61 | key_filename=str(self.key) if self.key else None, 62 | look_for_keys=True, 63 | timeout=self.timeout, 64 | ).items() 65 | if v is not None 66 | ) 67 | client.connect(**kwargs) 68 | except self.paramiko_exceptions.AuthenticationException as e: 69 | self.log.error( 70 | f"Cannot connect to {self.user}@{self.host}:{self.port} using {self.type}: {e}" 71 | ) 72 | self._context = None 73 | self.is_connected = False 74 | raise e 75 | return self 76 | return self 77 | 78 | def _run(self, command: str) -> CommandOutput: 79 | if not self._context: 80 | self.log.error(f"Connection failed, cannot run: {command}") 81 | return CommandOutput((command, 127, b"", b"")) 82 | else: 83 | cmd = f"{self.cd_prefix}{command}" if self.cd_prefix else command 84 | _, stdout, stderr = self._context.exec_command(cmd) 85 | # FIXME: We might be deadlocking here, we might want to reuse the 86 | # threaded readers. 87 | err = stderr.read() 88 | out = stdout.read() 89 | status = stdout.channel.recv_exit_status() 90 | return CommandOutput((command, status, out, err)) 91 | 92 | def _upload(self, remote: str, local: Path): 93 | if self.sftp: 94 | self.sftp.put(str(local), remote) 95 | else: 96 | raise RuntimeError("Unabled to create SFTP client") 97 | 98 | def _download(self, remote: str, local: Path): 99 | if self.sftp: 100 | self.sftp.get(remote, str(local)) 101 | else: 102 | raise RuntimeError("Unabled to create SFTP client") 103 | 104 | def _write(self, remote: str, content: bytes) -> bool: 105 | if self.sftp: 106 | with self.sftp.open(remote, "wb") as f: 107 | f.write(content) 108 | return True 109 | else: 110 | return False 111 | 112 | def _cd(self, path: str): 113 | success: bool = False 114 | if self._sftp: 115 | self._sftp.chdir(path) 116 | success = True 117 | if self._context: 118 | success = True 119 | # NOTE: There's no persistent CWD with Paramiko, so we need 120 | # to change the CWD for every command! 121 | return success 122 | 123 | def _disconnect(self) -> bool: 124 | connected: bool = False 125 | if self._sftp: 126 | self._sftp.close() 127 | self._sftp = None 128 | connected = True 129 | if self._context: 130 | self._context.close() 131 | self._context = None 132 | connected = True 133 | return connected 134 | 135 | 136 | # EOF 137 | -------------------------------------------------------------------------------- /src/py/cuisine/logging.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union, Callable, List, Any, Iterable 2 | 3 | try: 4 | from colorama import Fore, Style 5 | RED = Fore.RED 6 | GREEN = Fore.GREEN 7 | YELLOW = Fore.YELLOW 8 | BLUE = Fore.BLUE 9 | DIM = Style.DIM 10 | BRIGHT = Style.BRIGHT 11 | RESET = Style.RESET_ALL 12 | except ImportError as e: 13 | RED = "" 14 | GREEN = "" 15 | BLUE = "" 16 | YELLOW = "" 17 | DIM = "" 18 | BRIGHT = "" 19 | RESET = "" 20 | 21 | import sys 22 | import json 23 | 24 | LOGGING_BYTES = False 25 | STRINGIFY_MAXSTRING = 80 26 | STRINGIFY_MAXLISTSTRING = 20 27 | 28 | 29 | def stringify(value): 30 | """Turns the given value in a user-friendly string that can be displayed""" 31 | if type(value) in (str, bytes) and len(value) > STRINGIFY_MAXSTRING: 32 | return f"{value[0:STRINGIFY_MAXSTRING]}…" 33 | elif type(value) in (list, tuple) and len(value) > 10: 34 | return f"[{', '.join([stringify(_) for _ in value[0:STRINGIFY_MAXLISTSTRING]])},…]" 35 | else: 36 | return str(value) 37 | 38 | 39 | # TODO: We need to define how that works 40 | def log_call(function, args, kwargs): 41 | """Logs the given function call""" 42 | function_name = function.__name__ 43 | a = ", ".join([stringify(_) for _ in args] + [str(k) + 44 | "=" + stringify(v) for k, v in kwargs.items()]) 45 | log_debug("{0}({1})".format(function_name, a)) 46 | 47 | 48 | # TODO: The context should be tweakable, we should be able to apply a filter 49 | class LoggingContext: 50 | 51 | def __init__(self): 52 | self.prompt: Callable[[], str] = lambda: "" 53 | self._formatters = [Formatter.Get()] 54 | 55 | @property 56 | def formatter(self): 57 | return self._formatters[-1] 58 | 59 | def push(self, formatter: Optional['Formatter'] = None): 60 | """Used to push a new formatter onto the stack""" 61 | # FIXME: May not be the best way to do it, see TMux 62 | self._formatters.append(formatter or NullFormatter.Get()) 63 | return self 64 | 65 | def pop(self): 66 | self._formatters.pop() 67 | return self 68 | 69 | def dispatch(self, type: str, args: List[Any]): 70 | self.formatter.receive(self, type, args) 71 | 72 | def action(self, name: str, *args: str): 73 | self.dispatch("action", [name] + [_ for _ in args]) 74 | 75 | def output(self, output: "CommandOutput"): 76 | if output.status != 0: 77 | self.error(output.err) 78 | else: 79 | self.result(output.value if output else None, output.is_success) 80 | 81 | def result(self, value: Any, success=True): 82 | self.dispatch("result", [value, success]) 83 | 84 | def error(self, message: str): 85 | self.dispatch("error", [message]) 86 | 87 | def out(self, data: Union[str, bytes]): 88 | self.dispatch("out", [data]) 89 | 90 | def info(self, data: Union[str, bytes]): 91 | self.dispatch("info", [data]) 92 | 93 | def err(self, data: Union[str, bytes]): 94 | self.dispatch("err", [data]) 95 | 96 | 97 | # TODO: The formatting API is quite basic and ad-hoc for now, 98 | # should be reworked. 99 | class Formatter: 100 | 101 | SINGLETON: Optional['Formatter'] = None 102 | 103 | @classmethod 104 | def Get(cls) -> 'Formatter': 105 | if not cls.SINGLETON: 106 | cls.SINGLETON = cls() 107 | return cls.SINGLETON 108 | 109 | def __init__(self): 110 | self.active: Optional[LoggingContext] = None 111 | 112 | def write(self, line: str): 113 | sys.stdout.write(line) 114 | 115 | def receive(self, origin: LoggingContext, action: str, args: List[Any]): 116 | if origin != self.active: 117 | self.write( 118 | f"{BLUE}{DIM}═══{RESET}\t{BLUE}{origin.prompt()}{RESET}\n") 119 | self.active = origin 120 | if action == "out": 121 | self.write(DIM) 122 | self.block(args, "┆") 123 | self.write(RESET) 124 | elif action == "info": 125 | self.write(YELLOW) 126 | self.block(args, "🛈") 127 | self.write(RESET) 128 | elif action == "err": 129 | self.write(RED) 130 | self.block(args, "⫼") 131 | self.write(RESET) 132 | elif action == "action" and args[0] == "command": 133 | self.write( 134 | f"{DIM}┌─●\t{BRIGHT}{' '.join(args[1:])}{RESET}\n") 135 | elif action == "result": 136 | self.write( 137 | f"{GREEN}{DIM}└─►\t{RESET}{GREEN}{json.dumps(args[0])}{RESET}\n") 138 | elif action == "error": 139 | self.write( 140 | f"{RED}{DIM}└─✕\t{RESET}{RED}{json.dumps(args[0])}{RESET}\n") 141 | else: 142 | self.write( 143 | f"{BLUE}{DIM}▹▹▹{RESET}\t{BLUE}{' '.join(stringify(_) for _ in args)}{RESET}\n") 144 | 145 | def block(self, data: Iterable[Any], char="|"): 146 | for item in data: 147 | if isinstance(item, str): 148 | self.lines(item.split("\n"), f"{char}\t") 149 | elif isinstance(item, bytes): 150 | self.lines(str(item, "utf8").split("\n"), f"{char}\t") 151 | else: 152 | self.write(f"{char}\t{repr(item)}\n") 153 | 154 | def lines(self, lines: List[str], prefix=""): 155 | last = len(lines) - 1 156 | for i, line in enumerate(lines): 157 | if i == last and not line.strip(): 158 | continue 159 | print(f"{prefix}{line}") 160 | 161 | 162 | class NullFormatter(Formatter): 163 | 164 | SINGLETON: Optional['Formatter'] = None 165 | 166 | def write(self, line: str): 167 | pass 168 | 169 | def receive(self, origin: LoggingContext, action: str, args: List[Any]): 170 | pass 171 | 172 | # EOF 173 | -------------------------------------------------------------------------------- /src/py/cuisine/api/user.py: -------------------------------------------------------------------------------- 1 | from ..api import APIModule as API 2 | from ..utils import quoted, make_options_str 3 | from ..decorators import logged, expose, dispatch, requires, variant 4 | from typing import Optional, Dict 5 | import base64 6 | 7 | 8 | # TODO: User_list 9 | class UserAPI(API): 10 | 11 | @expose 12 | def detect_user(self) -> str: 13 | # TODO: Detects the variant 14 | return "linux" 15 | 16 | @expose 17 | @dispatch("user") 18 | def user_passwd(self, name: str, passwd: str, encrypted_passwd=True): 19 | """Sets the given user password. Password is expected to be encrypted by default.""" 20 | 21 | @expose 22 | @dispatch("user") 23 | def user_create(self, name: str, passwd: Optional[str] = None, 24 | home: Optional[str] = None, uid: Optional[int] = None, gid: Optional[int] = None, 25 | shell: Optional[str] = None, uid_min: Optional[int] = None, uid_max: Optional[int] = None, 26 | encrypted_passwd: Optional[bool] = True, 27 | fullname: Optional[str] = None, create_home: Optional[bool] = True): 28 | """Creates the user with the given name, optionally giving a 29 | specific password/home/uid/gid/shell.""" 30 | 31 | @expose 32 | @dispatch("user") 33 | def user_get(self, name: Optional[str] = None, uid: Optional[int] = None) -> Dict: 34 | """Checks if there is a user defined with the given name, 35 | returning its information as a 36 | '{"name":,"uid":,"gid":,"home":,"shell":}' 37 | or 'None' if the user does not exists. 38 | need_passwd (Boolean) indicates if password to be included in result or not. 39 | If set to True it parses 'getent shadow' and needs sudo access 40 | """ 41 | 42 | @expose 43 | @dispatch("user") 44 | def user_ensure(self, name: str, passwd: Optional[str] = None, 45 | home: Optional[str] = None, uid: Optional[int] = None, gid: Optional[int] = None, 46 | shell: Optional[str] = None, uid_min: Optional[int] = None, uid_max: Optional[int] = None, 47 | encrypted_passwd: Optional[bool] = True, 48 | fullname: Optional[str] = None, create_home: Optional[bool] = True): 49 | """Ensures that the given users exists, optionally updating their 50 | passwd/home/uid/gid/shell.""" 51 | 52 | @expose 53 | @dispatch("user") 54 | def user_exists(self, name: str) -> bool: 55 | """Tells if the user exists.""" 56 | 57 | @expose 58 | @dispatch("user") 59 | def user_remove(self, name: str, remove_home: bool = False): 60 | """Removes the user with the given name, optionally 61 | removing the home directory and mail spool.""" 62 | 63 | # -- 64 | # ## Linux User API 65 | # 66 | # This implements the user functions for Linux. 67 | 68 | 69 | class LinuxUserAPI(API): 70 | 71 | @expose 72 | @variant("linux") 73 | @requires("usermod", "openssl", "chpasswd") 74 | def user_passwd_linux(self, name: str, passwd: str, encrypted_passwd=True): 75 | """Sets the given user password. Password is expected to be encrypted by default.""" 76 | encoded_password = base64.b64encode(bytes(f"{name}:{passwd}", "utf8")) 77 | if encrypted_passwd: 78 | self.api.sudo( 79 | f"usermod -p {quoted(passwd)} {quoted(name)}") 80 | else: 81 | # NOTE: We use base64 here in case the password contains special chars 82 | # TODO: Make sure this openssl command works everywhere, maybe we should use a text_base64_decode? 83 | self.api.sudo( 84 | f"echo {quoted(encoded_password)} | openssl base64 -A -d | chpasswd") 85 | 86 | @expose 87 | @variant("linux") 88 | @requires("useradd") 89 | def user_create_linux(self, name: str, passwd: Optional[str] = None, 90 | home: Optional[str] = None, uid: Optional[int] = None, gid: Optional[int] = None, 91 | shell: Optional[str] = None, uid_min: Optional[int] = None, uid_max: Optional[int] = None, 92 | encrypted_passwd: Optional[bool] = True, 93 | fullname: Optional[str] = None, create_home: Optional[bool] = True): 94 | options = make_options_str({ 95 | "-d ": home, 96 | "-u ": uid, 97 | # TODO: We don't have groups updated yet 98 | # "-g ": gid if gid != None else name if self.api.group_exists(name) else None, 99 | "-g ": gid, 100 | "-s ": shell, 101 | "-K UID_MIN=": uid_min, 102 | "-K UID_MAX=": uid_max, 103 | "-c ": fullname, 104 | "-m": create_home, 105 | }) 106 | self.api.sudo(f"useradd {options} {quoted(name)}") 107 | if passwd: 108 | self.api.user_passwd(name=name, passwd=passwd, 109 | encrypted_passwd=encrypted_passwd) 110 | 111 | @expose 112 | @variant("linux") 113 | @requires("getent") 114 | def user_get_linux(self, name: str = None, uid: int = None): 115 | assert name != None or uid != None, "user_check: either `uid` or `name` should be given" 116 | assert name is None or uid is None, "user_check: `uid` and `name` both given, only one should be provided" 117 | for line in self.api.run("getent passwd").lines: 118 | fields = line.split(":") 119 | if len(fields) < 7: 120 | continue 121 | user_name, _, user_id, gid, fullname, home, shell = fields[0:7] 122 | if user_name == name or user_id == uid: 123 | return dict(name=user_name, uid=user_id, gid=gid, fullname=fullname, home=home, shell=shell) 124 | return None 125 | 126 | @expose 127 | @variant("linux") 128 | @requires("usermod") 129 | def user_ensure_linux(self, name: str, passwd: Optional[str] = None, 130 | home: Optional[str] = None, uid: Optional[int] = None, gid: Optional[int] = None, 131 | shell: Optional[str] = None, uid_min: Optional[int] = None, uid_max: Optional[int] = None, 132 | encrypted_passwd: Optional[bool] = True, 133 | fullname: Optional[str] = None, create_home: Optional[bool] = True): 134 | if not self.api.user_exists(name): 135 | self.api.user_create(name, passwd, home, uid, gid, shell, 136 | fullname=fullname, encrypted_passwd=encrypted_passwd) 137 | else: 138 | profile = self.api.user_get(name) 139 | options = make_options_str({ 140 | "-d ": home if profile.get("home") != home else None, 141 | "-u ": uid if profile.get("uid") != uid else None, 142 | "-g ": gid if profile.get("gid") != gid else None, 143 | "-s ": shell if profile.get("shell") != shell else None, 144 | "-c ": fullname if profile.get("fullname") != fullname else None, 145 | }) 146 | if options: 147 | self.api.sudo(f"usermod {options} {quoted(name)}") 148 | if passwd: 149 | self.api.user_passwd(name=name, passwd=passwd, 150 | encrypted_passwd=encrypted_passwd) 151 | 152 | @expose 153 | @variant("linux") 154 | @requires("id") 155 | def user_exists_linux(self, name: str) -> bool: 156 | return self.api.run(f"id -u {quoted(name)}").is_success 157 | 158 | @expose 159 | @variant("linux") 160 | @requires("userdel") 161 | def user_remove_linux(self, name: str, remove_home: bool = False): 162 | """Removes the user with the given name, optionally 163 | removing the home directory and mail spool.""" 164 | options = "-rf" if remove_home else "-f" 165 | self.api.sudo(f"userdel {options} {quoted(name)}") 166 | 167 | 168 | # EOF 169 | -------------------------------------------------------------------------------- /src/py/cuisine/api/connection.py: -------------------------------------------------------------------------------- 1 | from cuisine.connection.paramiko import ParamikoConnection 2 | from cuisine.connection.mitogen import MitogenConnection 3 | from cuisine.connection.tmux import TmuxConnection 4 | from cuisine.connection.parallelssh import ParallelSSHConnection 5 | from ..connection import CommandOutput, Connection 6 | from ..connection.local import LocalConnection 7 | from ..api import APIModule 8 | from ..decorators import expose, dispatch, variant 9 | from ..utils import normpath 10 | from typing import Optional, ContextManager, Union, List 11 | from pathlib import Path 12 | 13 | 14 | # -- 15 | # We keep a global counter of active connections, which is mainly useful 16 | # for debuggin. 17 | ACTIVE_CONNECTIONS = 0 18 | 19 | 20 | class ConnectionContext(ContextManager): 21 | """Automatically disconnects a connection path to where it was.""" 22 | 23 | def __init__(self, connection: "Connection"): 24 | self.connection = connection 25 | self.has_entered = False 26 | 27 | def __enter__(self): 28 | self.has_entered = True 29 | # TODO: We could return a Cuisine API scoped at the given connection 30 | # instead. 31 | return self.connection 32 | 33 | def __exit__(self, type, value, traceback): 34 | if self.has_entered: 35 | self.connection.disconnect() 36 | 37 | 38 | class Connection(APIModule): 39 | """Manages connections to local and remote hosts.""" 40 | 41 | def init(self): 42 | # We the default connection is local 43 | self.__connections: List[Connection] = [LocalConnection()] 44 | 45 | def register_connection(self, connection: Connection) -> ContextManager: 46 | """Registers the connection, connects it and returns a context 47 | manager that will disconnect from it on exit. This is an internal 48 | method used by the `connect_*` methods.""" 49 | global ACTIVE_CONNECTIONS 50 | ACTIVE_CONNECTIONS += 1 51 | self.__connections.append(connection) 52 | connection.on_disconnect = lambda _: self.clean_connections(_) 53 | connection.connect() 54 | return ConnectionContext(connection) 55 | 56 | def clean_connections(self, connection: Optional[Connection] = None): 57 | """Cleans the connections, removing the ones that are disconnected""" 58 | n = len(self.__connections) 59 | if connection: 60 | self.__connections = [_ for _ in self.__connections if _ is not connection] 61 | else: 62 | self.__connections = [_ for _ in self.__connections if _.is_connected] 63 | global ACTIVE_CONNECTIONS 64 | ACTIVE_CONNECTIONS -= n - len(self.__connections) 65 | 66 | @property 67 | def _connection(self) -> Connection: 68 | return self.__connections[-1] 69 | 70 | @expose 71 | def fail(self, message: Optional[str] = None): 72 | self._connection.log.error(f"Failure: {message}") 73 | 74 | @expose 75 | def connection(self) -> Connection: 76 | """Returns the current connection""" 77 | return self._connection 78 | 79 | @expose 80 | def connection_like(self, predicate) -> Optional[Connection]: 81 | """Returns the most recent opened connection that matches the given 82 | predicate.""" 83 | for i in reversed(range(len(self.__connections))): 84 | c = self.__connections[i] 85 | if predicate(c): 86 | return c 87 | 88 | @expose 89 | def is_local(self) -> bool: 90 | """Tells if the current connection is local or not.""" 91 | return isinstance(self._connection, LocalConnection) 92 | 93 | @expose 94 | def detect_connection(self) -> str: 95 | """Detects the recommended type of connection""" 96 | return "mitogen" 97 | 98 | @expose 99 | def select_connection(self, type: str) -> bool: 100 | """Selects the default type of connection. This returns `False` in case 101 | the connection is not found.""" 102 | return True 103 | 104 | @expose 105 | def connect( 106 | self, 107 | host=None, 108 | port=None, 109 | user=None, 110 | password=None, 111 | key: Union[str, Path] = None, 112 | transport: Optional[str] = None, 113 | ) -> ContextManager: 114 | """Connects to the given host/port using the given user/password/key_path credentials. Note that 115 | not all connection types support all these arguments, so you might get warnings if they are 116 | not supported.""" 117 | transport = ( 118 | transport or self.api.detect_connection() if host or port else "local" 119 | ) 120 | if transport == "local": 121 | assert not user, "Local user change is not supported yet" 122 | return ConnectionContext(self.connection()) 123 | else: 124 | connection_creator = getattr(self.api, f"connect_{transport}") 125 | if not connection_creator: 126 | raise RuntimeError(f"Connection type not supported: {transport}") 127 | else: 128 | return connection_creator( 129 | host=host, port=port, user=user, password=password, key=key 130 | ) 131 | 132 | @expose 133 | def disconnect(self) -> Optional[Connection]: 134 | """Disconnects from the current connection unless it'"s the default 135 | local connection.""" 136 | global ACTIVE_CONNECTIONS 137 | if len(self.__connections) > 1: 138 | conn = self.__connections.pop() 139 | ACTIVE_CONNECTIONS -= 1 140 | conn.disconnect() 141 | return conn 142 | else: 143 | return None 144 | 145 | @expose 146 | def terminate(self) -> List[Connection]: 147 | """Terminates/disconnects any remaining connection""" 148 | res = [] 149 | while connection := self.disconnect(): 150 | res.append(connection) 151 | return res 152 | 153 | @expose 154 | @variant("local") 155 | def connect_local(self, user=None) -> ContextManager: 156 | return self.register_connection(LocalConnection(user=user)) 157 | 158 | @expose 159 | @variant("paramiko") 160 | def connect_paramiko( 161 | self, host=None, port=None, user=None, password=None, key: Optional[Path] = None 162 | ) -> ContextManager: 163 | return self.register_connection( 164 | ParamikoConnection( 165 | host=host, port=port, user=user, password=password, key=key 166 | ) 167 | ) 168 | 169 | @expose 170 | @variant("mitogen") 171 | def connect_mitogen( 172 | self, host=None, port=None, user=None, password=None, key: Optional[Path] = None 173 | ) -> ContextManager: 174 | return self.register_connection( 175 | MitogenConnection( 176 | host=host, port=port, user=user, password=password, key=key 177 | ) 178 | ) 179 | 180 | @expose 181 | @variant("parallelssh") 182 | def connect_parallelssh( 183 | self, host=None, port=None, user=None, password=None, key: Optional[Path] = None 184 | ) -> ContextManager: 185 | return self.register_connection( 186 | ParallelSSHConnection( 187 | host=host, port=port, user=user, password=password, key=key 188 | ) 189 | ) 190 | 191 | @expose 192 | def connect_tmux(self, session: str, window: str) -> ContextManager: 193 | """Creates a new connection using the TmuxConnection""" 194 | return self.register_connection( 195 | TmuxConnection(self._connection, session, window) 196 | ) 197 | 198 | @expose 199 | def run(self, command: str) -> "CommandOutput": 200 | return self._connection.run(command) 201 | 202 | @expose 203 | def run_local(self, command: str) -> "CommandOutput": 204 | local_connection = self.__connections[0] 205 | assert isinstance(local_connection, LocalConnection) 206 | return local_connection.run(command) 207 | 208 | @expose 209 | def sudo( 210 | self, command: Optional[str] = None 211 | ) -> Union[ContextManager, "CommandOutput"]: 212 | return self._connection.sudo(command) 213 | 214 | @expose 215 | def cd(self, path: str) -> ContextManager: 216 | """Changes the current connection path, returning a context that can be 217 | used like so: 218 | 219 | ```python 220 | cd("~") 221 | with cd("/etc"): 222 | run("ls -l") 223 | # Current path will be "~" 224 | ``` 225 | """ 226 | return self._connection.cd(normpath(path)) 227 | 228 | 229 | # EOF 230 | -------------------------------------------------------------------------------- /src/py/cuisine/api/group.py: -------------------------------------------------------------------------------- 1 | from ..decorators import dispatch 2 | 3 | # ============================================================================= 4 | # 5 | # GROUP OPERATIONS 6 | # 7 | # ============================================================================= 8 | 9 | 10 | @dispatch('group') 11 | def group_create(name, gid=None): 12 | """Creates a group with the given name, and optionally given gid.""" 13 | 14 | 15 | @dispatch('group') 16 | def group_check(name): 17 | """Checks if there is a group defined with the given name, 18 | returning its information as a 19 | '{"name":,"gid":,"members":}' or 'None' if 20 | the group does not exists.""" 21 | 22 | 23 | @dispatch('group') 24 | def group_ensure(name, gid=None): 25 | """Ensures that the group with the given name (and optional gid) 26 | exists.""" 27 | 28 | 29 | @dispatch('group') 30 | def group_user_check(group, user): 31 | """Checks if the given user is a member of the given group. It 32 | will return 'False' if the group does not exist.""" 33 | 34 | 35 | @dispatch('group') 36 | def group_user_add(group, user): 37 | """Adds the given user/list of users to the given group/groups.""" 38 | 39 | 40 | @dispatch('group') 41 | def group_user_ensure(group, user): 42 | """Ensure that a given user is a member of a given group.""" 43 | 44 | 45 | @dispatch('group') 46 | def group_user_del(group, user): 47 | """remove the given user from the given group.""" 48 | 49 | 50 | @dispatch('group') 51 | def group_remove(group=None, wipe=False): 52 | """ Removes the given group, this implies to take members out the group 53 | if there are any. If wipe=True and the group is a primary one, 54 | deletes its user as well. 55 | """ 56 | 57 | # Linux support 58 | # 59 | # ============================================================================= 60 | 61 | 62 | def group_create_linux(name, gid=None): 63 | """Creates a group with the given name, and optionally given gid.""" 64 | options = [] 65 | if gid: 66 | options.append("-g '%s'" % (gid)) 67 | sudo("groupadd %s '%s'" % (" ".join(options), name)) 68 | 69 | 70 | def group_check_linux(name): 71 | """Checks if there is a group defined with the given name, 72 | returning its information as: 73 | '{"name":,"gid":,"members":}' 74 | or 75 | '{"name":,"gid":}' if the group has no members 76 | or 77 | 'None' if the group does not exists.""" 78 | group_data = run("getent group | egrep '^%s:' ; true" % (name)) 79 | if len(group_data.split(":")) == 4: 80 | name, _, gid, members = group_data.split(":", 4) 81 | return dict(name=name, gid=gid, 82 | members=tuple(m.strip() for m in members.split(","))) 83 | elif len(group_data.split(":")) == 3: 84 | name, _, gid = group_data.split(":", 3) 85 | return dict(name=name, gid=gid, members=('')) 86 | else: 87 | return None 88 | 89 | 90 | def group_ensure_linux(name, gid=None): 91 | """Ensures that the group with the given name (and optional gid) 92 | exists.""" 93 | d = group_check(name) 94 | if not d: 95 | group_create(name, gid) 96 | else: 97 | if gid != None and d.get("gid") != gid: 98 | sudo("groupmod -g %s '%s'" % (gid, name)) 99 | 100 | 101 | def group_user_check_linux(group, user): 102 | """Checks if the given user is a member of the given group. It 103 | will return 'False' if the group does not exist.""" 104 | d = group_check(group) 105 | if d is None: 106 | return False 107 | else: 108 | return user in d["members"] 109 | 110 | 111 | def group_user_add_linux(group, user): 112 | """Adds the given user/list of users to the given group/groups.""" 113 | assert group_check(group), "Group does not exist: %s" % (group) 114 | if not group_user_check(group, user): 115 | sudo("usermod -a -G '%s' '%s'" % (group, user)) 116 | 117 | 118 | def group_user_ensure_linux(group, user): 119 | """Ensure that a given user is a member of a given group.""" 120 | d = group_check(group) 121 | if not d: 122 | group_ensure("group") 123 | d = group_check(group) 124 | if user not in d["members"]: 125 | group_user_add(group, user) 126 | 127 | 128 | def group_user_del_linux(group, user): 129 | """remove the given user from the given group.""" 130 | assert group_check(group), "Group does not exist: %s" % (group) 131 | if group_user_check(group, user): 132 | group_for_user = run( 133 | "getent group | egrep -v '^%s:' | grep '%s' | awk -F':' '{print $1}' | grep -v %s; true" % (group, user, user)).splitlines() 134 | if group_for_user: 135 | sudo("usermod -G '%s' '%s'" % (",".join(group_for_user), user)) 136 | else: 137 | sudo("usermod -G '' '%s'" % (user)) 138 | 139 | 140 | def group_remove_linux(group=None, wipe=False): 141 | """ Removes the given group, this implies to take members out the group 142 | if there are any. If wipe=True and the group is a primary one, 143 | deletes its user as well. 144 | """ 145 | assert group_check(group), "Group does not exist: %s" % (group) 146 | members_of_group = run("getent group %s | awk -F':' '{print $4}'" % group) 147 | members = members_of_group.split(",") 148 | is_primary_group = user_check(name=group) 149 | if wipe: 150 | if len(members_of_group): 151 | for user in members: 152 | group_user_del(group, user) 153 | if is_primary_group: 154 | user_remove(group) 155 | else: 156 | sudo("groupdel %s" % group) 157 | elif not is_primary_group: 158 | if len(members_of_group): 159 | for user in members: 160 | group_user_del(group, user) 161 | sudo("groupdel %s" % group) 162 | 163 | 164 | # ============================================================================= 165 | # 166 | # BSD support 167 | # 168 | # ============================================================================= 169 | 170 | def group_create_bsd(name, gid=None): 171 | """Creates a group with the given name, and optionally given gid.""" 172 | options = [] 173 | if gid: 174 | options.append("-g '%s'" % (gid)) 175 | sudo("pw groupadd %s -n %s" % (" ".join(options), name)) 176 | 177 | 178 | def group_check_bsd(name): 179 | """Checks if there is a group defined with the given name, 180 | returning its information as: 181 | '{"name":,"gid":,"members":}' 182 | or 183 | '{"name":,"gid":}' if the group has no members 184 | or 185 | 'None' if the group does not exists.""" 186 | group_data = run("getent group | egrep '^%s:' ; true" % (name)) 187 | if len(group_data.split(":")) == 4: 188 | name, _, gid, members = group_data.split(":", 4) 189 | return dict(name=name, gid=gid, 190 | members=tuple(m.strip() for m in members.split(","))) 191 | elif len(group_data.split(":")) == 3: 192 | name, _, gid = group_data.split(":", 3) 193 | return dict(name=name, gid=gid, members=('')) 194 | else: 195 | return None 196 | 197 | 198 | def group_ensure_bsd(name, gid=None): 199 | """Ensures that the group with the given name (and optional gid) 200 | exists.""" 201 | d = group_check(name) 202 | if not d: 203 | group_create(name, gid) 204 | else: 205 | if gid != None and d.get("gid") != gid: 206 | sudo("pw groupmod -g %s -n %s" % (gid, name)) 207 | 208 | 209 | def group_user_check_bsd(group, user): 210 | """Checks if the given user is a member of the given group. It 211 | will return 'False' if the group does not exist.""" 212 | d = group_check(group) 213 | if d is None: 214 | return False 215 | else: 216 | return user in d["members"] 217 | 218 | 219 | def group_user_add_bsd(group, user): 220 | """Adds the given user/list of users to the given group/groups.""" 221 | assert group_check(group), "Group does not exist: %s" % (group) 222 | if not group_user_check(group, user): 223 | sudo("pw usermod '%s' -G '%s'" % (user, group)) 224 | 225 | 226 | def group_user_ensure_bsd(group, user): 227 | """Ensure that a given user is a member of a given group.""" 228 | d = group_check(group) 229 | if not d: 230 | group_ensure("group") 231 | d = group_check(group) 232 | if user not in d["members"]: 233 | group_user_add(group, user) 234 | 235 | 236 | def group_user_del_bsd(group, user): 237 | """remove the given user from the given group.""" 238 | assert group_check(group), "Group does not exist: %s" % (group) 239 | if group_user_check(group, user): 240 | group_for_user = run( 241 | "getent group | egrep -v '^%s:' | grep '%s' | awk -F':' '{print $1}' | grep -v %s; true" % (group, user, user)).splitlines() 242 | if group_for_user: 243 | sudo("pw usermod -G '%s' '%s'" % (",".join(group_for_user), user)) 244 | else: 245 | sudo("pw usermod -G '' '%s'" % (user)) 246 | 247 | 248 | def group_remove_bsd(group=None, wipe=False): 249 | """ Removes the given group, this implies to take members out the group 250 | if there are any. If wipe=True and the group is a primary one, 251 | deletes its user as well. 252 | """ 253 | assert group_check(group), "Group does not exist: %s" % (group) 254 | members_of_group = run("getent group %s | awk -F':' '{print $4}'" % group) 255 | members = members_of_group.split(",") 256 | is_primary_group = user_check(name=group) 257 | 258 | if wipe: 259 | if len(members_of_group): 260 | for user in members: 261 | group_user_del(group, user) 262 | if is_primary_group: 263 | user_remove(group) 264 | else: 265 | sudo("pw groupdel %s" % group) 266 | 267 | elif not is_primary_group: 268 | if len(members_of_group): 269 | for user in members: 270 | group_user_del(group, user) 271 | sudo("pw groupdel %s" % group) 272 | 273 | -------------------------------------------------------------------------------- /tests/linux/all.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | import hashlib 4 | import cuisine 5 | 6 | import tempfile 7 | 8 | USER = os.popen("whoami").read()[:-1] 9 | 10 | 11 | class Text(unittest.TestCase): 12 | 13 | def testEnsureLine(self): 14 | some_text = "foo" 15 | some_text = cuisine.text_ensure_line(some_text, "bar") 16 | assert some_text == 'foo\nbar' 17 | some_text = cuisine.text_ensure_line(some_text, "bar") 18 | assert some_text == 'foo\nbar' 19 | 20 | 21 | class Users(unittest.TestCase): 22 | 23 | def testUserCheck(self): 24 | user_data = cuisine.user_get(USER) 25 | assert user_data 26 | assert user_data["name"] == USER 27 | assert user_data == cuisine.user_get(name=USER) 28 | # We ensure that user_get works with uid and name 29 | assert cuisine.user_get(uid=user_data["uid"]) 30 | assert cuisine.user_get(uid=user_data["uid"])[ 31 | "name"] == user_data["name"] 32 | 33 | def testUserCheckNeedPasswd(self): 34 | user_data = cuisine.user_get(USER, need_passwd=False) 35 | user_data_with_passwd = cuisine.user_get(name=USER) 36 | assert user_data 37 | assert user_data["name"] == USER 38 | assert 'passwd' in user_data_with_passwd 39 | assert 'passwd' not in user_data 40 | # We ensure that user_get works with uid and name 41 | assert cuisine.user_get(uid=user_data["uid"], need_passwd=False) 42 | assert cuisine.user_get(uid=user_data["uid"], need_passwd=False)[ 43 | "name"] == user_data["name"] 44 | 45 | 46 | class Modes(unittest.TestCase): 47 | 48 | def testModeLocal(self): 49 | # We switch to remote and switch back to local 50 | assert cuisine.mode(cuisine.MODE_LOCAL) 51 | cuisine.mode_remote() 52 | assert not cuisine.mode(cuisine.MODE_LOCAL) 53 | cuisine.mode_local() 54 | assert cuisine.mode(cuisine.MODE_LOCAL) 55 | # We use the mode changer to switch to remote temporarily 56 | with cuisine.mode_remote(): 57 | assert not cuisine.mode(cuisine.MODE_LOCAL) 58 | assert cuisine.mode(cuisine.MODE_LOCAL) 59 | # We go into local from local 60 | with cuisine.mode_local(): 61 | assert cuisine.mode(cuisine.MODE_LOCAL) 62 | 63 | def testModeSudo(self): 64 | assert not cuisine.mode(cuisine.MODE_SUDO) 65 | cuisine.mode_sudo() 66 | assert cuisine.mode(cuisine.MODE_SUDO) 67 | cuisine.mode_user() 68 | assert not cuisine.mode(cuisine.MODE_SUDO) 69 | # We use the mode changer to switch to sudo temporarily 70 | with cuisine.mode_sudo(): 71 | assert cuisine.mode(cuisine.MODE_SUDO) 72 | assert cuisine.mode(cuisine.MODE_LOCAL) 73 | # We go into sudo from sudo 74 | with cuisine.mode_sudo(): 75 | assert cuisine.mode(cuisine.MODE_SUDO) 76 | 77 | 78 | # NOTE: Test disabled for now 79 | # def testSudoApplication( self ): 80 | # tmpdir = tempfile.mkdtemp() 81 | # try: 82 | # with cd(tmpdir), cuisine.mode_sudo(): 83 | # cuisine.run('echo "test" > test.txt') 84 | # cuisine.run('chmod 0600 test.txt') 85 | # 86 | # with cd(tmpdir), cuisine.mode_user(), settings(warn_only=True): 87 | # listing = cuisine.run('ls -la test.txt').split() 88 | # self.assertEqual('root', listing[2]) # user 89 | # self.assertEqual('root', listing[3]) # group 90 | # result = cuisine.run('cat test.txt') 91 | # self.assertTrue(result.failed) 92 | # self.assertIn('Permission denied', result) 93 | # finally: 94 | # shutil.rmtree(tmpdir) 95 | 96 | # NOTE: Test disabled for now 97 | # class LocalExecution(unittest.TestCase): 98 | # 99 | # def testFabricLocalCommands( self ): 100 | # ''' 101 | # Make sure local and lcd still work properly and that run and cd 102 | # in local mode don't interfere. 103 | # ''' 104 | # tmpdir = tempfile.mkdtemp() 105 | # try: 106 | # dir1 = os.path.join(tmpdir, 'test1') 107 | # dir2 = os.path.join(tmpdir, 'test2') 108 | # [os.mkdir(d) for d in [dir1, dir2]] 109 | # 110 | # with cd(dir1), fabric.api.lcd(dir2): 111 | # file1 = os.path.join(dir1, 'test1.txt') 112 | # cuisine.run('touch %s' % file1) 113 | # 114 | # file2 = os.path.join(dir2, 'test2.txt') 115 | # fabric.api.local('touch %s' % file2) 116 | # 117 | # self.assertTrue(cuisine.file_exists(file2)) 118 | # finally: 119 | # shutil.rmtree(tmpdir) 120 | # 121 | # 122 | # def testResultAttributes( self ): 123 | # failing_command = 'cat /etc/shadow' # insufficient permissions 124 | # succeeding_command = 'uname -a' 125 | # erroneous_command = 'this-command-does-not-exist -a' 126 | # 127 | # # A successful command should have the appropriate status 128 | # # attributes set 129 | # result = cuisine.run(succeeding_command) 130 | # self.assertTrue(result.succeeded) 131 | # self.assertFalse(result.failed) 132 | # self.assertEqual(result.return_code, 0) 133 | # 134 | # # With warn_only set, we should be able to examine the result 135 | # # even if it fails 136 | # with settings(warn_only=True): 137 | # # command should fail with output to stderr 138 | # result = cuisine.run(failing_command, combine_stderr=False) 139 | # self.assertTrue(result.failed) 140 | # self.assertFalse(result.succeeded) 141 | # self.assertEqual(result.return_code, 1) 142 | # self.assertIsNotNone(result.stderr) 143 | # self.assertIn('Permission denied', result.stderr) 144 | # 145 | # # With warn_only off, failure should cause execution to abort 146 | # with settings(warn_only=False): 147 | # with self.assertRaises(SystemExit): 148 | # cuisine.run(failing_command) 149 | # 150 | # # An erroneoneous command should fail similarly to fabric 151 | # with settings(warn_only=True): 152 | # result = cuisine.run(erroneous_command) 153 | # self.assertTrue(result.failed) 154 | # self.assertEqual(result.return_code, 127) 155 | # 156 | # def testCd( self ): 157 | # with cd('/tmp'): 158 | # self.assertEqual(cuisine.run('pwd'), '/tmp') 159 | # 160 | # def testShell( self ): 161 | # # Ensure that env.shell is respected by setting it to the 162 | # # 'exit' command and testing that it aborts. 163 | # with settings(use_shell=True, shell='exit'): 164 | # with self.assertRaises(SystemExit): 165 | # cuisine.run('ls') 166 | # 167 | # def testSudoPrefix( self ): 168 | # # Ensure that env.sudo_prefix is respected by setting it to 169 | # # echo the command to stdout rather than executing it 170 | # with settings(use_shell=True, sudo_prefix="echo %s"): 171 | # cmd = 'ls -la' 172 | # run_result = cuisine.run(cmd) 173 | # sudo_result = cuisine.sudo(cmd) 174 | # self.assertNotEqual(run_result.stdout, sudo_result.stdout) 175 | # self.assertIn(env.shell, sudo_result) 176 | # self.assertIn(cmd, sudo_result) 177 | # 178 | # def testPath( self ): 179 | # # Make sure the path is applied properly by setting it empty 180 | # # and making sure that stops a simple command from running 181 | # self.assertTrue(cuisine.run('ls').succeeded) 182 | # 183 | # with fabric.api.path(' ', behavior='replace'), settings(warn_only=True): 184 | # result = cuisine.run('ls', combine_stderr=False) 185 | # self.assertTrue(result.failed) 186 | # self.assertIn("command not found", result.stderr) 187 | 188 | 189 | class Files(unittest.TestCase): 190 | 191 | def testRead(self): 192 | cuisine.file_read("/etc/passwd") 193 | 194 | def testWrite(self): 195 | content = "Hello World!" 196 | path = "/tmp/cuisine.test" 197 | cuisine.file_write(path, content, check=False) 198 | assert os.path.exists(path) 199 | with file(path) as f: 200 | assert f.read() == content 201 | os.unlink(path) 202 | 203 | def testSHA1(self): 204 | content = "Hello World!" 205 | path = "/tmp/cuisine.test" 206 | cuisine.file_write(path, content, check=False) 207 | sig = cuisine.file_sha256(path) 208 | with file(path) as f: 209 | file_sig = hashlib.sha256(f.read()).hexdigest() 210 | assert sig == file_sig 211 | 212 | def testExists(self): 213 | try: 214 | fd, path = tempfile.mkstemp() 215 | f = os.fdopen(fd, 'w') 216 | f.write('Hello World!') 217 | f.close() 218 | assert cuisine.file_exists(path) 219 | finally: 220 | os.unlink(path) 221 | 222 | def attribs(self): 223 | cuisine.file_write("/tmp/cuisine.attribs.text") 224 | cuisine.file_attribs("/tmp/cuisine.attribs.text", "777") 225 | cuisine.file_unlink("/tmp/cuisine.attribs.text") 226 | 227 | 228 | class Packages(unittest.TestCase): 229 | 230 | def testInstall(self): 231 | with cuisine.mode_sudo(): 232 | cuisine.package_ensure("tree") 233 | cuisine.package_ensure("tree htop") 234 | cuisine.package_ensure(["tree", "htop"]) 235 | self.assertTrue(cuisine.run("tree --version").startswith("tree ")) 236 | 237 | 238 | class SSHKeys(unittest.TestCase): 239 | 240 | key = "ssh-dss XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX= user@cuisine""" 241 | 242 | def testKeygen(self): 243 | pass 244 | # if cuisine.ssh_keygen(USER): 245 | # print "SSH keys already there" 246 | # else: 247 | # print "SSH keys created" 248 | 249 | def testAuthorize(self): 250 | cuisine.ssh_authorize(USER, self.key) 251 | d = cuisine.user_get(USER, need_passwd=False) 252 | keyf = d["home"] + "/.ssh/authorized_keys" 253 | keys = [line.strip() for line in open(keyf)] 254 | assert keys.count(self.key) == 1 255 | 256 | def testUnauthorize(self): 257 | cuisine.ssh_unauthorize(USER, self.key) 258 | d = cuisine.user_get(USER, need_passwd=False) 259 | keyf = d["home"] + "/.ssh/authorized_keys" 260 | keys = [line.strip() for line in open(keyf)] 261 | assert keys.count(self.key) == 0 262 | 263 | 264 | if __name__ == "__main__": 265 | # We bypass fabric as we want the tests to be run locally 266 | cuisine.mode_local() 267 | unittest.main() 268 | 269 | # EOF 270 | -------------------------------------------------------------------------------- /src/py/cuisine/connection/tmux.py: -------------------------------------------------------------------------------- 1 | from ..utils import quoted, timenum 2 | from typing import Optional 3 | from pathlib import Path 4 | import time 5 | import re 6 | 7 | from ..connection import Connection, CommandOutput 8 | 9 | 10 | # SEE: https://gist.github.com/henrik/1967800 11 | RE_TMUX_FIELDS = re.compile(r"\[[^\]]+\]|\([^\)]+\)|[^ ]+") 12 | 13 | 14 | class TmuxConnection(Connection): 15 | def __init__(self, connection: Connection, session: str, window: int = 0): 16 | super().__init__() 17 | self._connection = connection 18 | self.session: str = session 19 | self.window: int = window 20 | self.tmux = Tmux(connection) 21 | 22 | def prompt(self): 23 | return f"{self._connection.prompt()}[tmux:{self.session}:{self.window}]" 24 | 25 | def _connect(self): 26 | if not self._connection.is_connected: 27 | self._connection._connect() 28 | 29 | def _disconnect(self): 30 | # We don't need to do anything specific there. 31 | pass 32 | 33 | def _run(self, command: str) -> Optional[CommandOutput]: 34 | cmd = self.cd_prefix + command if self.cd_prefix else command 35 | success, out = self.tmux.run(self.session, self.window, cmd) 36 | return CommandOutput( 37 | (command, 0 if success else 1, bytes(out or "", "utf8"), b"") 38 | ) 39 | 40 | def _upload(self, remote: str, source: Path): 41 | return self._connection._upload(remote, source) 42 | 43 | def _cd(self, path: str): 44 | self.tmux.run(self.session, self.window, f"cd {quoted(path)}") 45 | 46 | 47 | # TODO: We might want to change that so that an instance is not required, and 48 | # we just have flat class methods. 49 | class Tmux: 50 | """A simple wrapper around the `tmux` terminal multiplexer that allows to 51 | create sessions and windows and execute arbitrary code in it. 52 | This is particularly useful if you want to run command on remote servers 53 | but still want easy access to their detailed output/interact with them.""" 54 | 55 | def __init__(self, connection: Connection): 56 | """The Tmux wrapper takes a regular connection""" 57 | assert not isinstance( 58 | connection, TmuxConnection 59 | ), "TMux cannot use a TMux connection" 60 | self.connection = connection 61 | 62 | def command(self, command: str, silent=True) -> str: 63 | """Executes the given Tmux command, given directly to the connection 64 | as arguments to `tmux …`.""" 65 | cmd = f"tmux {command}" 66 | if silent: 67 | self.connection.log.push(None) 68 | res = self.connection.run(cmd) 69 | if silent: 70 | self.connection.log.pop() 71 | if res and res.is_success: 72 | return str(res.out_nocolor) 73 | else: 74 | self.connection.log.error( 75 | f"Could not run Tmux command '{command}' through connection '{self.connection.prompt()}'" 76 | ) 77 | return "" 78 | 79 | def session_list(self) -> list[str]: 80 | """Returns the list of sessions""" 81 | sessions = self.command("list-session").split("\n") 82 | return [_.split(":", 1)[0] for _ in sessions if _] 83 | 84 | def session_ensure(self, session: str) -> bool: 85 | """Ensures that the given session exists.""" 86 | sessions = self.session_list() 87 | if session not in sessions: 88 | # NOTE: If we use bash, we may get weird stuff like 89 | # `Install package 'python3-argcomplete' to provide command 'register-python-argcomplete'? [N/y]` 90 | # on some systems. 91 | # TODO: We should define PS1 92 | self.command( 93 | f"new-session -d -s {session} /bin/sh \\; set default-shell /bin/sh" 94 | ) 95 | return False 96 | else: 97 | return True 98 | 99 | def session_has(self, session: str) -> bool: 100 | """Tells if the given session exists or not.""" 101 | return session in self.session_list() 102 | 103 | def window_list(self, session: str) -> list[int]: 104 | """Retuns the list of windows in the given session""" 105 | if not self.session_has(session): 106 | return [] 107 | windows = filter( 108 | lambda _: _, self.command(f"list-windows -t {session}").split("\n") 109 | ) 110 | res = [] 111 | # OUTPUT is like: 112 | # 1: ONE- (1 panes) [122x45] [layout bffe,122x45,0,0,1] @1 113 | # 2: ONE* (1 panes) [122x45] [layout bfff,122x45,0,0,2] @2 (active) 114 | # 2: service@ip-172-31-15-180:~/dist* (1 panes) [80x23] [layout ae5f,80x23,0,0,2] @2 (active) 115 | for line in windows: 116 | fields = [_.group() for _ in RE_TMUX_FIELDS.finditer(line)] 117 | if len(fields) >= 2: 118 | index = int(fields[0][:-1]) 119 | # TODO: If we want, we could use the name 120 | # name = (fields[1][:-1] if fields[1][-1] 121 | # in "*-" else fields[1]).split("@", 1)[0].split(":", 1)[0] 122 | res.append(index) 123 | return res 124 | 125 | def window_get(self, session: str, window: int) -> list[str]: 126 | if not self.session_has(session): 127 | return [] 128 | return self.window_list(session) 129 | 130 | def window_has(self, session: str, window: int) -> bool: 131 | return ( 132 | bool(self.window_get(session, window)) 133 | if self.session_has(session) 134 | else False 135 | ) 136 | 137 | def window_ensure(self, session: str, window: int) -> bool: 138 | self.session_ensure(session) 139 | if not self.window_get(session, window): 140 | self.command( 141 | f"set-option -g allow-rename off \\; new-window -t {session} -n {window} \\; set-window -g automatic-rename off " 142 | ) 143 | 144 | return False 145 | else: 146 | return True 147 | 148 | def session_kill(self, session: str) -> bool: 149 | if not self.session_has(session): 150 | return False 151 | res = False 152 | for window in self.window_list(session): 153 | self.window_kill(session, window) 154 | res = True 155 | return res 156 | 157 | def window_kill(self, session: str, window: int) -> bool: 158 | if not self.session_has(session): 159 | return False 160 | res = False 161 | for window in self.window_get(session, window): 162 | self.command(f"kill-window -t {session}:{i}") 163 | res = True 164 | return res 165 | 166 | def read(self, session: str, window: int) -> str: 167 | """Reads from the given session and window""" 168 | return self.command( 169 | f"capture-pane -t {session}:{window} \\; save-buffer -", silent=True 170 | ) 171 | 172 | def write(self, session: str, window: int, commands: str): 173 | self.command(f"send-keys -t {session}:{window} {quoted(commands)}") 174 | self.command(f"send-keys -t {session}:{window} C-m") 175 | 176 | def halt(self, session: str, window: int): 177 | """Sends a `Ctrl-c` keystroke in this session.""" 178 | self.command(f"send-keys -t {session}:{window} C-c") 179 | 180 | def run( 181 | self, session: str, window: int, command: str, timeout=2, resolution=0.1 182 | ) -> tuple[bool, str]: 183 | """This function allows to run a command and retrieve its output 184 | as given by the shell. It is quite error prone, as it will include 185 | your prompt styling and will only poll the output at `resolution` seconds 186 | interval.""" 187 | self.window_ensure(session, window) 188 | delimiter = f"CMD_{timenum()}" 189 | output = None 190 | found = False 191 | start_delimiter = f"START_{delimiter}" 192 | ok_delimiter = f"OK_{delimiter}" 193 | end_delimiter = f"END_{delimiter}" 194 | # First, we need to clear any weird manipulation of the shell prompt. 195 | self.write(session, window, "\nexport PS1='> '") 196 | # NOTE: First, we're wrapping the expression in a new shell context, and 197 | # we're also adding an OK delimiter to make sure we determine if the 198 | # command succeeded or not. 199 | tmux_command = f"echo {start_delimiter};({command}) && echo {ok_delimiter}; echo {end_delimiter};" 200 | self.write(session, window, tmux_command) 201 | # TODO: This should be a new thread 202 | result: list[str] = [] 203 | is_success = False 204 | has_finished = False 205 | iterations = int(timeout / resolution) 206 | for _ in range(iterations): 207 | # FIXME: We should find a better way to capture TMux's output. Either 208 | # we're detecting the new lines (starting from the bottom) and adding 209 | # them, or we find some other way to do that. 210 | output = self.read(session, window) 211 | has_data = False 212 | block = [] 213 | for i, line in enumerate(output.split("\n")): 214 | if line.startswith(start_delimiter): 215 | has_data = True 216 | elif line.startswith(ok_delimiter): 217 | is_success = True 218 | has_finished = True 219 | break 220 | elif line.startswith(end_delimiter): 221 | has_finished = True 222 | break 223 | elif has_data: 224 | block.append(line) 225 | result = block 226 | if has_finished: 227 | break 228 | else: 229 | time.sleep(resolution) 230 | # The command output will be conveniently placed after the `echo 231 | # CMD_XXX` and before the output `CMD_XXX`. We use negative indexes 232 | # to avoid access problems when the program's output is too long. 233 | return is_success, "\n".join(result) 234 | # return output.rsplit(delimiter, 2)[-2].split("\n", 1)[-1] if found else None 235 | 236 | def is_responsive( 237 | self, session: str, window: int, timeout: int = 1, resolution: float = 0.1 238 | ) -> Optional[bool]: 239 | """Tells if the given session/window is responsive, returning None if the session does not 240 | exist.""" 241 | if self.session_has(session) and self.window_has(session, window): 242 | # Is the terminal responsive? 243 | key = f"TMUX_ACTION_CHECK_{timenum()}" 244 | self.write(session, window, "echo " + key) 245 | key = "\n" + key 246 | for _ in range(int(timeout / resolution)): 247 | text = self.read(session, window) 248 | is_responsive = text.find(key) != -1 249 | if not is_responsive: 250 | time.sleep(resolution) 251 | else: 252 | return True 253 | return False 254 | else: 255 | return None 256 | 257 | 258 | # EOF 259 | -------------------------------------------------------------------------------- /src/py/cuisine/connection/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import os, inspect 3 | from typing import Optional, Any, Iterable, Union, ContextManager 4 | from ..utils import shell_safe, strip_ansi, quoted 5 | from .. import logging 6 | 7 | # ============================================================================= 8 | # 9 | # COMMAND OUTPUT 10 | # 11 | # ============================================================================= 12 | 13 | 14 | class CommandOutput(str): 15 | """Wraps the result of a command output, this is the standard object 16 | that you will be getting as a result of running commands. It has 17 | the following fields: 18 | 19 | - `out`, the output stream 20 | - `err`, the output stream 21 | - `status`, the command status 22 | - `command`, the original command 23 | """ 24 | 25 | STATUS_SUCCESS: tuple[int] = (0,) 26 | 27 | @classmethod 28 | def Make( 29 | cls, *, command: str, status: int, out: bytes, err: bytes 30 | ) -> "CommandOutput": 31 | return cls((command, status, out, err)) 32 | 33 | # I'm not sure how that even works, as we're not initializing self with 34 | # `out`, but it still does work. 35 | def __init__(self, res: tuple[str, int, bytes, bytes]): 36 | str.__init__(self) 37 | command, status, out, err = res 38 | self.command: str = command 39 | self.status: int = status 40 | self._out: bytes = out 41 | self._err: bytes = err 42 | self._outStr: Optional[str] = None 43 | self._errStr: Optional[str] = None 44 | self.encoding = "utf8" 45 | self._value: Any = None 46 | 47 | @property 48 | def out(self) -> str: 49 | if self._outStr is None: 50 | if inspect.isgenerator(self._out): 51 | self._outStr = "".join(_ for _ in self._out) 52 | else: 53 | self._outStr = str(self._out, self.encoding) 54 | return self._outStr 55 | 56 | @property 57 | def out_nocolor(self) -> str: 58 | return strip_ansi(self.out) 59 | 60 | @property 61 | def err(self) -> str: 62 | if self._errStr is None: 63 | if inspect.isgenerator(self._err): 64 | self._errStr = "".join(_ for _ in self._err) 65 | else: 66 | self._errStr = str(self._err, self.encoding) 67 | return self._errStr 68 | 69 | @property 70 | def err_nocolor(self) -> str: 71 | return strip_ansi(self.err) 72 | 73 | @property 74 | def out_bytes(self) -> bytes: 75 | return self._out 76 | 77 | @property 78 | def err_bytes(self) -> bytes: 79 | return self._err 80 | 81 | @property 82 | def checked_value(self) -> Any: 83 | if not self.is_success: 84 | raise RuntimeError( 85 | f"Command failed with status {self.status}: {self.command}" 86 | ) 87 | else: 88 | return self.value 89 | 90 | @property 91 | def value(self) -> Any: 92 | if self._value is None: 93 | self._value = self.last_line 94 | return self._value 95 | 96 | @property 97 | def is_ok(self) -> Any: 98 | """Checks if the value ends with 'OK', which is a convenience function 99 | for the pattern where the command ends in `&& echo OK; true`.""" 100 | return self.value.endswith("OK") 101 | 102 | @property 103 | def lines(self) -> Iterable[str]: 104 | return self.out.split("\n") 105 | 106 | @property 107 | def has_value(self) -> bool: 108 | return bool(self.value) 109 | 110 | @property 111 | def last_line(self) -> str: 112 | """Returns the last line, stripping the trailing EOL""" 113 | i = self.out.rfind("\n", 0, -2) 114 | return (self.out if i == -1 else self.out[i + 1 :]).rstrip("\n") 115 | 116 | @property 117 | def is_success(self) -> bool: 118 | """Returns true if the command status is one of `STATUS_SUCCESS`""" 119 | return self.status in self.STATUS_SUCCESS 120 | 121 | @property 122 | def has_failed(self) -> bool: 123 | return not self.is_success 124 | 125 | def __str__(self) -> str: 126 | # FIXME: This might not be OK for all commands 127 | return str(self.last_line) 128 | 129 | def __repr__(self) -> str: 130 | return f"Command: {self.command}\nstatus: {self.status}\nout: {repr(logging.stringify(self.out))}\nerr: {repr(logging.stringify(self.err))}" 131 | 132 | 133 | # ============================================================================= 134 | # 135 | # CURRENT PATH 136 | # 137 | # ============================================================================= 138 | 139 | 140 | class CurrentPathContext(ContextManager): 141 | """A helper object returned by `cd` that will return the connection's 142 | path to where it was.""" 143 | 144 | def __init__(self, connection: "Connection", path: Optional[str] = None): 145 | """On exit, the given connection will be cd'ed to the given path. If no path 146 | is given ,then the connection's current path will be used.""" 147 | self.path = path or connection.path 148 | self.connection = connection 149 | 150 | def __enter__(self): 151 | pass 152 | 153 | def __exit__(self, type, value, traceback): 154 | # We don't do anything recursive 155 | if self.path and self.connection.path != self.path: 156 | self.connection._cd(self.path) 157 | 158 | 159 | class SudoContext(ContextManager): 160 | """A helper object that will temporarily set the connection to sudo.""" 161 | 162 | def __init__(self, connection: "Connection"): 163 | self.is_sudo = connection.is_sudo 164 | self.connection = connection 165 | 166 | def __enter__(self): 167 | self.connection.is_sudo = True 168 | 169 | def __exit__(self, type, value, traceback): 170 | self.connection.is_sudo = self.is_sudo 171 | 172 | 173 | # ============================================================================= 174 | # 175 | # CONNECTION 176 | # 177 | # ============================================================================= 178 | 179 | 180 | class Connection: 181 | """Abstract implementation of a remote SSH connection. Connections are 182 | created with credentials, and then we can connect to and disconnect from 183 | the connections. Commands can be run as strings, and return 184 | a `CommandOutput` object.""" 185 | 186 | TYPE = "unknown" 187 | 188 | def __init__( 189 | self, 190 | host: Optional[str] = None, 191 | port: Optional[int] = None, 192 | user: Optional[str] = None, 193 | password: Optional[str] = None, 194 | key: Optional[Path] = None, 195 | ): 196 | self.key = ( 197 | Path(os.path.normpath(os.path.expanduser(os.path.expandvars(key)))) 198 | if key 199 | else None 200 | ) 201 | self.password: Optional[str] = password 202 | self.user: Optional[str] = user 203 | self.host: Optional[str] = host 204 | self.port: Optional[int] = port 205 | self.timeout: int = 5 206 | self.is_sudo = False 207 | self.is_connected = False 208 | self.type = self.TYPE 209 | self._path: Optional[str] = None 210 | self.cd_prefix: Optional[str] = None 211 | self.log = logging.LoggingContext() 212 | self.log.prompt = self.prompt 213 | self.on_disconnect = None 214 | self.init() 215 | 216 | def init(self): 217 | pass 218 | 219 | def prompt(self): 220 | res: list[str] = [] 221 | if self.type: 222 | res.append(f"{self.type}://") 223 | if self.user: 224 | res.append(f"{self.user}@") 225 | if self.host: 226 | res.append(self.host) 227 | if self.port: 228 | res.append(f":{self.port}") 229 | if self.path: 230 | res.append(f":{self.path}") 231 | return "".join(res) 232 | 233 | @property 234 | def path(self) -> Optional[str]: 235 | return self._path 236 | 237 | @path.setter 238 | def path(self, value: str): 239 | self._path = value 240 | # We store the cd_prefix as we need to prefix commands with 241 | # a directory. 242 | self.cd_prefix = f"cd '{shell_safe(value)}';" if value else "" 243 | 244 | def connect( 245 | self, 246 | host: Optional[str] = None, 247 | port: Optional[int] = None, 248 | user: Optional[str] = None, 249 | password: Optional[str] = None, 250 | key: Optional[Path] = None, 251 | ): 252 | assert not self.is_connected, "Connection already made, call 'disconnect' first" 253 | self.host = host or self.host 254 | self.port = port or self.port 255 | self.user = user or self.user 256 | self.password = password or self.password 257 | self.cd_prefix = None 258 | self.key = password or self.key 259 | self.log.action("connect") 260 | self._connect() 261 | return self 262 | 263 | def reconnect( 264 | self, user: Optional[str], host: Optional[str], port: Optional[int] 265 | ) -> "Connection": 266 | self.disconnect() 267 | self.user = user or self.user 268 | host = host or self.host or "localhost" 269 | port = port or self.port or 22 270 | self.log.action("reconnect") 271 | return self.connect(host, port) 272 | 273 | def run(self, command: str) -> Optional[CommandOutput]: 274 | if self.is_sudo: 275 | return self.sudo(command) 276 | else: 277 | self.log.action("command", command) 278 | res = self._run(command) 279 | self.log.output(res) 280 | return res 281 | 282 | def sudo( 283 | self, command: Optional[str] = None 284 | ) -> Union[ContextManager, Optional[CommandOutput]]: 285 | if not command: 286 | return SudoContext(self) 287 | else: 288 | self.log.action("command.sudo", command) 289 | res = self._sudo(command) 290 | self.log.output(res) 291 | return res 292 | 293 | def cd(self, path: str) -> CurrentPathContext: 294 | context = CurrentPathContext(self, self.path) 295 | self.path = path 296 | self._cd(path) 297 | return context 298 | 299 | def upload(self, remote: str, local: str) -> bool: 300 | """Copies from the local file to the remote path""" 301 | self.log.action("upload", remote, local) 302 | local_path = Path( 303 | os.path.normpath(os.path.expanduser(os.path.expandvars(local))) 304 | ) 305 | if not local_path.exists(): 306 | raise ValueError(f"Local path does not exists: '{local}'") 307 | self._upload(remote, local_path) 308 | return True 309 | 310 | def download(self, remote: str, local: str) -> bool: 311 | """Copies from the remote file to the local path""" 312 | self.log.action("download", remote, local) 313 | local_path = Path( 314 | os.path.normpath(os.path.expanduser(os.path.expandvars(local))) 315 | ) 316 | if not (parent := local_path.parent).exists(): 317 | raise ValueError(f"Local path does not exists: '{parent}'") 318 | self._download(remote, local_path) 319 | return True 320 | 321 | def write(self, remote: str, content: bytes) -> bool: 322 | self.log.action("write", remote, str(len(content))) 323 | return self._write(remote, content) 324 | 325 | def disconnect(self) -> bool: 326 | self.log.action("disconnect") 327 | self._disconnect() 328 | if self.on_disconnect: 329 | self.on_disconnect(self) 330 | return self.is_connected 331 | 332 | def _connect(self): 333 | raise NotImplementedError 334 | 335 | def _disconnect(self): 336 | raise NotImplementedError 337 | 338 | def _write(self, path: str, content: bytes): 339 | raise NotImplementedError 340 | 341 | # FIXME: Arg #1 should be command 342 | def _run(self, path: str) -> Optional[CommandOutput]: 343 | raise NotImplementedError 344 | 345 | def _sudo(self, command: str) -> Optional[CommandOutput]: 346 | # NOTE: We need to wrap that in a shell 347 | return self._run(f"sudo sh -c {quoted(command)}") 348 | 349 | def _upload(self, remote: str, source: Path): 350 | with open(source, "rb") as f: 351 | self._write(remote, f.read()) 352 | 353 | def _download(self, remote: str, source: Path): 354 | raise NotImplementedError 355 | 356 | def _cd(self, path: str): 357 | raise NotImplementedError 358 | 359 | 360 | # EOF 361 | -------------------------------------------------------------------------------- /src/py/cuisine/api/file.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import base64 3 | import tempfile 4 | import hashlib 5 | import os 6 | import stat 7 | from pathlib import Path 8 | from ..api import APIModule as API 9 | from ..decorators import logged, expose, requires 10 | from ..utils import shell_safe, quoted 11 | from typing import Dict, Union, Optional 12 | 13 | 14 | class FileAPI(API): 15 | @expose 16 | def file_name(self, path: str) -> str: 17 | """Returns the file name for the given path.""" 18 | return os.path.basename(path) 19 | 20 | @expose 21 | @logged 22 | @requires("cp") 23 | def file_backup(self, path: str, suffix=".orig", once=False): 24 | """Backups the file at the given path in the same directory, appending 25 | the given suffix. If `once` is True, then the backup will be skipped if 26 | there is already a backup file.""" 27 | backup_path = path + suffix 28 | if once and self.file_exists(backup_path): 29 | return False 30 | else: 31 | return self.api.run( 32 | "cp -a {0} {1}".format(shell_safe(path), shell_safe(backup_path)) 33 | ) 34 | 35 | @expose 36 | @logged 37 | def file_read(self, path: str) -> bytes: 38 | """Reads the *remote* file at the given path, if default is not `None`, 39 | default will be returned if the file does not exist.""" 40 | # NOTE: We use base64 here to be sure to preserve the encoding (UNIX/DOC/MAC) of EOLs 41 | if not self.file_exists(path): 42 | return b"" 43 | else: 44 | data = self.file_base64(path) 45 | if not data: 46 | return b"" 47 | else: 48 | return base64.b64decode(data) 49 | 50 | @expose 51 | @logged 52 | def file_read_str(self, path: str) -> str: 53 | try: 54 | return str(self.api.file_read(path), "utf8") 55 | except UnicodeDecodeError as e: 56 | raise RuntimeError(f"Cannot decode contents of file '{path}': {e}") 57 | 58 | @expose 59 | def file_exists(self, path: str) -> bool: 60 | """Tests if there is a *remote* file at the given path.""" 61 | return self.api.run(f"test -e {quoted(path)} && echo OK").is_ok 62 | 63 | @expose 64 | def file_is_file(self, path: str): 65 | """Tells if the given path is a file or not""" 66 | return self.api.run(f"test -f '{shell_safe(path)}' && echo OK; true").is_ok 67 | 68 | @expose 69 | def file_is_dir(self, path: str) -> bool: 70 | """Tells if the given path is a directory or not""" 71 | return self.api.run(f"test -d '{shell_safe(path)}' && echo OK ; true").is_ok 72 | 73 | @expose 74 | def file_is_link(self, path: str) -> bool: 75 | """Tells if the given path is a symlink or not""" 76 | return self.api.run(f"test -L '{shell_safe(path)}' && echo OK ; true").is_ok 77 | 78 | @logged 79 | @expose 80 | def file_attribs(self, path: str, mode=None, owner=None, group=None): 81 | """Updates the mode/owner/group for the remote file at the given 82 | path.""" 83 | return self.api.dir_attribs(path, mode, owner, group, False) 84 | 85 | @expose 86 | @logged 87 | @requires("stat") 88 | def file_attribs_get(self, path: str) -> Dict[str, str]: 89 | """Return mode, owner, and group for remote path. 90 | Return mode, owner, and group if remote path exists, 'None' 91 | otherwise. 92 | """ 93 | if self.file_exists(self, path): 94 | fs_check = self.api.run( 95 | "stat %s %s" % (shell_safe(path), '--format="%a %U %G"') 96 | ) 97 | (mode, owner, group) = fs_check.split(" ") 98 | return {"mode": mode, "owner": owner, "group": group} 99 | else: 100 | return None 101 | 102 | @expose 103 | @logged 104 | def file_write( 105 | self, 106 | path: str, 107 | content: Union[str, bytes], 108 | mode=None, 109 | owner=None, 110 | group=None, 111 | sudo=None, 112 | check=True, 113 | scp=False, 114 | ): 115 | """Writes the given content to the file at the given remote 116 | path, optionally setting mode/owner/group.""" 117 | # FIXME: Big files are never transferred properly! 118 | # Gets the content signature and write it to a secure tempfile 119 | bytes_content = ( 120 | content if isinstance(content, bytes) else bytes(content, "utf8") 121 | ) 122 | if os.path.dirname(path): 123 | self.api.dir_ensure(os.path.dirname(path)) 124 | sig = hashlib.md5(bytes_content).hexdigest() 125 | fd, local_path = tempfile.mkstemp() 126 | os.write(fd, bytes_content) 127 | # Upload the content if necessary 128 | remote_sig = self.file_md5(path) 129 | if sig != remote_sig: 130 | self.api.connection().write(path, bytes_content) 131 | # Remove the local temp file 132 | os.fsync(fd) 133 | os.close(fd) 134 | os.unlink(local_path) 135 | # Ensures that the signature matches 136 | if check: 137 | file_sig = self.file_md5(path) 138 | assert ( 139 | sig == file_sig 140 | ), f"File content does not matches file: {path}, got {file_sig}, expects {sig}" 141 | return self.file_attribs(path, mode=mode, owner=owner, group=group) 142 | 143 | @expose 144 | @logged 145 | def file_ensure(self, path, mode=None, owner=None, group=None, scp=False): 146 | """Updates the mode/owner/group for the remote file at the given 147 | path.""" 148 | if self.file_exists(path): 149 | self.file_attribs(path, mode=mode, owner=owner, group=group) 150 | else: 151 | self.file_write(path, "", mode=mode, owner=owner, group=group, scp=scp) 152 | 153 | @expose 154 | @logged 155 | def file_ensure_lines( 156 | self, 157 | path: str, 158 | lines: list[str], 159 | mode=None, 160 | owner=None, 161 | group=None, 162 | ): 163 | """Updates the mode/owner/group for the remote file at the given 164 | path.""" 165 | file_lines: list[str] = str(self.api.file_read(path), "utf8").split("\n") 166 | changed = False 167 | for line in lines: 168 | if line not in file_lines: 169 | file_lines.append(line) 170 | changed = True 171 | if changed: 172 | self.api.file_write( 173 | path, "\n".join(file_lines), mode=mode, owner=owner, group=group 174 | ) 175 | return True 176 | 177 | def file_is_same(self, local: str, remote: str) -> bool: 178 | """Tells if the local and remote file have the same content. Both 179 | file must exist and have the same signature.""" 180 | if not os.path.exists(local): 181 | return False 182 | if not self.api.file_exists(remote): 183 | return False 184 | with open(local, "rb") as f: 185 | content = f.read() 186 | return self.file_md5(remote) == hashlib.md5(content).hexdigest() 187 | 188 | @expose 189 | @logged 190 | def file_upload( 191 | self, 192 | local: str, 193 | remote: str, 194 | mode: Optional[str] = None, 195 | owner: Optional[str] = None, 196 | group: Optional[str] = None, 197 | ): 198 | """Downloads the local file to the remote path only if the remote path does not 199 | exists or the content are different.""" 200 | # FIXME: Big files are never transferred properly! 201 | assert os.path.exists( 202 | local 203 | ), f"Cannot upload, local file does not exists: {local}" 204 | self.api.dir_ensure(os.path.dirname(remote)) 205 | # If the file is too big, we'll see if we can skip the upload 206 | size = os.stat(local)[stat.ST_SIZE] 207 | is_same = False 208 | if size > 100_000 and self.file_exists(remote): 209 | # NOTE: Remote and local may not calculate the signature the same 210 | # way. 211 | remote_sig = self.file_sha256(remote) 212 | with open(local, "rb") as f: 213 | local_sig = hashlib.sha256(f.read()).hexdigest() 214 | is_same = local_sig == remote_sig 215 | if is_same: 216 | self.api.info( 217 | f"Remote file is identical to local, no need to upload: {remote} ← {local}" 218 | ) 219 | else: 220 | self.api.connection().upload(remote, local) 221 | if mode or owner or group: 222 | self.api.file_attribs(remote, mode, owner, group) 223 | return remote 224 | 225 | @expose 226 | @logged 227 | def file_download( 228 | self, 229 | remote: str, 230 | local: str, 231 | mode: Optional[int] = None, 232 | owner: Optional[str] = None, 233 | group: Optional[str] = None, 234 | ): 235 | """Downloads the file at the `remote` path to the `local` path.""" 236 | # FIXME: Big files are never transferred properly! 237 | output_path = Path(local) 238 | try: 239 | self.api.connection().download(remote, local) 240 | except NotImplementedError: 241 | output_path.parent.mkdir(parents=True, exist_ok=True) 242 | with open(output_path, "wb") as f: 243 | f.write(self.file_read(remote)) 244 | if mode is not None: 245 | os.chmod(output_path, mode=mode) 246 | return output_path 247 | 248 | @expose 249 | @logged 250 | def file_update(self, path: str, updater=None): 251 | """Updates the content of the given by passing the existing 252 | content of the remote file at the given path to the 'updater' 253 | function. Return true if file content was changed. 254 | 255 | For instance, if you'd like to convert an existing file to all 256 | uppercase, simply do: 257 | 258 | > file_update("/etc/myfile", lambda _:_.upper()) 259 | 260 | Or restart service on config change: 261 | 262 | > if file_update("/etc/myfile.cfg", lambda _: text_ensure_line(_, line)): run("service restart") 263 | """ 264 | assert self.file_exists(path), "File does not exists: " + path 265 | old_content = self.file_read(path) 266 | new_content = updater(old_content) if updater else old_content 267 | if old_content == new_content: 268 | return False 269 | # assert type(new_content) in (str, unicode, fabric.operations._AttributeString), "Updater must be like (string)->string, got: %s() = %s" % (updater, type(new_content)) 270 | self.api.file_write(path, new_content) 271 | return True 272 | 273 | @expose 274 | @logged 275 | def file_append( 276 | self, 277 | path: str, 278 | content: Union[bytes, str], 279 | mode: Optional[str] = None, 280 | owner: Optional[str] = None, 281 | group: Optional[str] = None, 282 | ): 283 | """Appends the given content to the remote file at the given 284 | path, optionally updating its mode/owner/group.""" 285 | # TODO: Make sure this openssl command works everywhere, maybe we should use a text_base64_decode? 286 | # NOTE: We use tee to preserve the writing rights (sudo) 287 | content_bytes = ( 288 | content if isinstance(content, bytes) else bytes(content, "utf8") 289 | ) 290 | # SEE: https://unix.stackexchange.com/questions/503990/redirecting-from-right-to-left 291 | self.api.run( 292 | f"tee -a {quoted(path)} < <(echo {quoted(str(base64.b64encode(content_bytes), 'ascii'))} | openssl base64 -A -d) > /dev/null" 293 | ) 294 | return self.api.file_attribs(path, mode, owner, group) 295 | 296 | @expose 297 | @logged 298 | @requires(("unlink")) 299 | def file_unlink(self, path: str): 300 | """Removes the given file path if it exists""" 301 | if self.file_exists(path): 302 | self.api.run(f"unlink {shell_safe(path)}") 303 | 304 | @expose 305 | @logged 306 | def file_link( 307 | self, source, destination, symbolic=True, mode=None, owner=None, group=None 308 | ): 309 | """Creates a (symbolic) link between source and destination on the remote host, 310 | optionally setting its mode/owner/group.""" 311 | if self.file_exists(destination) and (not self.file_is_link(destination)): 312 | raise Exception( 313 | "Destination already exists and is not a link: %s" % (destination) 314 | ) 315 | # FIXME: Should resolve the link first before unlinking 316 | if self.file_is_link(destination): 317 | self.file_unlink(destination) 318 | if symbolic: 319 | self.api.run("ln -sf %s %s" % (shell_safe(source), shell_safe(destination))) 320 | else: 321 | self.api.run("ln -f %s %s" % (shell_safe(source), shell_safe(destination))) 322 | self.file_attribs(destination, mode, owner, group) 323 | 324 | # SHA256/MD5 sums with openssl are tricky to get working cross-platform 325 | # SEE: https://github.com/sebastien/cuisine/pull/184#issuecomment-102336443 326 | # SEE: http://stackoverflow.com/questions/22982673/is-there-any-function-to-get-the-md5sum-value-of-file-in-linux 327 | 328 | # NOTE: We need to use `cat` here as the first command will be run with sudo 329 | 330 | @expose 331 | @logged 332 | @requires("python", "openssl") 333 | def file_base64(self, path: str) -> str: 334 | """Returns the base64-encoded content of the file at the given path.""" 335 | # TODO: Support options 336 | option_hash = self.api.config_get("hash", "openssl") 337 | if option_hash == "python": 338 | # FIXME: This does not seem to work al 339 | return self.api.run( 340 | f"cat {quoted(path)} | {self.api.command('python')} -c 'import sys,base64;sys.stdout.buffer.write(base64.b64encode(sys.stdin.buffer.read()))'" 341 | ).out 342 | else: 343 | return self.api.run( 344 | f"cat {quoted(path)} | {self.api.command('openssl')} base64" 345 | ).out 346 | 347 | @expose 348 | @logged 349 | def file_sha256(self, path: str): 350 | """Returns the SHA-256 sum (as a hex string) for the remote file at the given path.""" 351 | # NOTE: In some cases, sudo can output errors in here -- but the errors will 352 | # appear before the result, so we simply split and get the last line to 353 | # be on the safe side. 354 | option_hash = self.api.config_get("hash", "python") 355 | if not self.file_exists(path): 356 | return None 357 | elif option_hash == "python": 358 | return self.api.run( 359 | f"cat {quoted(path)} | {self.api.command('python')} -c 'import sys,hashlib;sys.stdout.write(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())'" 360 | ).value 361 | else: 362 | return ( 363 | self.api.run(f"openssl dgst -sha256 {quoted(path)}") 364 | .split("\n")[-1] 365 | .split(")= ", 1)[-1] 366 | .strip() 367 | ) 368 | 369 | @expose 370 | @logged 371 | @requires("cat", "python", "openssl") 372 | def file_md5(self, path: str): 373 | """Returns the MD5 sum (as a hex string) for the remote file at the given path.""" 374 | # NOTE: In some cases, sudo can output errors in here -- but the errors will 375 | # appear before the result, so we simply split and get the last line to 376 | # be on the safe side. 377 | # FIXME: This should go through the options 378 | option_hash = self.api.config_get("hash", "openssl") 379 | if not self.file_exists(path): 380 | return None 381 | elif option_hash == "python": 382 | return self.api.run( 383 | f"python -c 'import sys,hashlib;sys.stdout.buffer.write(hashlib.md5(sys.stdin.buffer.read()).hexdigest())' < {quoted(path)}" 384 | ).out 385 | else: 386 | return self.api.run( 387 | f"openssl dgst -md5 {quoted(path)}" 388 | ).checked_value.split(")= ", 1)[-1] 389 | 390 | 391 | # EOF 392 | -------------------------------------------------------------------------------- /src/py/cuisine/api/package.py: -------------------------------------------------------------------------------- 1 | from ..api import APIModule 2 | from ..decorators import logged, dispatch, variant, expose 3 | from ..utils import quotable 4 | 5 | # ============================================================================= 6 | # 7 | # PACKAGE OPERATIONS 8 | # 9 | # ============================================================================= 10 | 11 | 12 | class PackageAPI(APIModule): 13 | 14 | @expose 15 | def select_package(self, type: str) -> bool: 16 | return True 17 | 18 | @expose 19 | def detect_package(self) -> str: 20 | """Automatically detects the type of package""" 21 | return "yum" 22 | 23 | @logged 24 | @expose 25 | @dispatch("package", multiple=True) 26 | def package_available(self, package: str) -> bool: 27 | """Tells if the given package is available""" 28 | 29 | @logged 30 | @expose 31 | @dispatch("package", multiple=True) 32 | def package_installed(self, package, update=False) -> bool: 33 | """Tells if the given package is installed or not.""" 34 | 35 | @logged 36 | @expose 37 | @dispatch("package", multiple=True) 38 | def package_upgrade(self, distupgrade=False): 39 | """Updates every package present on the system.""" 40 | 41 | @logged 42 | @expose 43 | @dispatch("package", multiple=True) 44 | def package_update(self, package=None): 45 | """Updates the package database (when no argument) or update the package 46 | or list of packages given as argument.""" 47 | 48 | @logged 49 | @expose 50 | @dispatch("package") 51 | def package_install(self, package, update=False): 52 | """Installs the given package/list of package, optionally updating 53 | the package database.""" 54 | 55 | @logged 56 | @expose 57 | @dispatch("package", multiple=True) 58 | def package_ensure(self, package, update=False): 59 | """Tests if the given package is installed, and installs it in 60 | case it's not already there. If `update` is true, then the 61 | package will be updated if it already exists.""" 62 | 63 | @logged 64 | @expose 65 | @dispatch("package") 66 | def package_clean(self, package=None): 67 | """Clean the repository for un-needed files.""" 68 | 69 | @logged 70 | @expose 71 | @dispatch("package", multiple=True) 72 | def package_remove(self, package, autoclean=False): 73 | """Remove package and optionally clean unused packages""" 74 | 75 | # ----------------------------------------------------------------------------- 76 | # APT PACKAGE (DEBIAN/UBUNTU) 77 | # ----------------------------------------------------------------------------- 78 | 79 | 80 | class PackageAPTAPI(APIModule): 81 | 82 | def apt_get(self, cmd): 83 | result = self.api.sudo(cmd) 84 | # If the installation process was interrupted, we might get the following message 85 | # E: dpkg was interrupted, you must manually run 'sudo dpkg --configure -a' to correct the problem. 86 | if "sudo dpkg --configure -a" in result: 87 | self.api.sudo("DEBIAN_FRONTEND=noninteractive dpkg --configure -a") 88 | result = self.api.sudo(cmd) 89 | return result 90 | 91 | def apt_cache(self, cmd): 92 | cmd = CMD_APT_CACHE + cmd 93 | return self.api.run(cmd) 94 | 95 | @logged 96 | @expose 97 | @variant("apt") 98 | def repository_ensure_apt(self, repository): 99 | return self.api.sudo("add-apt-repository --yes " + repository) 100 | 101 | @expose 102 | @variant("apt") 103 | def package_available_apt(package: str) -> bool: 104 | return self.apt_cache(f" search '^{quotable(package)}$'").has_value 105 | 106 | @expose 107 | @variant("apt") 108 | def package_update_apt(self, package=None): 109 | if package == None: 110 | return self.apt_get("-q --yes update") 111 | else: 112 | if isinstance(package, list) or isinstance(package, tuple): 113 | package = " ".join(package) 114 | return self.apt_get(' install --only-upgrade ' + package) 115 | 116 | @expose 117 | @variant("apt") 118 | def package_upgrade_apt(self, distupgrade=False): 119 | if distupgrade: 120 | return self.apt_get("dist-upgrade") 121 | else: 122 | return self.apt_get("install --only-upgrade") 123 | 124 | @expose 125 | @variant("apt") 126 | def package_install_apt(self, package, update=False): 127 | if update: 128 | self.apt_get("update") 129 | if isinstance(package, list) or isinstance(package, tuple): 130 | package = " ".join(package) 131 | return self.apt_get("install " + package) 132 | 133 | @expose 134 | @variant("apt") 135 | def package_installed_apt(self, package, update=False) -> False: 136 | pkg = package.strip() 137 | if not pkg: 138 | raise ValueError(f"Package argument is empty: {repr(package)}") 139 | # The most reliable way to detect success is to use the command status 140 | # and suffix it with OK. This won't break with other locales. 141 | status = self.api.run( 142 | f"dpkg-query -W -f='${{Status}} ' '{pkg}' && echo OK;true") 143 | return status.last_line.endswith("OK") 144 | 145 | @expose 146 | @variant("apt") 147 | def package_ensure_apt(self, package, update=False): 148 | """Ensure apt packages are installed""" 149 | if isinstance(package, str): 150 | package = package.split() 151 | res = {} 152 | for p in package: 153 | p = p.strip() 154 | if not p: 155 | continue 156 | # The most reliable way to detect success is to use the command status 157 | # and suffix it with OK. This won't break with other locales. 158 | status = run( 159 | "dpkg-query -W -f='${Status} ' %s && echo OK;true" % p) 160 | if not status.endswith("OK") or "not-installed" in status: 161 | package_install_apt(p) 162 | res[p] = False 163 | else: 164 | if update: 165 | package_update_apt(p) 166 | res[p] = True 167 | if len(res) == 1: 168 | return next(_ for _ in res.values()) 169 | else: 170 | return res 171 | 172 | @expose 173 | @variant("apt") 174 | def package_clean_apt(self, package=None): 175 | if type(package) in (list, tuple): 176 | package = " ".join(package) 177 | return self.apt_get("-y --purge remove %s" % package) 178 | 179 | @expose 180 | @variant("apt") 181 | def package_remove_apt(self, package, autoclean=False): 182 | self.apt_get('remove ' + package) 183 | if autoclean: 184 | self.apt_get("autoclean") 185 | # 186 | # # ----------------------------------------------------------------------------- 187 | # # YUM PACKAGE (RedHat, CentOS) 188 | # # added by Prune - 20120408 - v1.0 189 | # # ----------------------------------------------------------------------------- 190 | # 191 | 192 | 193 | class PackageYUMAPI(APIModule): 194 | 195 | @expose 196 | @variant("yum") 197 | def repository_ensure_yum(self, repository: str): 198 | raise Exception("Not implemented for Yum") 199 | 200 | @expose 201 | @variant("yum") 202 | def package_upgrade_yum(): 203 | self.api.sudo("yum -y update") 204 | 205 | @expose 206 | @variant("yum") 207 | def package_update_yum(self, package=None): 208 | if package == None: 209 | self.api.sudo("yum -y update") 210 | else: 211 | if type(package) in (list, tuple): 212 | package = " ".join(package) 213 | self.api.sudo("yum -y upgrade " + package) 214 | 215 | @expose 216 | @variant("yum") 217 | def package_install_yum(self, package, update=False): 218 | if update: 219 | self.api.sudo("yum -y update") 220 | if type(package) in (list, tuple): 221 | package = " ".join(package) 222 | if not self.api.sudo(f"yum -y install {package} && echo OK").value.endswith("OK"): 223 | return self.api.fail() 224 | else: 225 | return True 226 | 227 | @expose 228 | @variant("yum") 229 | def package_ensure_yum(self, package, update=False): 230 | if not self.api.run(f"yum list installed {package} && echo OK").value.endswith("OK"): 231 | self.package_install_yum(package, update) 232 | return False 233 | else: 234 | if update: 235 | self.package_update_yum(package) 236 | return True 237 | 238 | @expose 239 | @variant("yum") 240 | def package_clean_yum(self, package=None): 241 | self.api.sudosudo("yum -y clean all") 242 | 243 | @expose 244 | @variant("yum") 245 | def package_remove_yum(self, package, autoclean=False): 246 | self.api.sudo("yum -y remove %s" % (package)) 247 | # 248 | # # ----------------------------------------------------------------------------- 249 | # # ZYPPER PACKAGE (openSUSE) 250 | # # ----------------------------------------------------------------------------- 251 | # 252 | # 253 | # def repository_ensure_zypper(repository): 254 | # repository_uri = repository 255 | # if repository[-1] != '/': 256 | # repository_uri = repository.rpartition("/")[0] 257 | # status = run("zypper --non-interactive --gpg-auto-import-keys repos -d") 258 | # if status.find(repository_uri) == -1: 259 | # sudo("zypper --non-interactive --gpg-auto-import-keys addrepo " + repository) 260 | # sudo("zypper --non-interactive --gpg-auto-import-keys modifyrepo --refresh " + repository_uri) 261 | # 262 | # 263 | # def package_upgrade_zypper(): 264 | # sudo("zypper --non-interactive --gpg-auto-import-keys update --type package") 265 | # 266 | # 267 | # def package_update_zypper(package=None): 268 | # if package == None: 269 | # sudo("zypper --non-interactive --gpg-auto-import-keys refresh") 270 | # else: 271 | # if type(package) in (list, tuple): 272 | # package = " ".join(package) 273 | # sudo("zypper --non-interactive --gpg-auto-import-keys update --type package " + package) 274 | # 275 | # 276 | # def package_install_zypper(package, update=False): 277 | # if update: 278 | # package_update_zypper() 279 | # if type(package) in (list, tuple): 280 | # package = " ".join(package) 281 | # sudo("zypper --non-interactive --gpg-auto-import-keys install --type package --name " + package) 282 | # 283 | # 284 | # def package_ensure_zypper(package, update=False): 285 | # status = run( 286 | # "zypper --non-interactive --gpg-auto-import-keys search --type package --installed-only --match-exact %s ; true" % package) 287 | # if status.find("No packages found.") != -1 or status.find(package) == -1: 288 | # package_install_zypper(package) 289 | # return False 290 | # else: 291 | # if update: 292 | # package_update_zypper(package) 293 | # return True 294 | # 295 | # 296 | # def package_clean_zypper(): 297 | # sudo("zypper --non-interactive clean") 298 | # 299 | # 300 | # def package_remove_zypper(package, autoclean=False): 301 | # sudo("zypper --non-interactive remove %s" % (package)) 302 | # 303 | # # ----------------------------------------------------------------------------- 304 | # # PACMAN PACKAGE (Arch) 305 | # # ----------------------------------------------------------------------------- 306 | # 307 | # 308 | # def repository_ensure_pacman(repository): 309 | # raise Exception("Not implemented for Pacman") 310 | # 311 | # 312 | # def package_update_pacman(package=None): 313 | # if package == None: 314 | # sudo("pacman --noconfirm -Sy") 315 | # else: 316 | # if type(package) in (list, tuple): 317 | # package = " ".join(package) 318 | # sudo("pacman --noconfirm -S " + package) 319 | # 320 | # 321 | # def package_upgrade_pacman(): 322 | # sudo("pacman --noconfirm -Syu") 323 | # 324 | # 325 | # def package_install_pacman(package, update=False): 326 | # if update: 327 | # sudo("pacman --noconfirm -Sy") 328 | # if type(package) in (list, tuple): 329 | # package = " ".join(package) 330 | # sudo("pacman --noconfirm -S %s" % (package)) 331 | # 332 | # 333 | # def package_ensure_pacman(package, update=False): 334 | # """Ensure apt packages are installed""" 335 | # if not isinstance(package, str): 336 | # package = " ".join(package) 337 | # status = run("pacman -Q %s ; true" % package) 338 | # if ('was not found' in status): 339 | # package_install_pacman(package, update) 340 | # return False 341 | # else: 342 | # if update: 343 | # package_update_pacman(package) 344 | # return True 345 | # 346 | # 347 | # def package_clean_pacman(): 348 | # sudo("pacman --noconfirm -Sc") 349 | # 350 | # 351 | # def package_remove_pacman(package, autoclean=False): 352 | # if autoclean: 353 | # sudo('pacman --noconfirm -Rs ' + package) 354 | # else: 355 | # sudo('pacman --noconfirm -R ' + package) 356 | # 357 | # # ----------------------------------------------------------------------------- 358 | # # EMERGE PACKAGE (Gentoo Portage) 359 | # # added by davidmmiller - 20130417 - v0.1 (status - works for me...) 360 | # # ----------------------------------------------------------------------------- 361 | # 362 | # 363 | # def repository_ensure_emerge(repository): 364 | # raise Exception("Not implemented for emerge") 365 | # """This will be used to add Portage overlays in a future update.""" 366 | # 367 | # 368 | # def package_upgrade_emerge(distupgrade=False): 369 | # sudo("emerge -q --update --deep --newuse --with-bdeps=y world") 370 | # 371 | # 372 | # def package_update_emerge(package=None): 373 | # if package == None: 374 | # sudo("emerge -q --sync") 375 | # else: 376 | # if type(package) in (list, tuple): 377 | # package = " ".join(package) 378 | # sudo("emerge -q --update --newuse %s" % package) 379 | # 380 | # 381 | # def package_install_emerge(package, update=False): 382 | # if update: 383 | # sudo("emerge -q --sync") 384 | # if type(package) in (list, tuple): 385 | # package = " ".join(package) 386 | # sudo("emerge -q %s" % (package)) 387 | # 388 | # 389 | # def package_ensure_emerge(package, update=False): 390 | # if not isinstance(package, str): 391 | # package = " ".join(package) 392 | # if update: 393 | # sudo("emerge -q --update --newuse %s" % package) 394 | # else: 395 | # sudo("emerge -q --noreplace %s" % package) 396 | # 397 | # 398 | # def package_clean_emerge(package=None): 399 | # if type(package) in (list, tuple): 400 | # package = " ".join(package) 401 | # if package: 402 | # sudo("CONFIG_PROTECT='-*' emerge --quiet-unmerge-warn --unmerge %s" % package) 403 | # else: 404 | # sudo('emerge -q --depclean') 405 | # sudo('revdep-rebuild -q') 406 | # 407 | # 408 | # def package_remove_emerge(package, autoclean=False): 409 | # if autoclean: 410 | # sudo('emerge --quiet-unmerge-warn --unmerge ' + package) 411 | # sudo('emerge -q --depclean') 412 | # sudo('revdep-rebuild -q') 413 | # else: 414 | # sudo('emerge --quiet-unmerge-warn --unmerge ' + package) 415 | # 416 | # # ----------------------------------------------------------------------------- 417 | # # PKGIN (Illumos, SmartOS, BSD, OSX) 418 | # # added by lbivens - 20130520 - v0.5 (this works but can be better) 419 | # # ----------------------------------------------------------------------------- 420 | # 421 | # # This should be simple but I have to think it properly 422 | # 423 | # 424 | # def repository_ensure_pkgin(repository): 425 | # raise Exception("Not implemented for pkgin") 426 | # 427 | # 428 | # def package_upgrade_pkgin(): 429 | # sudo("pkgin -y upgrade") 430 | # 431 | # 432 | # def package_update_pkgin(package=None): 433 | # # test if this works 434 | # if package == None: 435 | # sudo("pkgin -y update") 436 | # else: 437 | # if type(package) in (list, tuple): 438 | # package = " ".join(package) 439 | # sudo("pkgin -y upgrade " + package) 440 | # 441 | # 442 | # def package_install_pkgin(package, update=False): 443 | # if update: 444 | # sudo("pkgin -y update") 445 | # if type(package) in (list, tuple): 446 | # package = " ".join(package) 447 | # sudo("pkgin -y install %s" % (package)) 448 | # 449 | # 450 | # def package_ensure_pkgin(package, update=False): 451 | # # I am gonna have to do something different here 452 | # status = run("pkgin list | grep %s ; true" % package) 453 | # if status.find("No matching Packages") != -1 or status.find(package) == -1: 454 | # package_install(package, update) 455 | # return False 456 | # else: 457 | # if update: 458 | # package_update(package) 459 | # return True 460 | # 461 | # 462 | # def package_clean_pkgin(package=None): 463 | # sudo("pkgin -y clean") 464 | # 465 | # 466 | # # ----------------------------------------------------------------------------- 467 | # # PKG - FreeBSD 468 | # # ----------------------------------------------------------------------------- 469 | # 470 | # def repository_ensure_pkgng(repository): 471 | # raise Exception("Not implemented for pkgng") 472 | # 473 | # 474 | # def package_upgrade_pkgng(): 475 | # sudo("echo y | pkg upgrade") 476 | # 477 | # 478 | # def package_update_pkgng(package=None): 479 | # # test if this works 480 | # if package == None: 481 | # sudo("pkg -y update") 482 | # else: 483 | # if type(package) in (list, tuple): 484 | # package = " ".join(package) 485 | # sudo("pkg upgrade " + package) 486 | # 487 | # 488 | # def package_install_pkgng(package, update=False): 489 | # if update: 490 | # sudo("pkg update") 491 | # if type(package) in (list, tuple): 492 | # package = " ".join(package) 493 | # sudo("echo y | pkg install %s" % (package)) 494 | # 495 | # 496 | # def package_ensure_pkgng(package, update=False): 497 | # # I am gonna have to do something different here 498 | # status = run("pkg info %s ; true" % package) 499 | # if status.find("No package(s) matching") != -1 or status.find(package) == -1: 500 | # package_install_pkgng(package, update) 501 | # return False 502 | # else: 503 | # if update: 504 | # package_update_pkgng(package) 505 | # return True 506 | # 507 | # 508 | # def package_clean_pkgng(package=None): 509 | # sudo("pkg delete %s" % (package)) 510 | -------------------------------------------------------------------------------- /src/py/cuisine/api/_stub.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, List, Dict, Optional, Union, ForwardRef, ContextManager 2 | import cuisine.connection 3 | import pathlib 4 | # NOTE: This is automatically generated by `python -m cuisine.api -t stub`, do not edit 5 | class API: 6 | 7 | def command(self, name: str) -> str: 8 | """Returns the normalized command name. This first tries to find a match 9 | in `DEFAULT_COMMANDS` and extract it, and then look for a `COMMAND_{name}` 10 | in the environment.""" 11 | raise NotImplementedError 12 | 13 | def command_check(self, command: str) -> bool: 14 | """Tests if the given command is available on the system.""" 15 | raise NotImplementedError 16 | 17 | def command_ensure(self, command: str, package=None) -> bool: 18 | """Ensures that the given command is present, if not installs the 19 | package with the given name, which is the same as the command by 20 | default.""" 21 | raise NotImplementedError 22 | 23 | def config_clear(self, variable: str) -> Optional[str]: 24 | """Clears the given `variable` from connection's environment. 25 | `default` if not found.""" 26 | raise NotImplementedError 27 | 28 | def config_get(self, variable: str, default: Optional[str] = None) -> Optional[str]: 29 | """Returns the given `variable` from the connection's environment, returning 30 | `default` if not found.""" 31 | raise NotImplementedError 32 | 33 | def config_get_variant(self, group: str) -> Optional[str]: 34 | """None""" 35 | raise NotImplementedError 36 | 37 | def config_has(self, variable: str) -> bool: 38 | """Sets the given `variable` in the connection's environment, returning 39 | `default` if not found.""" 40 | raise NotImplementedError 41 | 42 | def config_set(self, variable: str, value: str) -> str: 43 | """Sets the given `variable` in the connection's environment, returning 44 | `default` if not found.""" 45 | raise NotImplementedError 46 | 47 | def cd(self, path: str) -> ContextManager: 48 | """Changes the current connection path, returning a context that can be 49 | used like so: 50 | 51 | ```python 52 | cd("~") 53 | with cd("/etc"): 54 | run("ls -l") 55 | # Current path will be "~" 56 | ``` 57 | """ 58 | raise NotImplementedError 59 | 60 | def connect(self, host=None, port=None, user=None, password=None, key: Union[str, pathlib.Path] = None, transport: Optional[str] = None) -> ContextManager: 61 | """Connects to the given host/port using the given user/password/key_path credentials. Note that 62 | not all connection types support all these arguments, so you might get warnings if they are 63 | not supported.""" 64 | raise NotImplementedError 65 | 66 | def connect_local(self, user=None) -> ContextManager: 67 | """None""" 68 | raise NotImplementedError 69 | 70 | def connect_mitogen(self, host=None, port=None, user=None, password=None, key: Optional[pathlib.Path] = None) -> ContextManager: 71 | """None""" 72 | raise NotImplementedError 73 | 74 | def connect_parallelssh(self, host=None, port=None, user=None, password=None, key: Optional[pathlib.Path] = None) -> ContextManager: 75 | """None""" 76 | raise NotImplementedError 77 | 78 | def connect_paramiko(self, host=None, port=None, user=None, password=None, key: Optional[pathlib.Path] = None) -> ContextManager: 79 | """None""" 80 | raise NotImplementedError 81 | 82 | def connect_tmux(self, session: str, window: str) -> ContextManager: 83 | """Creates a new connection using the TmuxConnection""" 84 | raise NotImplementedError 85 | 86 | def connection(self) -> cuisine.connection.Connection: 87 | """Returns the current connection""" 88 | raise NotImplementedError 89 | 90 | def connection_like(self, predicate) -> Optional[cuisine.connection.Connection]: 91 | """Returns the most recent opened connection that matches the given 92 | predicate.""" 93 | raise NotImplementedError 94 | 95 | def detect_connection(self) -> str: 96 | """Detects the recommended type of connection""" 97 | raise NotImplementedError 98 | 99 | def disconnect(self) -> Optional[cuisine.connection.Connection]: 100 | """Disconnects from the current connection unless it'"s the default 101 | local connection.""" 102 | raise NotImplementedError 103 | 104 | def fail(self, message: Optional[str] = None): 105 | """None""" 106 | raise NotImplementedError 107 | 108 | def is_local(self) -> bool: 109 | """Tells if the current connection is local or not.""" 110 | raise NotImplementedError 111 | 112 | def run(self, command: str) -> 'CommandOutput': 113 | """None""" 114 | raise NotImplementedError 115 | 116 | def run_local(self, command: str) -> 'CommandOutput': 117 | """None""" 118 | raise NotImplementedError 119 | 120 | def select_connection(self, type: str) -> bool: 121 | """Selects the default type of connection. This returns `False` in case 122 | the connection is not found.""" 123 | raise NotImplementedError 124 | 125 | def sudo(self, command: Optional[str] = None) -> Union[ContextManager, ForwardRef('CommandOutput')]: 126 | """None""" 127 | raise NotImplementedError 128 | 129 | def terminate(self) -> List[cuisine.connection.Connection]: 130 | """Terminates/disconnects any remaining connection""" 131 | raise NotImplementedError 132 | 133 | def dir_attribs(self, path: str, mode=None, owner=None, group=None, recursive=False): 134 | """Updates the mode/owner/group for the given remote directory.""" 135 | raise NotImplementedError 136 | 137 | def dir_ensure(self, path: str, recursive=True, mode=None, owner=None, group=None) -> str: 138 | """Ensures that there is a remote directory at the given path, 139 | optionally updating its mode/owner/group. 140 | 141 | If we are not updating the owner/group then this can be done as a single 142 | ssh call, so use that method, otherwise set owner/group after creation.""" 143 | raise NotImplementedError 144 | 145 | def dir_ensure_parent(self, path: str, recursive=True, mode=None, owner=None, group=None): 146 | """Ensures that the parent directory of the given path exists""" 147 | raise NotImplementedError 148 | 149 | def dir_exists(self, path: str) -> bool: 150 | """Tells if there is a remote directory at the given path.""" 151 | raise NotImplementedError 152 | 153 | def dir_remove(self, path: str, recursive=True) -> Optional[bool]: 154 | """ Removes a directory """ 155 | raise NotImplementedError 156 | 157 | def env_clear(self, variable: str) -> str: 158 | """Clears the given `variable` from connection's environment. 159 | `default` if not found.""" 160 | raise NotImplementedError 161 | 162 | def env_get(self, variable: str, default: Optional[str] = None) -> str: 163 | """Returns the given `variable` from the connection's environment, returning 164 | `default` if not found.""" 165 | raise NotImplementedError 166 | 167 | def env_set(self, variable: str, value: str) -> str: 168 | """Sets the given `variable` in the connection's environment, returning 169 | `default` if not found.""" 170 | raise NotImplementedError 171 | 172 | def file_append(self, path: str, content: Union[bytes, str], mode: Optional[str] = None, owner: Optional[str] = None, group: Optional[str] = None): 173 | """Appends the given content to the remote file at the given 174 | path, optionally updating its mode/owner/group.""" 175 | raise NotImplementedError 176 | 177 | def file_attribs(self, path: str, mode=None, owner=None, group=None): 178 | """Updates the mode/owner/group for the remote file at the given 179 | path.""" 180 | raise NotImplementedError 181 | 182 | def file_attribs_get(self, path: str) -> Dict[str, str]: 183 | """Return mode, owner, and group for remote path. 184 | Return mode, owner, and group if remote path exists, 'None' 185 | otherwise. 186 | """ 187 | raise NotImplementedError 188 | 189 | def file_backup(self, path: str, suffix='.orig', once=False): 190 | """Backups the file at the given path in the same directory, appending 191 | the given suffix. If `once` is True, then the backup will be skipped if 192 | there is already a backup file.""" 193 | raise NotImplementedError 194 | 195 | def file_base64(self, path: str) -> str: 196 | """Returns the base64-encoded content of the file at the given path.""" 197 | raise NotImplementedError 198 | 199 | def file_download(self, remote: str, local: str, mode: Optional[int] = None, owner: Optional[str] = None, group: Optional[str] = None): 200 | """Downloads the file at the `remote` path to the `local` path.""" 201 | raise NotImplementedError 202 | 203 | def file_ensure(self, path, mode=None, owner=None, group=None, scp=False): 204 | """Updates the mode/owner/group for the remote file at the given 205 | path.""" 206 | raise NotImplementedError 207 | 208 | def file_ensure_lines(self, path: str, lines: list[str], mode=None, owner=None, group=None): 209 | """Updates the mode/owner/group for the remote file at the given 210 | path.""" 211 | raise NotImplementedError 212 | 213 | def file_exists(self, path: str) -> bool: 214 | """Tests if there is a *remote* file at the given path.""" 215 | raise NotImplementedError 216 | 217 | def file_is_dir(self, path: str) -> bool: 218 | """Tells if the given path is a directory or not""" 219 | raise NotImplementedError 220 | 221 | def file_is_file(self, path: str): 222 | """Tells if the given path is a file or not""" 223 | raise NotImplementedError 224 | 225 | def file_is_link(self, path: str) -> bool: 226 | """Tells if the given path is a symlink or not""" 227 | raise NotImplementedError 228 | 229 | def file_link(self, source, destination, symbolic=True, mode=None, owner=None, group=None): 230 | """Creates a (symbolic) link between source and destination on the remote host, 231 | optionally setting its mode/owner/group.""" 232 | raise NotImplementedError 233 | 234 | def file_md5(self, path: str): 235 | """Returns the MD5 sum (as a hex string) for the remote file at the given path.""" 236 | raise NotImplementedError 237 | 238 | def file_name(self, path: str) -> str: 239 | """Returns the file name for the given path.""" 240 | raise NotImplementedError 241 | 242 | def file_read(self, path: str) -> bytes: 243 | """Reads the *remote* file at the given path, if default is not `None`, 244 | default will be returned if the file does not exist.""" 245 | raise NotImplementedError 246 | 247 | def file_read_str(self, path: str) -> str: 248 | """None""" 249 | raise NotImplementedError 250 | 251 | def file_sha256(self, path: str): 252 | """Returns the SHA-256 sum (as a hex string) for the remote file at the given path.""" 253 | raise NotImplementedError 254 | 255 | def file_unlink(self, path: str): 256 | """Removes the given file path if it exists""" 257 | raise NotImplementedError 258 | 259 | def file_update(self, path: str, updater=None): 260 | """Updates the content of the given by passing the existing 261 | content of the remote file at the given path to the 'updater' 262 | function. Return true if file content was changed. 263 | 264 | For instance, if you'd like to convert an existing file to all 265 | uppercase, simply do: 266 | 267 | > file_update("/etc/myfile", lambda _:_.upper()) 268 | 269 | Or restart service on config change: 270 | 271 | > if file_update("/etc/myfile.cfg", lambda _: text_ensure_line(_, line)): run("service restart") 272 | """ 273 | raise NotImplementedError 274 | 275 | def file_upload(self, local: str, remote: str, mode: Optional[str] = None, owner: Optional[str] = None, group: Optional[str] = None): 276 | """Downloads the local file to the remote path only if the remote path does not 277 | exists or the content are different.""" 278 | raise NotImplementedError 279 | 280 | def file_write(self, path: str, content: Union[str, bytes], mode=None, owner=None, group=None, sudo=None, check=True, scp=False): 281 | """Writes the given content to the file at the given remote 282 | path, optionally setting mode/owner/group.""" 283 | raise NotImplementedError 284 | 285 | def user_create_linux(self, name: str, passwd: Optional[str] = None, home: Optional[str] = None, uid: Optional[int] = None, gid: Optional[int] = None, shell: Optional[str] = None, uid_min: Optional[int] = None, uid_max: Optional[int] = None, encrypted_passwd: Optional[bool] = True, fullname: Optional[str] = None, create_home: Optional[bool] = True): 286 | """None""" 287 | raise NotImplementedError 288 | 289 | def user_ensure_linux(self, name: str, passwd: Optional[str] = None, home: Optional[str] = None, uid: Optional[int] = None, gid: Optional[int] = None, shell: Optional[str] = None, uid_min: Optional[int] = None, uid_max: Optional[int] = None, encrypted_passwd: Optional[bool] = True, fullname: Optional[str] = None, create_home: Optional[bool] = True): 290 | """None""" 291 | raise NotImplementedError 292 | 293 | def user_exists_linux(self, name: str) -> bool: 294 | """None""" 295 | raise NotImplementedError 296 | 297 | def user_get_linux(self, name: str = None, uid: int = None): 298 | """None""" 299 | raise NotImplementedError 300 | 301 | def user_passwd_linux(self, name: str, passwd: str, encrypted_passwd=True): 302 | """Sets the given user password. Password is expected to be encrypted by default.""" 303 | raise NotImplementedError 304 | 305 | def user_remove_linux(self, name: str, remove_home: bool = False): 306 | """Removes the user with the given name, optionally 307 | removing the home directory and mail spool.""" 308 | raise NotImplementedError 309 | 310 | def error(self, message: str) -> None: 311 | """None""" 312 | raise NotImplementedError 313 | 314 | def info(self, message: str) -> None: 315 | """None""" 316 | raise NotImplementedError 317 | 318 | def detect_package(self) -> str: 319 | """Automatically detects the type of package""" 320 | raise NotImplementedError 321 | 322 | def package_available(self, package: str) -> bool: 323 | """Tells if the given package is available""" 324 | raise NotImplementedError 325 | 326 | def package_clean(self, package=None): 327 | """Clean the repository for un-needed files.""" 328 | raise NotImplementedError 329 | 330 | def package_ensure(self, package, update=False): 331 | """Tests if the given package is installed, and installs it in 332 | case it's not already there. If `update` is true, then the 333 | package will be updated if it already exists.""" 334 | raise NotImplementedError 335 | 336 | def package_install(self, package, update=False): 337 | """Installs the given package/list of package, optionally updating 338 | the package database.""" 339 | raise NotImplementedError 340 | 341 | def package_installed(self, package, update=False) -> bool: 342 | """Tells if the given package is installed or not.""" 343 | raise NotImplementedError 344 | 345 | def package_remove(self, package, autoclean=False): 346 | """Remove package and optionally clean unused packages""" 347 | raise NotImplementedError 348 | 349 | def package_update(self, package=None): 350 | """Updates the package database (when no argument) or update the package 351 | or list of packages given as argument.""" 352 | raise NotImplementedError 353 | 354 | def package_upgrade(self, distupgrade=False): 355 | """Updates every package present on the system.""" 356 | raise NotImplementedError 357 | 358 | def select_package(self, type: str) -> bool: 359 | """None""" 360 | raise NotImplementedError 361 | 362 | def package_available_apt(package: str) -> bool: 363 | """None""" 364 | raise NotImplementedError 365 | 366 | def package_clean_apt(self, package=None): 367 | """None""" 368 | raise NotImplementedError 369 | 370 | def package_ensure_apt(self, package, update=False): 371 | """Ensure apt packages are installed""" 372 | raise NotImplementedError 373 | 374 | def package_install_apt(self, package, update=False): 375 | """None""" 376 | raise NotImplementedError 377 | 378 | def package_installed_apt(self, package, update=False) -> False: 379 | """None""" 380 | raise NotImplementedError 381 | 382 | def package_remove_apt(self, package, autoclean=False): 383 | """None""" 384 | raise NotImplementedError 385 | 386 | def package_update_apt(self, package=None): 387 | """None""" 388 | raise NotImplementedError 389 | 390 | def package_upgrade_apt(self, distupgrade=False): 391 | """None""" 392 | raise NotImplementedError 393 | 394 | def repository_ensure_apt(self, repository): 395 | """None""" 396 | raise NotImplementedError 397 | 398 | def package_clean_yum(self, package=None): 399 | """None""" 400 | raise NotImplementedError 401 | 402 | def package_ensure_yum(self, package, update=False): 403 | """None""" 404 | raise NotImplementedError 405 | 406 | def package_install_yum(self, package, update=False): 407 | """None""" 408 | raise NotImplementedError 409 | 410 | def package_remove_yum(self, package, autoclean=False): 411 | """None""" 412 | raise NotImplementedError 413 | 414 | def package_update_yum(self, package=None): 415 | """None""" 416 | raise NotImplementedError 417 | 418 | def package_upgrade_yum(): 419 | """None""" 420 | raise NotImplementedError 421 | 422 | def repository_ensure_yum(self, repository: str): 423 | """None""" 424 | raise NotImplementedError 425 | 426 | def python_package_ensure_pip(self, package=None, local=True): 427 | """None""" 428 | raise NotImplementedError 429 | 430 | def python_package_install_pip(self, package=None, local=True): 431 | """None""" 432 | raise NotImplementedError 433 | 434 | def python_package_remove_pip(self, package, local=True): 435 | """None""" 436 | raise NotImplementedError 437 | 438 | def python_package_upgrade_pip(self, package=None, local=True): 439 | """None""" 440 | raise NotImplementedError 441 | 442 | def detect_python_package(self) -> str: 443 | """Automatically detects the type of package""" 444 | raise NotImplementedError 445 | 446 | def python_package_ensure(self, package): 447 | """Tests if the given python package is installed, and installs it in 448 | case it's not already there.""" 449 | raise NotImplementedError 450 | 451 | def python_package_install(self, package=None): 452 | """Installs the given python package/list of python packages.""" 453 | raise NotImplementedError 454 | 455 | def python_package_remove(self, package): 456 | """Removes the given python package. """ 457 | raise NotImplementedError 458 | 459 | def python_package_upgrade(self, package): 460 | """Upgraded the given Python package""" 461 | raise NotImplementedError 462 | 463 | def select_python_package(self, type: str) -> bool: 464 | """None""" 465 | raise NotImplementedError 466 | 467 | def ssh_authorize(self, user: str, key: Optional[str] = None) -> bool: 468 | """Adds the given key to the '.ssh/authorized_keys' for the given 469 | user.""" 470 | raise NotImplementedError 471 | 472 | def ssh_keygen(self, user: str, keytype='rsa') -> str: 473 | """Generates a pair of ssh keys in the user's home .ssh directory.""" 474 | raise NotImplementedError 475 | 476 | def ssh_unauthorize(self, user: str, key: str): 477 | """Removes the given key to the remote '.ssh/authorized_keys' for the given 478 | user.""" 479 | raise NotImplementedError 480 | 481 | def tmux_has(self, session: str, window: Optional[int]) -> bool: 482 | """None""" 483 | raise NotImplementedError 484 | 485 | def tmux_is_responsive(self, session: str, window: int) -> Optional[bool]: 486 | """None""" 487 | raise NotImplementedError 488 | 489 | def tmux_session_list(self) -> List[str]: 490 | """None""" 491 | raise NotImplementedError 492 | 493 | def tmux_window_list(self, session: str) -> List[int]: 494 | """None""" 495 | raise NotImplementedError 496 | 497 | def detect_user(self) -> str: 498 | """None""" 499 | raise NotImplementedError 500 | 501 | def user_create(self, name: str, passwd: Optional[str] = None, home: Optional[str] = None, uid: Optional[int] = None, gid: Optional[int] = None, shell: Optional[str] = None, uid_min: Optional[int] = None, uid_max: Optional[int] = None, encrypted_passwd: Optional[bool] = True, fullname: Optional[str] = None, create_home: Optional[bool] = True): 502 | """Creates the user with the given name, optionally giving a 503 | specific password/home/uid/gid/shell.""" 504 | raise NotImplementedError 505 | 506 | def user_ensure(self, name: str, passwd: Optional[str] = None, home: Optional[str] = None, uid: Optional[int] = None, gid: Optional[int] = None, shell: Optional[str] = None, uid_min: Optional[int] = None, uid_max: Optional[int] = None, encrypted_passwd: Optional[bool] = True, fullname: Optional[str] = None, create_home: Optional[bool] = True): 507 | """Ensures that the given users exists, optionally updating their 508 | passwd/home/uid/gid/shell.""" 509 | raise NotImplementedError 510 | 511 | def user_exists(self, name: str) -> bool: 512 | """Tells if the user exists.""" 513 | raise NotImplementedError 514 | 515 | def user_get(self, name: Optional[str] = None, uid: Optional[int] = None) -> Dict: 516 | """Checks if there is a user defined with the given name, 517 | returning its information as a 518 | '{"name":,"uid":,"gid":,"home":,"shell":}' 519 | or 'None' if the user does not exists. 520 | need_passwd (Boolean) indicates if password to be included in result or not. 521 | If set to True it parses 'getent shadow' and needs sudo access 522 | """ 523 | raise NotImplementedError 524 | 525 | def user_passwd(self, name: str, passwd: str, encrypted_passwd=True): 526 | """Sets the given user password. Password is expected to be encrypted by default.""" 527 | raise NotImplementedError 528 | 529 | def user_remove(self, name: str, remove_home: bool = False): 530 | """Removes the user with the given name, optionally 531 | removing the home directory and mail spool.""" 532 | raise NotImplementedError 533 | 534 | # EOF 535 | --------------------------------------------------------------------------------