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.
@@ -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)