chapman-sip 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chapman_sip-0.1.0/PKG-INFO +53 -0
- chapman_sip-0.1.0/README.md +39 -0
- chapman_sip-0.1.0/pyproject.toml +26 -0
- chapman_sip-0.1.0/setup.cfg +4 -0
- chapman_sip-0.1.0/src/chapman_runner/__init__.py +5 -0
- chapman_sip-0.1.0/src/chapman_runner/cli.py +409 -0
- chapman_sip-0.1.0/src/chapman_runner/ssh.py +100 -0
- chapman_sip-0.1.0/src/chapman_runner/tunnel.py +80 -0
- chapman_sip-0.1.0/src/chapman_sip.egg-info/PKG-INFO +53 -0
- chapman_sip-0.1.0/src/chapman_sip.egg-info/SOURCES.txt +12 -0
- chapman_sip-0.1.0/src/chapman_sip.egg-info/dependency_links.txt +1 -0
- chapman_sip-0.1.0/src/chapman_sip.egg-info/entry_points.txt +2 -0
- chapman_sip-0.1.0/src/chapman_sip.egg-info/requires.txt +3 -0
- chapman_sip-0.1.0/src/chapman_sip.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chapman-sip
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A CLI tool to run steerable models on the Chapman cluster with SSH port forwarding
|
|
5
|
+
Author-email: Developer <developer@example.com>
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: paramiko>=3.0.0
|
|
12
|
+
Requires-Dist: click>=8.0.0
|
|
13
|
+
Requires-Dist: rich>=12.0.0
|
|
14
|
+
|
|
15
|
+
# Chapman-Sip CLI
|
|
16
|
+
|
|
17
|
+
A pip-installable Python command-line utility to connect to the Chapman cluster (`dgx0.chapman.edu`), pull the `steerable-model-runner` repository, find an open port, launch the model server, and tunnel the port back to your local machine.
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- **Automated Host Setup**: Checks if the remote repository is cloned at `~/.steerable-models` on the cluster, cloning or pulling changes automatically.
|
|
22
|
+
- **Dynamic Port Hunting**: Generates a random port and verifies it is not currently bound on the cluster before initiating.
|
|
23
|
+
- **SSH Port Forwarding**: Automatically forwards the selected remote port to `localhost:[PORT]` locally.
|
|
24
|
+
- **Interactive Log Streaming**: Streams stderr and stdout from the model serving script in real-time.
|
|
25
|
+
- **Safe Shutdown**: Automatically cleans up the remote process, closes the port-forwarding server, and closes SSH channels on Ctrl+C.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
Install in editable mode for development or directly from the source directory:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install -e .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
Simply run:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
sip
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
You will be prompted for:
|
|
44
|
+
- SSH username
|
|
45
|
+
- SSH password
|
|
46
|
+
- Hugging Face API token (for model download)
|
|
47
|
+
- Hugging Face repository name (e.g., the model repository to run)
|
|
48
|
+
|
|
49
|
+
Once connected, the utility will output the endpoint URL, e.g.:
|
|
50
|
+
```
|
|
51
|
+
https://localhost:[PORT]/v1/
|
|
52
|
+
```
|
|
53
|
+
Keep the CLI running while you query the endpoint. Press `Ctrl+C` to terminate the model server and close the tunnel.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Chapman-Sip CLI
|
|
2
|
+
|
|
3
|
+
A pip-installable Python command-line utility to connect to the Chapman cluster (`dgx0.chapman.edu`), pull the `steerable-model-runner` repository, find an open port, launch the model server, and tunnel the port back to your local machine.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Automated Host Setup**: Checks if the remote repository is cloned at `~/.steerable-models` on the cluster, cloning or pulling changes automatically.
|
|
8
|
+
- **Dynamic Port Hunting**: Generates a random port and verifies it is not currently bound on the cluster before initiating.
|
|
9
|
+
- **SSH Port Forwarding**: Automatically forwards the selected remote port to `localhost:[PORT]` locally.
|
|
10
|
+
- **Interactive Log Streaming**: Streams stderr and stdout from the model serving script in real-time.
|
|
11
|
+
- **Safe Shutdown**: Automatically cleans up the remote process, closes the port-forwarding server, and closes SSH channels on Ctrl+C.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
Install in editable mode for development or directly from the source directory:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install -e .
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
Simply run:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
sip
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
You will be prompted for:
|
|
30
|
+
- SSH username
|
|
31
|
+
- SSH password
|
|
32
|
+
- Hugging Face API token (for model download)
|
|
33
|
+
- Hugging Face repository name (e.g., the model repository to run)
|
|
34
|
+
|
|
35
|
+
Once connected, the utility will output the endpoint URL, e.g.:
|
|
36
|
+
```
|
|
37
|
+
https://localhost:[PORT]/v1/
|
|
38
|
+
```
|
|
39
|
+
Keep the CLI running while you query the endpoint. Press `Ctrl+C` to terminate the model server and close the tunnel.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "chapman-sip"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A CLI tool to run steerable models on the Chapman cluster with SSH port forwarding"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "Developer", email = "developer@example.com" }
|
|
12
|
+
]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
]
|
|
18
|
+
requires-python = ">=3.8"
|
|
19
|
+
dependencies = [
|
|
20
|
+
"paramiko>=3.0.0",
|
|
21
|
+
"click>=8.0.0",
|
|
22
|
+
"rich>=12.0.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
sip = "chapman_runner.cli:main"
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
import click
|
|
6
|
+
import shutil
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
import webbrowser
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from chapman_runner.ssh import (
|
|
15
|
+
connect_ssh,
|
|
16
|
+
find_free_remote_port,
|
|
17
|
+
setup_remote_repo,
|
|
18
|
+
start_model_server,
|
|
19
|
+
)
|
|
20
|
+
from chapman_runner.tunnel import SSHTunnel
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
CACHE_FILE = os.path.expanduser("~/.sip_cache.json")
|
|
24
|
+
|
|
25
|
+
def is_docker_running():
|
|
26
|
+
"""
|
|
27
|
+
Checks if Docker is installed and the daemon is active.
|
|
28
|
+
"""
|
|
29
|
+
if shutil.which("docker") is None:
|
|
30
|
+
return False
|
|
31
|
+
try:
|
|
32
|
+
result = subprocess.run(
|
|
33
|
+
["docker", "info"],
|
|
34
|
+
stdout=subprocess.DEVNULL,
|
|
35
|
+
stderr=subprocess.DEVNULL,
|
|
36
|
+
timeout=5
|
|
37
|
+
)
|
|
38
|
+
return result.returncode == 0
|
|
39
|
+
except Exception:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
def is_local_port_free(port):
|
|
43
|
+
"""
|
|
44
|
+
Checks if a port is free on the local loopback.
|
|
45
|
+
"""
|
|
46
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
47
|
+
try:
|
|
48
|
+
s.bind(("127.0.0.1", port))
|
|
49
|
+
return True
|
|
50
|
+
except OSError:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
def find_free_local_port(start_port=3000):
|
|
54
|
+
"""
|
|
55
|
+
Finds the first free local port starting at start_port.
|
|
56
|
+
"""
|
|
57
|
+
port = start_port
|
|
58
|
+
while port < 65535:
|
|
59
|
+
if is_local_port_free(port):
|
|
60
|
+
return port
|
|
61
|
+
port += 1
|
|
62
|
+
raise RuntimeError("No free local ports found!")
|
|
63
|
+
|
|
64
|
+
def start_open_webui(model_port):
|
|
65
|
+
"""
|
|
66
|
+
Finds a local port, restarts the Open WebUI docker container,
|
|
67
|
+
and opens the URL in the browser.
|
|
68
|
+
"""
|
|
69
|
+
if not is_docker_running():
|
|
70
|
+
console.print("[yellow]* Docker is not running or not installed. Skipping Open WebUI startup.[/yellow]")
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
webui_port = find_free_local_port(3000)
|
|
75
|
+
console.print(f"[cyan]Starting Open WebUI on local port {webui_port}...[/cyan]")
|
|
76
|
+
|
|
77
|
+
# 1. Clean up any existing 'sip-open-webui' container
|
|
78
|
+
subprocess.run(
|
|
79
|
+
["docker", "rm", "-f", "sip-open-webui"],
|
|
80
|
+
stdout=subprocess.DEVNULL,
|
|
81
|
+
stderr=subprocess.DEVNULL
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# 2. Run the new container
|
|
85
|
+
cmd = [
|
|
86
|
+
"docker", "run", "-d",
|
|
87
|
+
"--name", "sip-open-webui",
|
|
88
|
+
"-p", f"{webui_port}:8080",
|
|
89
|
+
"-e", f"OPENAI_API_BASE_URL=http://host.docker.internal:{model_port}/v1",
|
|
90
|
+
"-v", "open-webui:/app/backend/data",
|
|
91
|
+
"--restart", "always",
|
|
92
|
+
"ghcr.io/open-webui/open-webui:main"
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
96
|
+
if result.returncode != 0:
|
|
97
|
+
console.print(f"[red]Failed to start Open WebUI container: {result.stderr.strip()}[/red]")
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
console.print(f"[green]* Open WebUI container started successfully (ID: {result.stdout.strip()[:12]}).[/green]")
|
|
101
|
+
|
|
102
|
+
# 3. Open in browser
|
|
103
|
+
webui_url = f"http://localhost:{webui_port}"
|
|
104
|
+
console.print(f"[green]* Opening {webui_url} in default browser...[/green]")
|
|
105
|
+
webbrowser.open(webui_url)
|
|
106
|
+
|
|
107
|
+
return webui_port
|
|
108
|
+
except Exception as e:
|
|
109
|
+
console.print(f"[red]Error starting Open WebUI: {e}[/red]")
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def load_cache():
|
|
113
|
+
"""
|
|
114
|
+
Loads saved configurations from the local cache file.
|
|
115
|
+
"""
|
|
116
|
+
if os.path.exists(CACHE_FILE):
|
|
117
|
+
try:
|
|
118
|
+
with open(CACHE_FILE, "r") as f:
|
|
119
|
+
data = json.load(f)
|
|
120
|
+
if isinstance(data, dict) and "history" in data:
|
|
121
|
+
return data
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
return {"history": []}
|
|
125
|
+
|
|
126
|
+
def save_cache(host, username, hf_token, hf_repo):
|
|
127
|
+
"""
|
|
128
|
+
Saves a successful configuration run to the local cache file.
|
|
129
|
+
"""
|
|
130
|
+
cache = load_cache()
|
|
131
|
+
history = cache.get("history", [])
|
|
132
|
+
|
|
133
|
+
# Create the config object (passwords are NEVER cached)
|
|
134
|
+
config = {
|
|
135
|
+
"host": host,
|
|
136
|
+
"username": username,
|
|
137
|
+
"hf_token": hf_token,
|
|
138
|
+
"hf_repo": hf_repo
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# Remove any existing configuration for the same host, username, and repo to avoid duplication
|
|
142
|
+
history = [
|
|
143
|
+
c for c in history
|
|
144
|
+
if not (c.get("host") == host and c.get("username") == username and c.get("hf_repo") == hf_repo)
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
# Insert new run configuration at the front
|
|
148
|
+
history.insert(0, config)
|
|
149
|
+
|
|
150
|
+
# Keep only the last 10 configurations
|
|
151
|
+
cache["history"] = history[:10]
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
|
|
155
|
+
with open(CACHE_FILE, "w") as f:
|
|
156
|
+
json.dump(cache, f, indent=4)
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
def print_banner():
|
|
161
|
+
banner = Text()
|
|
162
|
+
banner.append("\n", style="bold")
|
|
163
|
+
banner.append("+--------------------------------------------------------+\n", style="bold cyan")
|
|
164
|
+
banner.append("| CHAPMAN CLUSTER MODEL RUNNER CLI |\n", style="bold cyan")
|
|
165
|
+
banner.append("+--------------------------------------------------------+\n", style="bold cyan")
|
|
166
|
+
console.print(banner)
|
|
167
|
+
|
|
168
|
+
@click.command()
|
|
169
|
+
@click.option("--host", default="dgx0.chapman.edu", help="The Chapman cluster host address.")
|
|
170
|
+
@click.option("--username", help="Your SSH username for the Chapman cluster.")
|
|
171
|
+
def main(host, username):
|
|
172
|
+
"""
|
|
173
|
+
CLI utility to connect to the Chapman cluster, set up steerable-model-runner,
|
|
174
|
+
and tunnel the serving port back to your local machine.
|
|
175
|
+
"""
|
|
176
|
+
print_banner()
|
|
177
|
+
|
|
178
|
+
cache = load_cache()
|
|
179
|
+
history = cache.get("history", [])
|
|
180
|
+
selected_config = None
|
|
181
|
+
edit_config = False
|
|
182
|
+
|
|
183
|
+
while True:
|
|
184
|
+
selected_config = None
|
|
185
|
+
edit_config = False
|
|
186
|
+
|
|
187
|
+
# Check if there are cached configurations
|
|
188
|
+
if history:
|
|
189
|
+
console.print("[bold yellow]Saved configurations found:[/bold yellow]")
|
|
190
|
+
for idx, config in enumerate(history, 1):
|
|
191
|
+
console.print(f"[{idx}] [green]{config['hf_repo']}[/green] (on {config['host']} as {config['username']})")
|
|
192
|
+
console.print(f"[{len(history) + 1}] Start a new custom run...")
|
|
193
|
+
|
|
194
|
+
choice = click.prompt(
|
|
195
|
+
click.style("Select an option", fg="green", bold=True),
|
|
196
|
+
type=click.IntRange(1, len(history) + 1),
|
|
197
|
+
default=1
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if choice <= len(history):
|
|
201
|
+
selected_config = history[choice - 1]
|
|
202
|
+
else:
|
|
203
|
+
selected_config = None
|
|
204
|
+
|
|
205
|
+
if selected_config:
|
|
206
|
+
console.print(f"\n[cyan]Selected:[/cyan] [green]{selected_config['hf_repo']}[/green] on {selected_config['host']} as {selected_config['username']}")
|
|
207
|
+
console.print("[1] Run directly")
|
|
208
|
+
console.print("[2] Edit this configuration")
|
|
209
|
+
console.print("[3] Cancel (Go back)")
|
|
210
|
+
|
|
211
|
+
action = click.prompt(
|
|
212
|
+
click.style("Select action", fg="green", bold=True),
|
|
213
|
+
type=click.IntRange(1, 3),
|
|
214
|
+
default=1
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if action == 3:
|
|
218
|
+
console.print()
|
|
219
|
+
continue
|
|
220
|
+
elif action == 2:
|
|
221
|
+
edit_config = True
|
|
222
|
+
|
|
223
|
+
break
|
|
224
|
+
|
|
225
|
+
# Resolve details based on selection or prompts
|
|
226
|
+
if selected_config and not edit_config:
|
|
227
|
+
host = selected_config.get("host")
|
|
228
|
+
username = selected_config.get("username")
|
|
229
|
+
hf_token = selected_config.get("hf_token")
|
|
230
|
+
hf_repo = selected_config.get("hf_repo")
|
|
231
|
+
|
|
232
|
+
# Only prompt for the password
|
|
233
|
+
password = click.prompt(
|
|
234
|
+
click.style(f"Enter SSH Password for {username}", fg="green", bold=True),
|
|
235
|
+
hide_input=True,
|
|
236
|
+
type=str
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# If there is no hf_token in the cache, ask for it!
|
|
240
|
+
if not hf_token:
|
|
241
|
+
hf_token = click.prompt(
|
|
242
|
+
click.style("Enter Hugging Face API Token", fg="green", bold=True),
|
|
243
|
+
hide_input=True,
|
|
244
|
+
type=str
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
# Defaults based on selection or the most recent run in history
|
|
248
|
+
default_host = selected_config.get("host") if selected_config else (host or "dgx0.chapman.edu")
|
|
249
|
+
default_username = selected_config.get("username") if selected_config else (username or (history[0]["username"] if history else None))
|
|
250
|
+
default_token = selected_config.get("hf_token") if selected_config else (history[0]["hf_token"] if history else None)
|
|
251
|
+
default_repo = selected_config.get("hf_repo") if selected_config else (history[0]["hf_repo"] if history else None)
|
|
252
|
+
|
|
253
|
+
host = click.prompt(
|
|
254
|
+
click.style("Enter Chapman Cluster Host", fg="green", bold=True),
|
|
255
|
+
default=default_host,
|
|
256
|
+
type=str
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
username = click.prompt(
|
|
260
|
+
click.style("Enter SSH Username", fg="green", bold=True),
|
|
261
|
+
default=default_username,
|
|
262
|
+
type=str
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
password = click.prompt(
|
|
266
|
+
click.style(f"Enter SSH Password for {username}", fg="green", bold=True),
|
|
267
|
+
hide_input=True,
|
|
268
|
+
type=str
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
hf_token = click.prompt(
|
|
272
|
+
click.style("Enter Hugging Face API Token", fg="green", bold=True),
|
|
273
|
+
default=default_token,
|
|
274
|
+
hide_input=True,
|
|
275
|
+
type=str
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
hf_repo = click.prompt(
|
|
279
|
+
click.style("Enter Hugging Face Model Repository", fg="green", bold=True),
|
|
280
|
+
default=default_repo,
|
|
281
|
+
type=str
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
ssh_client = None
|
|
285
|
+
tunnel = None
|
|
286
|
+
channel = None
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
# Step 1: Connect to the cluster
|
|
290
|
+
with console.status(f"[bold cyan]Connecting to {host}...[/bold cyan]") as status:
|
|
291
|
+
ssh_client = connect_ssh(host, username, password)
|
|
292
|
+
console.print(f"[green]* Connected to {host} successfully.[/green]")
|
|
293
|
+
|
|
294
|
+
# Step 2: Generate and verify remote port
|
|
295
|
+
with console.status("[bold cyan]Searching for an open port on the cluster...[/bold cyan]") as status:
|
|
296
|
+
port = find_free_remote_port(ssh_client)
|
|
297
|
+
console.print(f"[green]* Reserved free remote port: [bold]{port}[/bold][/green]")
|
|
298
|
+
|
|
299
|
+
# Step 3: Setup steerable-model-runner repo
|
|
300
|
+
with console.status("[bold cyan]Cloning/updating repository 'steerable-model-runner' on the cluster...[/bold cyan]") as status:
|
|
301
|
+
setup_remote_repo(
|
|
302
|
+
ssh_client,
|
|
303
|
+
repo_url="https://github.com/Alignment-Faking-Chapman/steerable-model-runner.git",
|
|
304
|
+
target_dir="~/.steerable-models"
|
|
305
|
+
)
|
|
306
|
+
console.print("[green]* Repository is set up and up-to-date in ~/.steerable-models.[/green]")
|
|
307
|
+
|
|
308
|
+
# Configuration succeeded, save to cache
|
|
309
|
+
save_cache(host, username, hf_token, hf_repo)
|
|
310
|
+
|
|
311
|
+
# Step 4: Setup local tunnel
|
|
312
|
+
tunnel = SSHTunnel(ssh_client, port)
|
|
313
|
+
tunnel.start()
|
|
314
|
+
console.print(f"[green]* Local SSH port forwarding active on port {port}.[/green]")
|
|
315
|
+
|
|
316
|
+
# Step 5: Start model server
|
|
317
|
+
console.print("[cyan]Starting model server on remote cluster...[/cyan]")
|
|
318
|
+
channel = start_model_server(
|
|
319
|
+
ssh_client,
|
|
320
|
+
repo_dir="~/.steerable-models",
|
|
321
|
+
hf_token=hf_token,
|
|
322
|
+
hf_repo=hf_repo,
|
|
323
|
+
port=port
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Trigger Open WebUI startup if Docker is running
|
|
327
|
+
webui_port = start_open_webui(port)
|
|
328
|
+
|
|
329
|
+
# Output the OpenAI endpoint
|
|
330
|
+
endpoint_url = f"https://localhost:{port}/v1/"
|
|
331
|
+
|
|
332
|
+
# Display the endpoint beautifully
|
|
333
|
+
endpoint_text = Text()
|
|
334
|
+
endpoint_text.append("\nModel server is launching!\n\n", style="bold green")
|
|
335
|
+
endpoint_text.append("Your OpenAI-compatible local endpoint is:\n", style="white")
|
|
336
|
+
endpoint_text.append(f" {endpoint_url}\n\n", style="bold underline yellow")
|
|
337
|
+
|
|
338
|
+
if webui_port:
|
|
339
|
+
webui_url = f"http://localhost:{webui_port}"
|
|
340
|
+
endpoint_text.append("Open WebUI is running at:\n", style="white")
|
|
341
|
+
endpoint_text.append(f" {webui_url}\n\n", style="bold underline yellow")
|
|
342
|
+
endpoint_text.append("Note: The model server is still downloading and loading weights.\n", style="cyan")
|
|
343
|
+
endpoint_text.append("The WebUI will become active once startup is complete.\n\n", style="cyan")
|
|
344
|
+
|
|
345
|
+
endpoint_text.append("Streaming remote logs below. Press ", style="white")
|
|
346
|
+
endpoint_text.append("Ctrl+C", style="bold red")
|
|
347
|
+
endpoint_text.append(" to stop the server and close the tunnel.\n", style="white")
|
|
348
|
+
|
|
349
|
+
console.print(Panel(endpoint_text, title="Endpoint Ready", border_style="cyan"))
|
|
350
|
+
|
|
351
|
+
# Keep reading remote logs and forward to local terminal
|
|
352
|
+
while True:
|
|
353
|
+
if channel.recv_ready():
|
|
354
|
+
data = channel.recv(4096)
|
|
355
|
+
if len(data) == 0:
|
|
356
|
+
break
|
|
357
|
+
sys.stdout.buffer.write(data)
|
|
358
|
+
sys.stdout.flush()
|
|
359
|
+
|
|
360
|
+
if channel.recv_stderr_ready():
|
|
361
|
+
data = channel.recv_stderr(4096)
|
|
362
|
+
if len(data) == 0:
|
|
363
|
+
break
|
|
364
|
+
sys.stderr.buffer.write(data)
|
|
365
|
+
sys.stderr.flush()
|
|
366
|
+
|
|
367
|
+
if channel.exit_status_ready():
|
|
368
|
+
exit_code = channel.recv_exit_status()
|
|
369
|
+
console.print(f"\n[yellow]Remote process exited with status {exit_code}.[/yellow]")
|
|
370
|
+
break
|
|
371
|
+
|
|
372
|
+
time.sleep(0.01)
|
|
373
|
+
|
|
374
|
+
sys.exit(0)
|
|
375
|
+
|
|
376
|
+
except KeyboardInterrupt:
|
|
377
|
+
console.print("\n[yellow]Received KeyboardInterrupt. Shutting down...[/yellow]")
|
|
378
|
+
sys.exit(0)
|
|
379
|
+
except Exception as e:
|
|
380
|
+
console.print(f"\n[bold red]Error: {e}[/bold red]")
|
|
381
|
+
sys.exit(1)
|
|
382
|
+
finally:
|
|
383
|
+
# Clean up
|
|
384
|
+
if channel:
|
|
385
|
+
console.print("[cyan]Terminating remote model server process...[/cyan]")
|
|
386
|
+
try:
|
|
387
|
+
# Send Ctrl+C (INT) to the pty
|
|
388
|
+
channel.send("\x03")
|
|
389
|
+
# Wait briefly
|
|
390
|
+
for _ in range(15):
|
|
391
|
+
if channel.exit_status_ready():
|
|
392
|
+
break
|
|
393
|
+
time.sleep(0.1)
|
|
394
|
+
channel.close()
|
|
395
|
+
except Exception:
|
|
396
|
+
pass
|
|
397
|
+
|
|
398
|
+
if tunnel:
|
|
399
|
+
console.print("[cyan]Stopping SSH tunnel...[/cyan]")
|
|
400
|
+
tunnel.stop()
|
|
401
|
+
|
|
402
|
+
if ssh_client:
|
|
403
|
+
console.print("[cyan]Closing SSH connection...[/cyan]")
|
|
404
|
+
ssh_client.close()
|
|
405
|
+
|
|
406
|
+
console.print("[green]* Cleaned up successfully. Goodbye![/green]")
|
|
407
|
+
|
|
408
|
+
if __name__ == "__main__":
|
|
409
|
+
main()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import time
|
|
3
|
+
import paramiko
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
def connect_ssh(host, username, password, port=22):
|
|
9
|
+
"""
|
|
10
|
+
Establishes an SSH connection to the Chapman cluster.
|
|
11
|
+
"""
|
|
12
|
+
client = paramiko.SSHClient()
|
|
13
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
client.connect(
|
|
17
|
+
hostname=host,
|
|
18
|
+
port=port,
|
|
19
|
+
username=username,
|
|
20
|
+
password=password,
|
|
21
|
+
timeout=15,
|
|
22
|
+
look_for_keys=False,
|
|
23
|
+
allow_agent=False
|
|
24
|
+
)
|
|
25
|
+
return client
|
|
26
|
+
except Exception as e:
|
|
27
|
+
console.print(f"[red]Error connecting to {host}: {e}[/red]")
|
|
28
|
+
raise
|
|
29
|
+
|
|
30
|
+
def is_port_free_remote(ssh_client, port):
|
|
31
|
+
"""
|
|
32
|
+
Checks if a port is free on the remote machine by attempting to bind to it via Python.
|
|
33
|
+
"""
|
|
34
|
+
# Run a quick python bind test on remote
|
|
35
|
+
cmd = f"python3 -c \"import socket; s = socket.socket(); s.bind(('127.0.0.1', {port}))\""
|
|
36
|
+
stdin, stdout, stderr = ssh_client.exec_command(cmd)
|
|
37
|
+
|
|
38
|
+
# Wait for the command to finish
|
|
39
|
+
exit_status = stdout.channel.recv_exit_status()
|
|
40
|
+
return exit_status == 0
|
|
41
|
+
|
|
42
|
+
def find_free_remote_port(ssh_client, min_port=15000, max_port=60000, max_attempts=20):
|
|
43
|
+
"""
|
|
44
|
+
Finds a random free port on the remote machine.
|
|
45
|
+
"""
|
|
46
|
+
for _ in range(max_attempts):
|
|
47
|
+
port = random.randint(min_port, max_port)
|
|
48
|
+
if is_port_free_remote(ssh_client, port):
|
|
49
|
+
return port
|
|
50
|
+
raise RuntimeError("Could not find a free port on the remote host after several attempts.")
|
|
51
|
+
|
|
52
|
+
def setup_remote_repo(ssh_client, repo_url, target_dir="~/.steerable-models"):
|
|
53
|
+
"""
|
|
54
|
+
Ensures the target repository is cloned and updated on the cluster.
|
|
55
|
+
"""
|
|
56
|
+
# Shell commands to check if git repo exists, if not clone it, else pull it.
|
|
57
|
+
setup_cmd = f"""
|
|
58
|
+
if [ ! -d {target_dir}/.git ]; then
|
|
59
|
+
rm -rf {target_dir}
|
|
60
|
+
git clone {repo_url} {target_dir}
|
|
61
|
+
else
|
|
62
|
+
cd {target_dir} && git pull
|
|
63
|
+
fi
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
stdin, stdout, stderr = ssh_client.exec_command(setup_cmd)
|
|
67
|
+
|
|
68
|
+
# Read output to print if needed and wait for completion
|
|
69
|
+
exit_status = stdout.channel.recv_exit_status()
|
|
70
|
+
if exit_status != 0:
|
|
71
|
+
err_msg = stderr.read().decode().strip()
|
|
72
|
+
out_msg = stdout.read().decode().strip()
|
|
73
|
+
console.print(f"[red]Failed to setup repository on remote machine.[/red]")
|
|
74
|
+
if out_msg:
|
|
75
|
+
console.print(f"[yellow]Stdout:[/yellow]\n{out_msg}")
|
|
76
|
+
if err_msg:
|
|
77
|
+
console.print(f"[red]Stderr:[/red]\n{err_msg}")
|
|
78
|
+
raise RuntimeError("Failed to set up remote repository.")
|
|
79
|
+
|
|
80
|
+
def start_model_server(ssh_client, repo_dir, hf_token, hf_repo, port):
|
|
81
|
+
"""
|
|
82
|
+
Starts the model server remotely using a PTY session and returns the channel.
|
|
83
|
+
"""
|
|
84
|
+
transport = ssh_client.get_transport()
|
|
85
|
+
if not transport:
|
|
86
|
+
raise RuntimeError("SSH Transport is not active.")
|
|
87
|
+
|
|
88
|
+
channel = transport.open_session()
|
|
89
|
+
# Request a PTY to ensure child processes are terminated when the session is closed,
|
|
90
|
+
# and to enable real-time ANSI log formatting.
|
|
91
|
+
channel.get_pty()
|
|
92
|
+
|
|
93
|
+
cmd = (
|
|
94
|
+
f"chmod +x {repo_dir}/run.sh && "
|
|
95
|
+
f"cd {repo_dir} && "
|
|
96
|
+
f"./run.sh --hf_token '{hf_token}' --hf_repo '{hf_repo}' --port {port}"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
channel.exec_command(cmd)
|
|
100
|
+
return channel
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import select
|
|
2
|
+
import socketserver
|
|
3
|
+
import threading
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
class ForwardServer(socketserver.ThreadingTCPServer):
|
|
9
|
+
daemon_threads = True
|
|
10
|
+
allow_reuse_address = True
|
|
11
|
+
|
|
12
|
+
class Handler(socketserver.BaseRequestHandler):
|
|
13
|
+
"""
|
|
14
|
+
Handles local requests and tunnels them through the Paramiko SSH transport channel.
|
|
15
|
+
"""
|
|
16
|
+
def handle(self):
|
|
17
|
+
try:
|
|
18
|
+
chan = self.ssh_transport.open_channel(
|
|
19
|
+
"direct-tcpip",
|
|
20
|
+
("127.0.0.1", self.chain_port),
|
|
21
|
+
self.request.getpeername(),
|
|
22
|
+
)
|
|
23
|
+
except Exception as e:
|
|
24
|
+
# Log connection issues at verbose/debug levels if needed
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
if chan is None:
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
while True:
|
|
32
|
+
r, w, x = select.select([self.request, chan], [], [])
|
|
33
|
+
if self.request in r:
|
|
34
|
+
data = self.request.recv(4096)
|
|
35
|
+
if len(data) == 0:
|
|
36
|
+
break
|
|
37
|
+
chan.sendall(data)
|
|
38
|
+
if chan in r:
|
|
39
|
+
data = chan.recv(4096)
|
|
40
|
+
if len(data) == 0:
|
|
41
|
+
break
|
|
42
|
+
self.request.sendall(data)
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
finally:
|
|
46
|
+
chan.close()
|
|
47
|
+
self.request.close()
|
|
48
|
+
|
|
49
|
+
class SSHTunnel:
|
|
50
|
+
"""
|
|
51
|
+
Manager for setting up and tearing down the SSH port-forwarding tunnel.
|
|
52
|
+
"""
|
|
53
|
+
def __init__(self, ssh_client, port):
|
|
54
|
+
self.ssh_transport = ssh_client.get_transport()
|
|
55
|
+
self.port = port
|
|
56
|
+
self.server = None
|
|
57
|
+
self.thread = None
|
|
58
|
+
|
|
59
|
+
def start(self):
|
|
60
|
+
# Create a dynamically subclassed handler to inject the transport and target port
|
|
61
|
+
class CustomHandler(Handler):
|
|
62
|
+
ssh_transport = self.ssh_transport
|
|
63
|
+
chain_port = self.port
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
self.server = ForwardServer(("127.0.0.1", self.port), CustomHandler)
|
|
67
|
+
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
|
68
|
+
self.thread.start()
|
|
69
|
+
except Exception as e:
|
|
70
|
+
console.print(f"[red]Failed to start local forwarding server on port {self.port}: {e}[/red]")
|
|
71
|
+
raise
|
|
72
|
+
|
|
73
|
+
def stop(self):
|
|
74
|
+
if self.server:
|
|
75
|
+
self.server.shutdown()
|
|
76
|
+
self.server.server_close()
|
|
77
|
+
self.server = None
|
|
78
|
+
if self.thread:
|
|
79
|
+
self.thread.join(timeout=1.0)
|
|
80
|
+
self.thread = None
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chapman-sip
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A CLI tool to run steerable models on the Chapman cluster with SSH port forwarding
|
|
5
|
+
Author-email: Developer <developer@example.com>
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: paramiko>=3.0.0
|
|
12
|
+
Requires-Dist: click>=8.0.0
|
|
13
|
+
Requires-Dist: rich>=12.0.0
|
|
14
|
+
|
|
15
|
+
# Chapman-Sip CLI
|
|
16
|
+
|
|
17
|
+
A pip-installable Python command-line utility to connect to the Chapman cluster (`dgx0.chapman.edu`), pull the `steerable-model-runner` repository, find an open port, launch the model server, and tunnel the port back to your local machine.
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- **Automated Host Setup**: Checks if the remote repository is cloned at `~/.steerable-models` on the cluster, cloning or pulling changes automatically.
|
|
22
|
+
- **Dynamic Port Hunting**: Generates a random port and verifies it is not currently bound on the cluster before initiating.
|
|
23
|
+
- **SSH Port Forwarding**: Automatically forwards the selected remote port to `localhost:[PORT]` locally.
|
|
24
|
+
- **Interactive Log Streaming**: Streams stderr and stdout from the model serving script in real-time.
|
|
25
|
+
- **Safe Shutdown**: Automatically cleans up the remote process, closes the port-forwarding server, and closes SSH channels on Ctrl+C.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
Install in editable mode for development or directly from the source directory:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install -e .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
Simply run:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
sip
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
You will be prompted for:
|
|
44
|
+
- SSH username
|
|
45
|
+
- SSH password
|
|
46
|
+
- Hugging Face API token (for model download)
|
|
47
|
+
- Hugging Face repository name (e.g., the model repository to run)
|
|
48
|
+
|
|
49
|
+
Once connected, the utility will output the endpoint URL, e.g.:
|
|
50
|
+
```
|
|
51
|
+
https://localhost:[PORT]/v1/
|
|
52
|
+
```
|
|
53
|
+
Keep the CLI running while you query the endpoint. Press `Ctrl+C` to terminate the model server and close the tunnel.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/chapman_runner/__init__.py
|
|
4
|
+
src/chapman_runner/cli.py
|
|
5
|
+
src/chapman_runner/ssh.py
|
|
6
|
+
src/chapman_runner/tunnel.py
|
|
7
|
+
src/chapman_sip.egg-info/PKG-INFO
|
|
8
|
+
src/chapman_sip.egg-info/SOURCES.txt
|
|
9
|
+
src/chapman_sip.egg-info/dependency_links.txt
|
|
10
|
+
src/chapman_sip.egg-info/entry_points.txt
|
|
11
|
+
src/chapman_sip.egg-info/requires.txt
|
|
12
|
+
src/chapman_sip.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
chapman_runner
|