Skip to content
This repository was archived by the owner on Sep 22, 2023. It is now read-only.

Commit be9950b

Browse files
leksikovachimnol
andauthored
backend.ai ssh/scp commands (#138)
* The ssh/scp commands pass all unknown arguments and options to the underlying command. For isntance, now it's possible to run a SOCKS proxy easily: `backend.ai ssh -D 5500 mysession` (This is possible by using Click's `allow_interspersed_args` context option) Co-authored-by: Joongi Kim <[email protected]>
1 parent feb4c74 commit be9950b

File tree

3 files changed

+165
-1
lines changed

3 files changed

+165
-1
lines changed

changes/138.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `backend.ai ssh` and `backend.ai scp` command which provides transparent wrappers of `ssh` and `scp` against the given compute session

src/ai/backend/client/cli/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ def main(skip_sslcert_validation):
3333

3434

3535
def _attach_command():
36-
from . import admin, config, app, files, logs, manager, proxy, ps, run # noqa
36+
from . import admin, config, app, files, logs, manager, proxy, ps, run # noqa
37+
from . import ssh # noqa
3738
from . import vfolder # noqa
3839
from . import session_template # noqa
3940
from . import dotfile # noqa

src/ai/backend/client/cli/ssh.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import contextlib
2+
import os
3+
from pathlib import Path
4+
import secrets
5+
import signal
6+
import subprocess
7+
import sys
8+
from typing import Iterator, List
9+
10+
import click
11+
12+
from . import main
13+
from .pretty import print_info, print_fail, print_error
14+
15+
16+
@contextlib.contextmanager
17+
def container_ssh_ctx(session_ref: str, port: int) -> Iterator[Path]:
18+
random_id = secrets.token_hex(16)
19+
key_filename = "id_container"
20+
key_path = Path(f"~/.ssh/id_{random_id}").expanduser()
21+
try:
22+
subprocess.run(
23+
["backend.ai", "download", session_ref, key_filename],
24+
shell=False,
25+
check=True,
26+
stdout=subprocess.PIPE,
27+
stderr=subprocess.STDOUT,
28+
)
29+
except subprocess.CalledProcessError as e:
30+
print_fail(f"Failed to download the SSH key from the session (exit: {e.returncode}):")
31+
print(e.stdout.decode())
32+
sys.exit(1)
33+
os.rename(key_filename, key_path)
34+
try:
35+
print_info(f"running a temporary sshd proxy at localhost:{port} ...", file=sys.stderr)
36+
# proxy_proc is a background process
37+
proxy_proc = subprocess.Popen(
38+
[
39+
"backend.ai", "app", session_ref,
40+
"sshd", "-b", f"127.0.0.1:{port}",
41+
],
42+
stdout=subprocess.PIPE,
43+
stderr=subprocess.STDOUT,
44+
)
45+
assert proxy_proc.stdout is not None
46+
lines: List[bytes] = []
47+
while True:
48+
line = proxy_proc.stdout.readline(1024)
49+
if not line:
50+
proxy_proc.wait()
51+
print_fail(f"Unexpected early termination of the sshd app command "
52+
f"(exit: {proxy_proc.returncode}):")
53+
print((b"\n".join(lines)).decode())
54+
sys.exit(1)
55+
if f"127.0.0.1:{port}".encode() in line:
56+
break
57+
lines.append(line)
58+
lines.clear()
59+
yield key_path
60+
finally:
61+
proxy_proc.send_signal(signal.SIGINT)
62+
proxy_proc.wait()
63+
os.unlink(key_path)
64+
65+
66+
@main.command(
67+
context_settings={
68+
"ignore_unknown_options": True,
69+
"allow_extra_args": True,
70+
"allow_interspersed_args": True,
71+
},
72+
)
73+
@click.argument("session_ref", type=str, metavar='SESSION_REF')
74+
@click.option('-p', '--port', type=int, metavar='PORT', default=9922,
75+
help="the port number for localhost")
76+
@click.pass_context
77+
def ssh(ctx: click.Context, session_ref: str, port: int) -> None:
78+
"""Execute the ssh command against the target compute session
79+
80+
\b
81+
SESSION_REF: The user-provided name or the unique ID of a running compute session.
82+
83+
All remaining options and arguments not listed here are passed to the ssh command as-is.
84+
"""
85+
try:
86+
with container_ssh_ctx(session_ref, port) as key_path:
87+
ssh_proc = subprocess.run(
88+
[
89+
"ssh",
90+
"-o", "StrictHostKeyChecking=no",
91+
"-i", key_path,
92+
"work@localhost",
93+
"-p", str(port),
94+
*ctx.args,
95+
],
96+
shell=False,
97+
check=False, # be transparent against the main command
98+
)
99+
sys.exit(ssh_proc.returncode)
100+
except Exception as e:
101+
print_error(e)
102+
103+
104+
@main.command(
105+
context_settings={
106+
"ignore_unknown_options": True,
107+
"allow_extra_args": True,
108+
"allow_interspersed_args": True,
109+
},
110+
)
111+
@click.argument("session_ref", type=str, metavar='SESSION_REF')
112+
@click.argument("src", type=str, metavar='SRC')
113+
@click.argument("dst", type=str, metavar='DST')
114+
@click.option('-p', '--port', type=str, metavar='PORT', default=9922,
115+
help="the port number for localhost")
116+
@click.option('-r', '--recursive', default=False, is_flag=True,
117+
help="recursive flag option to process directories")
118+
@click.pass_context
119+
def scp(ctx: click.Context, session_ref: str, src: str, dst: str, port: int, recursive: bool) -> None:
120+
"""Execute the scp command against the target compute session
121+
122+
\b
123+
The SRC and DST have the same format with the original scp command,
124+
either a remote path as "work@localhost:path" or a local path.
125+
126+
SESSION_REF: The user-provided name or the unique ID of a running compute session.
127+
SRC: the source path
128+
DST: the destination path
129+
130+
All remaining options and arguments not listed here are passed to the ssh command as-is.
131+
132+
Examples:
133+
134+
* Uploading a local directory to the session:
135+
136+
> backend.ai scp mysess -p 9922 -r tmp/ work@localhost:tmp2/
137+
138+
* Downloading a directory from the session:
139+
140+
> backend.ai scp mysess -p 9922 -r work@localhost:tmp2/ tmp/
141+
"""
142+
recursive_args = []
143+
if recursive:
144+
recursive_args.append("-r")
145+
try:
146+
with container_ssh_ctx(session_ref, port) as key_path:
147+
scp_proc = subprocess.run(
148+
[
149+
"scp",
150+
"-o", "StrictHostKeyChecking=no",
151+
"-i", key_path,
152+
"-P", str(port),
153+
*recursive_args,
154+
src, dst,
155+
*ctx.args,
156+
],
157+
shell=False,
158+
check=False, # be transparent against the main command
159+
)
160+
sys.exit(scp_proc.returncode)
161+
except Exception as e:
162+
print_error(e)

0 commit comments

Comments
 (0)