diff --git a/README.md b/README.md index a2be6d78..7b679f0d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,39 @@ Should you use fuselage? Probably not. But if you are wondering why: * It's **simple**. It provides the absolute minimum, and tries to get out the way for the stuff where it doesn't need to have an opinion. Bring your own template engine, or don't use one at all. Bring your own control plane. Run it from a deamonset, run it via fabric or even just use scp and run it by hand. +## Using with paramiko + +```python +import paramiko + +from fuselage.bundle import ResourceBundle +from fuselage.resources import * +from fuselage.ssh import execute_via_ssh + + +bundle = ResourceBundle() + +bundle.add(File( + name="/tmp/hello.txt", + contents="A test file!!", +)) + +transport = paramiko.Transport(("localhost", 22)) +transport.connect( + username="john", + password="my super sekrit password", +) + +# Compile the bundle, scp it to target server, execute it via sudo +execute_via_ssh( + transport, + bundle, + "root", + sudo_password="my super sekrit password" +) +``` + + ## Using with fabric You will need to install fabric explicitly. Fuselage does not depend on fabric. diff --git a/fuselage/builder.py b/fuselage/builder.py index 60181793..9785370c 100644 --- a/fuselage/builder.py +++ b/fuselage/builder.py @@ -96,7 +96,7 @@ def close(self): self.zipfile.close() -def build(bundle: ResourceBundle, name: str="payload.pex") -> io.BytesIO: +def build(bundle: ResourceBundle, name: str = "payload.pex") -> io.BytesIO: buffer = io.BytesIO() buffer.name = name diff --git a/fuselage/ssh.py b/fuselage/ssh.py new file mode 100644 index 00000000..2e569d79 --- /dev/null +++ b/fuselage/ssh.py @@ -0,0 +1,97 @@ +import os +import time +from typing import Optional + +import paramiko + +from .builder import build +from .bundle import ResourceBundle + + +def iter_chunks(channel: paramiko.Channel): + while not channel.exit_status_ready(): + while channel.recv_ready(): + yield channel.recv(1024) + time.sleep(0.1) + + while channel.recv_ready(): + yield channel.recv(1024) + + +def iter_lines(channel: paramiko.Channel): + buffer = bytearray() + for chunk in iter_chunks(channel): + buffer.extend(chunk) + + while b"\n" in buffer: + line, buffer = buffer.split(b"\n", 1) + yield line.decode("utf-8") + + +def execute_via_ssh( + transport: paramiko.Transport, + bundle: ResourceBundle, + dry_run: bool = False, + username: Optional[str] = "root", + sudo_password: Optional[str] = None, +): + """ + transport = paramiko.Transport(("localhost", 22)) + transport.connect( + username="ubuntu", + password="password55", + ) + execute(transport, bundle, "root", "mysudopassword") + """ + payload = build(bundle) + + sftp = transport.open_sftp_client() + sftp.chdir(".") + + path = os.path.join(sftp.getcwd(), ".payload.pex") + + command_parts = [path, "--resume"] + + if dry_run: + command_parts.append("--simulate") + + command = " ".join(command_parts) + + channel = transport.open_session() + channel.get_pty() + channel.set_combine_stderr(1) + + try: + sftp.putfo(payload, path) + sftp.chmod(path, 0o755) + + try: + if username and username != transport.get_username(): + command = f"sudo -u {username} {command}" + channel.exec_command(command) + + if sudo_password: + first_line = channel.recv(1024) + + if b"[sudo]" not in first_line and not first_line.startswith( + b"Password:" + ): + raise RuntimeError(f"Expected sudo, got {first_line!r}") + + channel.sendall((sudo_password + "\n").encode("utf-8")) + else: + channel.exec_command(path) + + channel.shutdown_write() + + try: + for line in iter_lines(channel): + print(line) + + return channel.recv_exit_status() + finally: + channel.close() + finally: + sftp.remove(path) + finally: + sftp.close() diff --git a/poetry.lock b/poetry.lock index a913022b..0ff1738c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -531,10 +531,13 @@ python-versions = ">=3.6" docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] +[extras] +ssh = ["paramiko"] + [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "258bb8978fed05f84433f8f1f0d99dedf0a20e6262acf2d4d6fa452fb6c39821" +content-hash = "b2691d572cb6b423ca4c806c6004d59b90a42b059eb450143937e02d629007e4" [metadata.files] apache-libcloud = [