gitarsenal-cli 1.1.2 → 1.1.4
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.
- package/package.json +1 -1
- package/python/MODAL_PROXY_README.md +126 -0
- package/python/README.md +120 -0
- package/python/__pycache__/gitarsenal_proxy_client.cpython-313.pyc +0 -0
- package/python/gitarsenal.py +460 -95
- package/python/gitarsenal_proxy_client.py +408 -0
- package/python/modal_proxy_service.py +336 -0
- package/python/requirements.txt +3 -1
- package/python/test_modalSandboxScript.py +54 -1
@@ -0,0 +1,408 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
GitArsenal Proxy Client - Integration for Modal Proxy Service
|
4
|
+
|
5
|
+
This module provides integration between gitarsenal-cli and the Modal proxy service,
|
6
|
+
allowing users to use Modal services without exposing the server's Modal token.
|
7
|
+
"""
|
8
|
+
|
9
|
+
import os
|
10
|
+
import json
|
11
|
+
import requests
|
12
|
+
import time
|
13
|
+
import sys
|
14
|
+
from pathlib import Path
|
15
|
+
import getpass
|
16
|
+
|
17
|
+
class GitArsenalProxyClient:
|
18
|
+
"""Client for interacting with the Modal Proxy Service from gitarsenal-cli"""
|
19
|
+
|
20
|
+
def __init__(self, base_url=None, api_key=None):
|
21
|
+
"""Initialize the client with base URL and API key"""
|
22
|
+
self.base_url = base_url or os.environ.get("MODAL_PROXY_URL")
|
23
|
+
self.api_key = api_key or os.environ.get("MODAL_PROXY_API_KEY")
|
24
|
+
|
25
|
+
# If no URL/API key provided, try to load from config
|
26
|
+
if not self.base_url or not self.api_key:
|
27
|
+
config = self.load_config()
|
28
|
+
if not self.base_url:
|
29
|
+
self.base_url = config.get("proxy_url")
|
30
|
+
if not self.api_key:
|
31
|
+
self.api_key = config.get("api_key")
|
32
|
+
|
33
|
+
# If still no URL, use default
|
34
|
+
if not self.base_url:
|
35
|
+
self.base_url = "https://e74889c63199.ngrok-free.app" # Default to ngrok URL
|
36
|
+
|
37
|
+
# Warn if no API key
|
38
|
+
if not self.api_key:
|
39
|
+
print("⚠️ No API key provided. You will need to authenticate with the proxy service.")
|
40
|
+
|
41
|
+
def load_config(self):
|
42
|
+
"""Load proxy configuration from user's home directory"""
|
43
|
+
config_file = Path.home() / ".gitarsenal" / "proxy_config.json"
|
44
|
+
if not config_file.exists():
|
45
|
+
return {}
|
46
|
+
|
47
|
+
try:
|
48
|
+
with open(config_file, 'r') as f:
|
49
|
+
return json.load(f)
|
50
|
+
except (json.JSONDecodeError, IOError):
|
51
|
+
return {}
|
52
|
+
|
53
|
+
def save_config(self, config):
|
54
|
+
"""Save proxy configuration to user's home directory"""
|
55
|
+
config_dir = Path.home() / ".gitarsenal"
|
56
|
+
config_file = config_dir / "proxy_config.json"
|
57
|
+
|
58
|
+
# Ensure directory exists
|
59
|
+
if not config_dir.exists():
|
60
|
+
config_dir.mkdir(parents=True)
|
61
|
+
# Set restrictive permissions on Unix-like systems
|
62
|
+
if os.name == 'posix':
|
63
|
+
config_dir.chmod(0o700) # Only owner can read/write/execute
|
64
|
+
|
65
|
+
try:
|
66
|
+
with open(config_file, 'w') as f:
|
67
|
+
json.dump(config, f)
|
68
|
+
|
69
|
+
# Set restrictive permissions on Unix-like systems
|
70
|
+
if os.name == 'posix':
|
71
|
+
config_file.chmod(0o600) # Only owner can read/write
|
72
|
+
|
73
|
+
return True
|
74
|
+
except IOError as e:
|
75
|
+
print(f"❌ Error saving proxy configuration: {e}")
|
76
|
+
return False
|
77
|
+
|
78
|
+
def configure(self, interactive=True):
|
79
|
+
"""Configure the proxy client with URL and API key"""
|
80
|
+
config = self.load_config()
|
81
|
+
|
82
|
+
if interactive:
|
83
|
+
print("\n" + "="*60)
|
84
|
+
print("🔧 MODAL PROXY CONFIGURATION")
|
85
|
+
print("="*60)
|
86
|
+
print("Configure GitArsenal to use a Modal proxy service.")
|
87
|
+
print("This allows you to use Modal services without having your own Modal token.")
|
88
|
+
print("-" * 60)
|
89
|
+
|
90
|
+
# Get proxy URL
|
91
|
+
default_url = config.get("proxy_url", self.base_url)
|
92
|
+
print(f"\nEnter the URL of the Modal proxy service")
|
93
|
+
print(f"(Press Enter to use default: {default_url})")
|
94
|
+
proxy_url = input("Proxy URL: ").strip()
|
95
|
+
if not proxy_url:
|
96
|
+
proxy_url = default_url
|
97
|
+
|
98
|
+
# Get API key
|
99
|
+
print("\nEnter your API key for the Modal proxy service")
|
100
|
+
print("(Contact the proxy service administrator if you don't have one)")
|
101
|
+
api_key = getpass.getpass("API Key (hidden): ").strip()
|
102
|
+
|
103
|
+
# Save configuration
|
104
|
+
config["proxy_url"] = proxy_url
|
105
|
+
if api_key:
|
106
|
+
config["api_key"] = api_key
|
107
|
+
|
108
|
+
self.save_config(config)
|
109
|
+
|
110
|
+
# Update current instance
|
111
|
+
self.base_url = proxy_url
|
112
|
+
if api_key:
|
113
|
+
self.api_key = api_key
|
114
|
+
|
115
|
+
print("\n✅ Proxy configuration saved successfully!")
|
116
|
+
return True
|
117
|
+
else:
|
118
|
+
# Non-interactive configuration
|
119
|
+
if "proxy_url" in config:
|
120
|
+
self.base_url = config["proxy_url"]
|
121
|
+
if "api_key" in config:
|
122
|
+
self.api_key = config["api_key"]
|
123
|
+
return "proxy_url" in config and "api_key" in config
|
124
|
+
|
125
|
+
def _make_request(self, method, endpoint, data=None, params=None):
|
126
|
+
"""Make a request to the proxy service"""
|
127
|
+
url = f"{self.base_url}{endpoint}"
|
128
|
+
headers = {"X-API-Key": self.api_key} if self.api_key else {}
|
129
|
+
|
130
|
+
try:
|
131
|
+
if method.lower() == "get":
|
132
|
+
response = requests.get(url, headers=headers, params=params, timeout=30)
|
133
|
+
elif method.lower() == "post":
|
134
|
+
response = requests.post(url, headers=headers, json=data, timeout=30)
|
135
|
+
else:
|
136
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
137
|
+
|
138
|
+
# Check if the response is valid JSON
|
139
|
+
try:
|
140
|
+
response_data = response.json()
|
141
|
+
except json.JSONDecodeError:
|
142
|
+
return {
|
143
|
+
"success": False,
|
144
|
+
"error": f"Invalid JSON response: {response.text[:100]}...",
|
145
|
+
"status_code": response.status_code
|
146
|
+
}
|
147
|
+
|
148
|
+
# Check for errors
|
149
|
+
if response.status_code >= 400:
|
150
|
+
return {
|
151
|
+
"success": False,
|
152
|
+
"error": response_data.get("error", "Unknown error"),
|
153
|
+
"status_code": response.status_code
|
154
|
+
}
|
155
|
+
|
156
|
+
# Return successful response
|
157
|
+
return {
|
158
|
+
"success": True,
|
159
|
+
"data": response_data,
|
160
|
+
"status_code": response.status_code
|
161
|
+
}
|
162
|
+
|
163
|
+
except requests.exceptions.RequestException as e:
|
164
|
+
return {
|
165
|
+
"success": False,
|
166
|
+
"error": f"Request failed: {str(e)}",
|
167
|
+
"status_code": None
|
168
|
+
}
|
169
|
+
|
170
|
+
def health_check(self):
|
171
|
+
"""Check if the proxy service is running"""
|
172
|
+
return self._make_request("get", "/api/health")
|
173
|
+
|
174
|
+
def create_sandbox(self, gpu_type="A10G", repo_url=None, repo_name=None,
|
175
|
+
setup_commands=None, volume_name=None, wait=False):
|
176
|
+
"""Create a Modal sandbox through the proxy service"""
|
177
|
+
data = {
|
178
|
+
"gpu_type": gpu_type,
|
179
|
+
"repo_url": repo_url,
|
180
|
+
"repo_name": repo_name,
|
181
|
+
"setup_commands": setup_commands or [],
|
182
|
+
"volume_name": volume_name
|
183
|
+
}
|
184
|
+
|
185
|
+
response = self._make_request("post", "/api/create-sandbox", data=data)
|
186
|
+
|
187
|
+
if not response["success"]:
|
188
|
+
print(f"❌ Failed to create sandbox: {response['error']}")
|
189
|
+
return None
|
190
|
+
|
191
|
+
sandbox_id = response["data"].get("sandbox_id")
|
192
|
+
print(f"🚀 Sandbox creation started. ID: {sandbox_id}")
|
193
|
+
|
194
|
+
# If wait is True, poll for sandbox status
|
195
|
+
if wait and sandbox_id:
|
196
|
+
print("⏳ Waiting for sandbox to be ready...")
|
197
|
+
max_wait_time = 300 # 5 minutes
|
198
|
+
poll_interval = 10 # 10 seconds
|
199
|
+
start_time = time.time()
|
200
|
+
|
201
|
+
while time.time() - start_time < max_wait_time:
|
202
|
+
status_response = self.get_container_status(sandbox_id)
|
203
|
+
|
204
|
+
if status_response["success"]:
|
205
|
+
status_data = status_response["data"]
|
206
|
+
if status_data.get("status") == "active":
|
207
|
+
print(f"✅ Sandbox is ready!")
|
208
|
+
return status_data.get("info")
|
209
|
+
|
210
|
+
print(".", end="", flush=True)
|
211
|
+
time.sleep(poll_interval)
|
212
|
+
|
213
|
+
print("\n⚠️ Timed out waiting for sandbox to be ready")
|
214
|
+
|
215
|
+
return {"sandbox_id": sandbox_id}
|
216
|
+
|
217
|
+
def create_ssh_container(self, gpu_type="A10G", repo_url=None, repo_name=None,
|
218
|
+
setup_commands=None, volume_name=None, timeout=60, wait=False):
|
219
|
+
"""Create a Modal SSH container through the proxy service"""
|
220
|
+
# Verify we have a valid API key
|
221
|
+
if not self.api_key:
|
222
|
+
print("❌ No API key provided. Please configure the proxy client first:")
|
223
|
+
print(" ./gitarsenal.py proxy configure")
|
224
|
+
return None
|
225
|
+
|
226
|
+
# Verify proxy URL is set
|
227
|
+
if not self.base_url:
|
228
|
+
print("❌ No proxy URL provided. Please configure the proxy client first:")
|
229
|
+
print(" ./gitarsenal.py proxy configure")
|
230
|
+
return None
|
231
|
+
|
232
|
+
# Check if proxy is reachable
|
233
|
+
health = self.health_check()
|
234
|
+
if not health["success"]:
|
235
|
+
print(f"❌ Could not connect to proxy service at {self.base_url}")
|
236
|
+
print(f" Error: {health.get('error', 'Unknown error')}")
|
237
|
+
print(" Please check if the proxy service is running and properly configured.")
|
238
|
+
return None
|
239
|
+
|
240
|
+
print(f"✅ Connected to proxy service at {self.base_url}")
|
241
|
+
|
242
|
+
data = {
|
243
|
+
"gpu_type": gpu_type,
|
244
|
+
"repo_url": repo_url,
|
245
|
+
"repo_name": repo_name,
|
246
|
+
"setup_commands": setup_commands or [],
|
247
|
+
"volume_name": volume_name,
|
248
|
+
"timeout": timeout
|
249
|
+
}
|
250
|
+
|
251
|
+
print("🔄 Sending request to create SSH container...")
|
252
|
+
response = self._make_request("post", "/api/create-ssh-container", data=data)
|
253
|
+
|
254
|
+
if not response["success"]:
|
255
|
+
print(f"❌ Failed to create SSH container: {response['error']}")
|
256
|
+
print(f" Status code: {response.get('status_code', 'Unknown')}")
|
257
|
+
|
258
|
+
# Additional error handling for common issues
|
259
|
+
if response.get('status_code') == 401:
|
260
|
+
print(" Authentication failed. Please check your API key.")
|
261
|
+
print(" Run './gitarsenal.py proxy configure' to set up a new API key.")
|
262
|
+
elif response.get('status_code') == 500:
|
263
|
+
print(" Server error. The proxy service might be misconfigured.")
|
264
|
+
print(" Check if the MODAL_TOKEN is properly set on the server.")
|
265
|
+
|
266
|
+
return None
|
267
|
+
|
268
|
+
container_id = response["data"].get("container_id")
|
269
|
+
ssh_password = response["data"].get("ssh_password")
|
270
|
+
|
271
|
+
print(f"🚀 SSH container creation started. ID: {container_id}")
|
272
|
+
if ssh_password:
|
273
|
+
print(f"🔐 SSH Password: {ssh_password}")
|
274
|
+
|
275
|
+
# If wait is True, poll for container status
|
276
|
+
if wait and container_id:
|
277
|
+
print("⏳ Waiting for SSH container to be ready...")
|
278
|
+
max_wait_time = 300 # 5 minutes
|
279
|
+
poll_interval = 10 # 10 seconds
|
280
|
+
start_time = time.time()
|
281
|
+
|
282
|
+
while time.time() - start_time < max_wait_time:
|
283
|
+
status_response = self.get_container_status(container_id)
|
284
|
+
|
285
|
+
if status_response["success"]:
|
286
|
+
status_data = status_response["data"]
|
287
|
+
if status_data.get("status") == "active":
|
288
|
+
print(f"✅ SSH container is ready!")
|
289
|
+
container_info = status_data.get("info", {})
|
290
|
+
|
291
|
+
# Add the password back since it's removed in the status endpoint
|
292
|
+
container_info["ssh_password"] = ssh_password
|
293
|
+
|
294
|
+
return container_info
|
295
|
+
|
296
|
+
print(".", end="", flush=True)
|
297
|
+
time.sleep(poll_interval)
|
298
|
+
|
299
|
+
print("\n⚠️ Timed out waiting for SSH container to be ready")
|
300
|
+
print("The container may still be initializing. Check status with:")
|
301
|
+
print(f"./gitarsenal.py proxy status {container_id}")
|
302
|
+
|
303
|
+
return {"container_id": container_id, "ssh_password": ssh_password}
|
304
|
+
|
305
|
+
def get_container_status(self, container_id):
|
306
|
+
"""Get the status of a container"""
|
307
|
+
return self._make_request("get", f"/api/container-status/{container_id}")
|
308
|
+
|
309
|
+
def terminate_container(self, container_id):
|
310
|
+
"""Terminate a container"""
|
311
|
+
data = {"container_id": container_id}
|
312
|
+
return self._make_request("post", "/api/terminate-container", data=data)
|
313
|
+
|
314
|
+
|
315
|
+
if __name__ == "__main__":
|
316
|
+
# Example usage
|
317
|
+
if len(sys.argv) < 2:
|
318
|
+
print("Usage: python gitarsenal_proxy_client.py [command] [options]")
|
319
|
+
print("Commands: configure, health, create-sandbox, create-ssh, status, terminate")
|
320
|
+
sys.exit(1)
|
321
|
+
|
322
|
+
client = GitArsenalProxyClient()
|
323
|
+
command = sys.argv[1]
|
324
|
+
|
325
|
+
if command == "configure":
|
326
|
+
client.configure(interactive=True)
|
327
|
+
|
328
|
+
elif command == "health":
|
329
|
+
response = client.health_check()
|
330
|
+
if response["success"]:
|
331
|
+
print(f"✅ Proxy service is running: {response['data']['message']}")
|
332
|
+
else:
|
333
|
+
print(f"❌ Proxy service health check failed: {response['error']}")
|
334
|
+
|
335
|
+
elif command == "create-sandbox":
|
336
|
+
import argparse
|
337
|
+
parser = argparse.ArgumentParser(description="Create a Modal sandbox")
|
338
|
+
parser.add_argument("--gpu", type=str, default="A10G", help="GPU type")
|
339
|
+
parser.add_argument("--repo", type=str, help="Repository URL")
|
340
|
+
parser.add_argument("--name", type=str, help="Repository name")
|
341
|
+
parser.add_argument("--volume", type=str, help="Volume name")
|
342
|
+
parser.add_argument("--wait", action="store_true", help="Wait for sandbox to be ready")
|
343
|
+
args = parser.parse_args(sys.argv[2:])
|
344
|
+
|
345
|
+
result = client.create_sandbox(
|
346
|
+
gpu_type=args.gpu,
|
347
|
+
repo_url=args.repo,
|
348
|
+
repo_name=args.name,
|
349
|
+
volume_name=args.volume,
|
350
|
+
wait=args.wait
|
351
|
+
)
|
352
|
+
|
353
|
+
if result:
|
354
|
+
print(f"🚀 Sandbox creation initiated: {result}")
|
355
|
+
|
356
|
+
elif command == "create-ssh":
|
357
|
+
import argparse
|
358
|
+
parser = argparse.ArgumentParser(description="Create a Modal SSH container")
|
359
|
+
parser.add_argument("--gpu", type=str, default="A10G", help="GPU type")
|
360
|
+
parser.add_argument("--repo", type=str, help="Repository URL")
|
361
|
+
parser.add_argument("--name", type=str, help="Repository name")
|
362
|
+
parser.add_argument("--volume", type=str, help="Volume name")
|
363
|
+
parser.add_argument("--timeout", type=int, default=60, help="Timeout in minutes")
|
364
|
+
parser.add_argument("--wait", action="store_true", help="Wait for container to be ready")
|
365
|
+
args = parser.parse_args(sys.argv[2:])
|
366
|
+
|
367
|
+
result = client.create_ssh_container(
|
368
|
+
gpu_type=args.gpu,
|
369
|
+
repo_url=args.repo,
|
370
|
+
repo_name=args.name,
|
371
|
+
volume_name=args.volume,
|
372
|
+
timeout=args.timeout,
|
373
|
+
wait=args.wait
|
374
|
+
)
|
375
|
+
|
376
|
+
if result:
|
377
|
+
print(f"🚀 SSH container creation initiated: {result}")
|
378
|
+
|
379
|
+
elif command == "status":
|
380
|
+
if len(sys.argv) < 3:
|
381
|
+
print("Usage: python gitarsenal_proxy_client.py status [container_id]")
|
382
|
+
sys.exit(1)
|
383
|
+
|
384
|
+
container_id = sys.argv[2]
|
385
|
+
response = client.get_container_status(container_id)
|
386
|
+
|
387
|
+
if response["success"]:
|
388
|
+
print(f"✅ Container status: {json.dumps(response['data'], indent=2)}")
|
389
|
+
else:
|
390
|
+
print(f"❌ Failed to get container status: {response['error']}")
|
391
|
+
|
392
|
+
elif command == "terminate":
|
393
|
+
if len(sys.argv) < 3:
|
394
|
+
print("Usage: python gitarsenal_proxy_client.py terminate [container_id]")
|
395
|
+
sys.exit(1)
|
396
|
+
|
397
|
+
container_id = sys.argv[2]
|
398
|
+
response = client.terminate_container(container_id)
|
399
|
+
|
400
|
+
if response["success"]:
|
401
|
+
print(f"✅ Container terminated: {response['data']['message']}")
|
402
|
+
else:
|
403
|
+
print(f"❌ Failed to terminate container: {response['error']}")
|
404
|
+
|
405
|
+
else:
|
406
|
+
print(f"❌ Unknown command: {command}")
|
407
|
+
print("Available commands: configure, health, create-sandbox, create-ssh, status, terminate")
|
408
|
+
sys.exit(1)
|