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,336 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
Modal Proxy Service for GitArsenal CLI
|
4
|
+
|
5
|
+
This service allows GitArsenal CLI users to access Modal services without exposing your Modal token.
|
6
|
+
It acts as a secure proxy between clients and Modal's API.
|
7
|
+
"""
|
8
|
+
|
9
|
+
import os
|
10
|
+
import json
|
11
|
+
import secrets
|
12
|
+
import string
|
13
|
+
import time
|
14
|
+
from flask import Flask, request, jsonify
|
15
|
+
from flask_cors import CORS
|
16
|
+
import modal
|
17
|
+
import threading
|
18
|
+
import logging
|
19
|
+
from dotenv import load_dotenv
|
20
|
+
import uuid
|
21
|
+
import sys
|
22
|
+
from pathlib import Path
|
23
|
+
|
24
|
+
# Add the current directory to the path so we can import the test_modalSandboxScript module
|
25
|
+
current_dir = Path(__file__).parent.absolute()
|
26
|
+
sys.path.append(str(current_dir.parent))
|
27
|
+
|
28
|
+
# Load environment variables from .env file
|
29
|
+
load_dotenv()
|
30
|
+
|
31
|
+
# Configure logging
|
32
|
+
logging.basicConfig(
|
33
|
+
level=logging.INFO,
|
34
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
35
|
+
handlers=[
|
36
|
+
logging.FileHandler("modal_proxy.log"),
|
37
|
+
logging.StreamHandler()
|
38
|
+
]
|
39
|
+
)
|
40
|
+
logger = logging.getLogger("modal-proxy")
|
41
|
+
|
42
|
+
app = Flask(__name__)
|
43
|
+
CORS(app) # Enable CORS for all routes
|
44
|
+
|
45
|
+
# Get Modal token from environment variable
|
46
|
+
MODAL_TOKEN = os.environ.get("MODAL_TOKEN")
|
47
|
+
if not MODAL_TOKEN:
|
48
|
+
logger.error("MODAL_TOKEN environment variable is not set!")
|
49
|
+
|
50
|
+
# Dictionary to store active containers
|
51
|
+
active_containers = {}
|
52
|
+
|
53
|
+
# Authentication tokens for clients
|
54
|
+
# In a production environment, use a proper authentication system
|
55
|
+
API_KEYS = {}
|
56
|
+
if os.environ.get("API_KEYS"):
|
57
|
+
API_KEYS = {key.strip(): True for key in os.environ.get("API_KEYS").split(",")}
|
58
|
+
|
59
|
+
def generate_api_key():
|
60
|
+
"""Generate a new API key for a client"""
|
61
|
+
return ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(32))
|
62
|
+
|
63
|
+
def authenticate_request():
|
64
|
+
"""Authenticate the request using API key"""
|
65
|
+
api_key = request.headers.get('X-API-Key')
|
66
|
+
if not api_key or api_key not in API_KEYS:
|
67
|
+
return False
|
68
|
+
return True
|
69
|
+
|
70
|
+
def generate_random_password(length=16):
|
71
|
+
"""Generate a random password for SSH access"""
|
72
|
+
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
|
73
|
+
password = ''.join(secrets.choice(alphabet) for i in range(length))
|
74
|
+
return password
|
75
|
+
|
76
|
+
def setup_modal_auth():
|
77
|
+
"""Set up Modal authentication using the server's token"""
|
78
|
+
if not MODAL_TOKEN:
|
79
|
+
logger.error("Cannot set up Modal authentication: No token provided")
|
80
|
+
return False
|
81
|
+
|
82
|
+
try:
|
83
|
+
# Set the token in the environment
|
84
|
+
os.environ["MODAL_TOKEN_ID"] = MODAL_TOKEN
|
85
|
+
# Also set the token directly in modal.config
|
86
|
+
modal.config._auth_config.token_id = MODAL_TOKEN
|
87
|
+
logger.info("Modal token set in environment and config")
|
88
|
+
return True
|
89
|
+
except Exception as e:
|
90
|
+
logger.error(f"Error setting up Modal authentication: {e}")
|
91
|
+
return False
|
92
|
+
|
93
|
+
@app.route('/api/health', methods=['GET'])
|
94
|
+
def health_check():
|
95
|
+
"""Health check endpoint"""
|
96
|
+
return jsonify({"status": "ok", "message": "Modal proxy service is running"})
|
97
|
+
|
98
|
+
@app.route('/api/create-api-key', methods=['POST'])
|
99
|
+
def create_api_key():
|
100
|
+
"""Create a new API key (protected by admin key)"""
|
101
|
+
admin_key = request.headers.get('X-Admin-Key')
|
102
|
+
if not admin_key or admin_key != os.environ.get("ADMIN_KEY"):
|
103
|
+
return jsonify({"error": "Unauthorized"}), 401
|
104
|
+
|
105
|
+
new_key = generate_api_key()
|
106
|
+
API_KEYS[new_key] = True
|
107
|
+
|
108
|
+
return jsonify({"api_key": new_key})
|
109
|
+
|
110
|
+
@app.route('/api/create-sandbox', methods=['POST'])
|
111
|
+
def create_sandbox():
|
112
|
+
"""Create a Modal sandbox"""
|
113
|
+
if not authenticate_request():
|
114
|
+
return jsonify({"error": "Unauthorized"}), 401
|
115
|
+
|
116
|
+
if not setup_modal_auth():
|
117
|
+
return jsonify({"error": "Failed to set up Modal authentication"}), 500
|
118
|
+
|
119
|
+
try:
|
120
|
+
data = request.json
|
121
|
+
gpu_type = data.get('gpu_type', 'A10G')
|
122
|
+
repo_url = data.get('repo_url')
|
123
|
+
repo_name = data.get('repo_name')
|
124
|
+
setup_commands = data.get('setup_commands', [])
|
125
|
+
volume_name = data.get('volume_name')
|
126
|
+
|
127
|
+
# Import the sandbox creation function from your module
|
128
|
+
from test_modalSandboxScript import create_modal_sandbox
|
129
|
+
|
130
|
+
logger.info(f"Creating sandbox with GPU: {gpu_type}, Repo: {repo_url}")
|
131
|
+
|
132
|
+
# Create a unique ID for this sandbox
|
133
|
+
sandbox_id = str(uuid.uuid4())
|
134
|
+
|
135
|
+
# Start sandbox creation in a separate thread
|
136
|
+
def create_sandbox_thread():
|
137
|
+
try:
|
138
|
+
# Ensure Modal token is set before creating sandbox
|
139
|
+
if not setup_modal_auth():
|
140
|
+
logger.error("Failed to set up Modal authentication in thread")
|
141
|
+
return
|
142
|
+
|
143
|
+
result = create_modal_sandbox(
|
144
|
+
gpu_type,
|
145
|
+
repo_url=repo_url,
|
146
|
+
repo_name=repo_name,
|
147
|
+
setup_commands=setup_commands,
|
148
|
+
volume_name=volume_name
|
149
|
+
)
|
150
|
+
|
151
|
+
if result:
|
152
|
+
active_containers[sandbox_id] = {
|
153
|
+
"container_id": result.get("container_id"),
|
154
|
+
"sandbox_id": result.get("sandbox_id"),
|
155
|
+
"created_at": time.time(),
|
156
|
+
"type": "sandbox"
|
157
|
+
}
|
158
|
+
logger.info(f"Sandbox created successfully: {result.get('container_id')}")
|
159
|
+
else:
|
160
|
+
logger.error("Failed to create sandbox")
|
161
|
+
except Exception as e:
|
162
|
+
logger.error(f"Error in sandbox creation thread: {e}")
|
163
|
+
|
164
|
+
thread = threading.Thread(target=create_sandbox_thread)
|
165
|
+
thread.daemon = True
|
166
|
+
thread.start()
|
167
|
+
|
168
|
+
return jsonify({
|
169
|
+
"message": "Sandbox creation started",
|
170
|
+
"sandbox_id": sandbox_id
|
171
|
+
})
|
172
|
+
|
173
|
+
except Exception as e:
|
174
|
+
logger.error(f"Error creating sandbox: {e}")
|
175
|
+
return jsonify({"error": str(e)}), 500
|
176
|
+
|
177
|
+
@app.route('/api/create-ssh-container', methods=['POST'])
|
178
|
+
def create_ssh_container():
|
179
|
+
"""Create a Modal SSH container"""
|
180
|
+
if not authenticate_request():
|
181
|
+
return jsonify({"error": "Unauthorized"}), 401
|
182
|
+
|
183
|
+
if not setup_modal_auth():
|
184
|
+
return jsonify({"error": "Failed to set up Modal authentication"}), 500
|
185
|
+
|
186
|
+
try:
|
187
|
+
data = request.json
|
188
|
+
gpu_type = data.get('gpu_type', 'A10G')
|
189
|
+
repo_url = data.get('repo_url')
|
190
|
+
repo_name = data.get('repo_name')
|
191
|
+
setup_commands = data.get('setup_commands', [])
|
192
|
+
volume_name = data.get('volume_name')
|
193
|
+
timeout_minutes = data.get('timeout', 60)
|
194
|
+
|
195
|
+
# Generate a random password for SSH
|
196
|
+
ssh_password = generate_random_password()
|
197
|
+
|
198
|
+
# Import the SSH container creation function
|
199
|
+
from test_modalSandboxScript import create_modal_ssh_container
|
200
|
+
|
201
|
+
logger.info(f"Creating SSH container with GPU: {gpu_type}, Repo: {repo_url}")
|
202
|
+
|
203
|
+
# Create a unique ID for this container
|
204
|
+
container_id = str(uuid.uuid4())
|
205
|
+
|
206
|
+
# Start container creation in a separate thread
|
207
|
+
def create_container_thread():
|
208
|
+
try:
|
209
|
+
# Ensure Modal token is set before creating container
|
210
|
+
if not setup_modal_auth():
|
211
|
+
logger.error("Failed to set up Modal authentication in thread")
|
212
|
+
return
|
213
|
+
|
214
|
+
# Explicitly set the Modal token in the environment again for this thread
|
215
|
+
os.environ["MODAL_TOKEN_ID"] = MODAL_TOKEN
|
216
|
+
|
217
|
+
# Log token status for debugging
|
218
|
+
token_status = "Token is set" if MODAL_TOKEN else "Token is missing"
|
219
|
+
logger.info(f"Modal token status: {token_status}")
|
220
|
+
|
221
|
+
result = create_modal_ssh_container(
|
222
|
+
gpu_type,
|
223
|
+
repo_url=repo_url,
|
224
|
+
repo_name=repo_name,
|
225
|
+
setup_commands=setup_commands,
|
226
|
+
volume_name=volume_name,
|
227
|
+
timeout_minutes=timeout_minutes,
|
228
|
+
ssh_password=ssh_password
|
229
|
+
)
|
230
|
+
|
231
|
+
if result:
|
232
|
+
active_containers[container_id] = {
|
233
|
+
"container_id": result.get("app_name"),
|
234
|
+
"ssh_password": ssh_password,
|
235
|
+
"created_at": time.time(),
|
236
|
+
"type": "ssh"
|
237
|
+
}
|
238
|
+
logger.info(f"SSH container created successfully: {result.get('app_name')}")
|
239
|
+
else:
|
240
|
+
logger.error("Failed to create SSH container")
|
241
|
+
except Exception as e:
|
242
|
+
logger.error(f"Error in SSH container creation thread: {e}")
|
243
|
+
|
244
|
+
thread = threading.Thread(target=create_container_thread)
|
245
|
+
thread.daemon = True
|
246
|
+
thread.start()
|
247
|
+
|
248
|
+
return jsonify({
|
249
|
+
"message": "SSH container creation started",
|
250
|
+
"container_id": container_id,
|
251
|
+
"ssh_password": ssh_password
|
252
|
+
})
|
253
|
+
|
254
|
+
except Exception as e:
|
255
|
+
logger.error(f"Error creating SSH container: {e}")
|
256
|
+
return jsonify({"error": str(e)}), 500
|
257
|
+
|
258
|
+
@app.route('/api/container-status/<container_id>', methods=['GET'])
|
259
|
+
def container_status(container_id):
|
260
|
+
"""Get status of a container"""
|
261
|
+
if not authenticate_request():
|
262
|
+
return jsonify({"error": "Unauthorized"}), 401
|
263
|
+
|
264
|
+
if container_id in active_containers:
|
265
|
+
# Remove sensitive information like passwords
|
266
|
+
container_info = active_containers[container_id].copy()
|
267
|
+
if "ssh_password" in container_info:
|
268
|
+
del container_info["ssh_password"]
|
269
|
+
|
270
|
+
return jsonify({
|
271
|
+
"status": "active",
|
272
|
+
"info": container_info
|
273
|
+
})
|
274
|
+
else:
|
275
|
+
return jsonify({
|
276
|
+
"status": "not_found",
|
277
|
+
"message": "Container not found or has been terminated"
|
278
|
+
}), 404
|
279
|
+
|
280
|
+
@app.route('/api/terminate-container', methods=['POST'])
|
281
|
+
def terminate_container():
|
282
|
+
"""Terminate a Modal container"""
|
283
|
+
if not authenticate_request():
|
284
|
+
return jsonify({"error": "Unauthorized"}), 401
|
285
|
+
|
286
|
+
if not setup_modal_auth():
|
287
|
+
return jsonify({"error": "Failed to set up Modal authentication"}), 500
|
288
|
+
|
289
|
+
try:
|
290
|
+
data = request.json
|
291
|
+
container_id = data.get('container_id')
|
292
|
+
|
293
|
+
if not container_id:
|
294
|
+
return jsonify({"error": "Container ID is required"}), 400
|
295
|
+
|
296
|
+
if container_id not in active_containers:
|
297
|
+
return jsonify({"error": "Container not found"}), 404
|
298
|
+
|
299
|
+
modal_container_id = active_containers[container_id].get("container_id")
|
300
|
+
|
301
|
+
# Terminate the container using Modal CLI
|
302
|
+
import subprocess
|
303
|
+
result = subprocess.run(
|
304
|
+
["modal", "container", "terminate", modal_container_id],
|
305
|
+
capture_output=True,
|
306
|
+
text=True
|
307
|
+
)
|
308
|
+
|
309
|
+
if result.returncode == 0:
|
310
|
+
# Remove from active containers
|
311
|
+
del active_containers[container_id]
|
312
|
+
logger.info(f"Container terminated successfully: {modal_container_id}")
|
313
|
+
return jsonify({"message": "Container terminated successfully"})
|
314
|
+
else:
|
315
|
+
logger.error(f"Failed to terminate container: {result.stderr}")
|
316
|
+
return jsonify({"error": f"Failed to terminate container: {result.stderr}"}), 500
|
317
|
+
|
318
|
+
except Exception as e:
|
319
|
+
logger.error(f"Error terminating container: {e}")
|
320
|
+
return jsonify({"error": str(e)}), 500
|
321
|
+
|
322
|
+
if __name__ == '__main__':
|
323
|
+
# Check if Modal token is set
|
324
|
+
if not MODAL_TOKEN:
|
325
|
+
logger.error("MODAL_TOKEN environment variable must be set!")
|
326
|
+
exit(1)
|
327
|
+
|
328
|
+
# Generate an admin key if not set
|
329
|
+
if not os.environ.get("ADMIN_KEY"):
|
330
|
+
admin_key = generate_api_key()
|
331
|
+
os.environ["ADMIN_KEY"] = admin_key
|
332
|
+
logger.info(f"Generated admin key: {admin_key}")
|
333
|
+
print(f"Admin key: {admin_key}")
|
334
|
+
|
335
|
+
port = int(os.environ.get("PORT", 5001)) # Default to 5001 to avoid macOS AirPlay conflict
|
336
|
+
app.run(host='0.0.0.0', port=port)
|
package/python/requirements.txt
CHANGED
@@ -2067,6 +2067,38 @@ def create_modal_ssh_container(gpu_type, repo_url=None, repo_name=None, setup_co
|
|
2067
2067
|
volume_name=None, timeout_minutes=60, ssh_password=None):
|
2068
2068
|
"""Create a Modal SSH container with GPU support and tunneling"""
|
2069
2069
|
|
2070
|
+
# Check if Modal is authenticated
|
2071
|
+
try:
|
2072
|
+
# Try to access Modal token to check authentication
|
2073
|
+
try:
|
2074
|
+
# This will raise an exception if not authenticated
|
2075
|
+
modal.config.get_current_workspace_name()
|
2076
|
+
print("ā
Modal authentication verified")
|
2077
|
+
except modal.exception.AuthError:
|
2078
|
+
print("\n" + "="*80)
|
2079
|
+
print("š MODAL AUTHENTICATION REQUIRED")
|
2080
|
+
print("="*80)
|
2081
|
+
print("GitArsenal requires Modal authentication to create cloud environments.")
|
2082
|
+
|
2083
|
+
# Check if token is in environment
|
2084
|
+
modal_token = os.environ.get("MODAL_TOKEN_ID")
|
2085
|
+
if not modal_token:
|
2086
|
+
print("ā ļø No Modal token found in environment.")
|
2087
|
+
return None
|
2088
|
+
else:
|
2089
|
+
print("š Using Modal token from environment")
|
2090
|
+
# Try to authenticate with the token
|
2091
|
+
try:
|
2092
|
+
# Set token directly in modal config
|
2093
|
+
modal.config._auth_config.token_id = modal_token
|
2094
|
+
print("ā
Modal token set in config")
|
2095
|
+
except Exception as e:
|
2096
|
+
print(f"ā ļø Error setting Modal token: {e}")
|
2097
|
+
return None
|
2098
|
+
except Exception as e:
|
2099
|
+
print(f"ā ļø Error checking Modal authentication: {e}")
|
2100
|
+
print("Continuing anyway, but Modal operations may fail")
|
2101
|
+
|
2070
2102
|
# Generate a unique app name with timestamp to avoid conflicts
|
2071
2103
|
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
2072
2104
|
app_name = f"ssh-container-{timestamp}"
|
@@ -2116,6 +2148,18 @@ def create_modal_ssh_container(gpu_type, repo_url=None, repo_name=None, setup_co
|
|
2116
2148
|
print(f"ā ļø Could not create default volume: {e}")
|
2117
2149
|
print("ā ļø Continuing without persistent volume")
|
2118
2150
|
volume = None
|
2151
|
+
|
2152
|
+
# Print debug info for authentication
|
2153
|
+
print("š Modal authentication debug info:")
|
2154
|
+
modal_token = os.environ.get("MODAL_TOKEN_ID")
|
2155
|
+
print(f" - MODAL_TOKEN_ID in env: {'Yes' if modal_token else 'No'}")
|
2156
|
+
print(f" - Token length: {len(modal_token) if modal_token else 'N/A'}")
|
2157
|
+
|
2158
|
+
try:
|
2159
|
+
workspace = modal.config.get_current_workspace_name()
|
2160
|
+
print(f" - Current workspace: {workspace}")
|
2161
|
+
except Exception as e:
|
2162
|
+
print(f" - Error getting workspace: {e}")
|
2119
2163
|
|
2120
2164
|
# Create SSH-enabled image
|
2121
2165
|
ssh_image = (
|
@@ -2153,7 +2197,12 @@ def create_modal_ssh_container(gpu_type, repo_url=None, repo_name=None, setup_co
|
|
2153
2197
|
)
|
2154
2198
|
|
2155
2199
|
# Create the Modal app
|
2156
|
-
|
2200
|
+
try:
|
2201
|
+
app = modal.App(app_name)
|
2202
|
+
print("ā
Created Modal app successfully")
|
2203
|
+
except Exception as e:
|
2204
|
+
print(f"ā Error creating Modal app: {e}")
|
2205
|
+
return None
|
2157
2206
|
|
2158
2207
|
# Configure volumes if available
|
2159
2208
|
volumes_config = {}
|
@@ -2319,6 +2368,10 @@ def create_modal_ssh_container(gpu_type, repo_url=None, repo_name=None, setup_co
|
|
2319
2368
|
"volume_name": volume_name,
|
2320
2369
|
"volume_mount_path": volume_mount_path if volume else None
|
2321
2370
|
}
|
2371
|
+
except modal.exception.AuthError as auth_err:
|
2372
|
+
print(f"ā Modal authentication error: {auth_err}")
|
2373
|
+
print("š Please check that your Modal token is valid and properly set")
|
2374
|
+
return None
|
2322
2375
|
except KeyboardInterrupt:
|
2323
2376
|
print("\nš Container startup interrupted")
|
2324
2377
|
return None
|