gitarsenal-cli 1.1.1 → 1.1.3

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,375 @@
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 = "http://localhost:5001" # Default to 5001 to avoid macOS AirPlay conflict
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
+ data = {
221
+ "gpu_type": gpu_type,
222
+ "repo_url": repo_url,
223
+ "repo_name": repo_name,
224
+ "setup_commands": setup_commands or [],
225
+ "volume_name": volume_name,
226
+ "timeout": timeout
227
+ }
228
+
229
+ response = self._make_request("post", "/api/create-ssh-container", data=data)
230
+
231
+ if not response["success"]:
232
+ print(f"❌ Failed to create SSH container: {response['error']}")
233
+ return None
234
+
235
+ container_id = response["data"].get("container_id")
236
+ ssh_password = response["data"].get("ssh_password")
237
+
238
+ print(f"🚀 SSH container creation started. ID: {container_id}")
239
+ if ssh_password:
240
+ print(f"🔐 SSH Password: {ssh_password}")
241
+
242
+ # If wait is True, poll for container status
243
+ if wait and container_id:
244
+ print("⏳ Waiting for SSH container to be ready...")
245
+ max_wait_time = 300 # 5 minutes
246
+ poll_interval = 10 # 10 seconds
247
+ start_time = time.time()
248
+
249
+ while time.time() - start_time < max_wait_time:
250
+ status_response = self.get_container_status(container_id)
251
+
252
+ if status_response["success"]:
253
+ status_data = status_response["data"]
254
+ if status_data.get("status") == "active":
255
+ print(f"✅ SSH container is ready!")
256
+ return {
257
+ "container_id": container_id,
258
+ "ssh_password": ssh_password,
259
+ "info": status_data.get("info")
260
+ }
261
+
262
+ print(".", end="", flush=True)
263
+ time.sleep(poll_interval)
264
+
265
+ print("\n⚠️ Timed out waiting for SSH container to be ready")
266
+
267
+ return {
268
+ "container_id": container_id,
269
+ "ssh_password": ssh_password
270
+ }
271
+
272
+ def get_container_status(self, container_id):
273
+ """Get the status of a container"""
274
+ return self._make_request("get", f"/api/container-status/{container_id}")
275
+
276
+ def terminate_container(self, container_id):
277
+ """Terminate a container"""
278
+ data = {"container_id": container_id}
279
+ return self._make_request("post", "/api/terminate-container", data=data)
280
+
281
+
282
+ if __name__ == "__main__":
283
+ # Example usage
284
+ if len(sys.argv) < 2:
285
+ print("Usage: python gitarsenal_proxy_client.py [command] [options]")
286
+ print("Commands: configure, health, create-sandbox, create-ssh, status, terminate")
287
+ sys.exit(1)
288
+
289
+ client = GitArsenalProxyClient()
290
+ command = sys.argv[1]
291
+
292
+ if command == "configure":
293
+ client.configure(interactive=True)
294
+
295
+ elif command == "health":
296
+ response = client.health_check()
297
+ if response["success"]:
298
+ print(f"✅ Proxy service is running: {response['data']['message']}")
299
+ else:
300
+ print(f"❌ Proxy service health check failed: {response['error']}")
301
+
302
+ elif command == "create-sandbox":
303
+ import argparse
304
+ parser = argparse.ArgumentParser(description="Create a Modal sandbox")
305
+ parser.add_argument("--gpu", type=str, default="A10G", help="GPU type")
306
+ parser.add_argument("--repo", type=str, help="Repository URL")
307
+ parser.add_argument("--name", type=str, help="Repository name")
308
+ parser.add_argument("--volume", type=str, help="Volume name")
309
+ parser.add_argument("--wait", action="store_true", help="Wait for sandbox to be ready")
310
+ args = parser.parse_args(sys.argv[2:])
311
+
312
+ result = client.create_sandbox(
313
+ gpu_type=args.gpu,
314
+ repo_url=args.repo,
315
+ repo_name=args.name,
316
+ volume_name=args.volume,
317
+ wait=args.wait
318
+ )
319
+
320
+ if result:
321
+ print(f"🚀 Sandbox creation initiated: {result}")
322
+
323
+ elif command == "create-ssh":
324
+ import argparse
325
+ parser = argparse.ArgumentParser(description="Create a Modal SSH container")
326
+ parser.add_argument("--gpu", type=str, default="A10G", help="GPU type")
327
+ parser.add_argument("--repo", type=str, help="Repository URL")
328
+ parser.add_argument("--name", type=str, help="Repository name")
329
+ parser.add_argument("--volume", type=str, help="Volume name")
330
+ parser.add_argument("--timeout", type=int, default=60, help="Timeout in minutes")
331
+ parser.add_argument("--wait", action="store_true", help="Wait for container to be ready")
332
+ args = parser.parse_args(sys.argv[2:])
333
+
334
+ result = client.create_ssh_container(
335
+ gpu_type=args.gpu,
336
+ repo_url=args.repo,
337
+ repo_name=args.name,
338
+ volume_name=args.volume,
339
+ timeout=args.timeout,
340
+ wait=args.wait
341
+ )
342
+
343
+ if result:
344
+ print(f"🚀 SSH container creation initiated: {result}")
345
+
346
+ elif command == "status":
347
+ if len(sys.argv) < 3:
348
+ print("Usage: python gitarsenal_proxy_client.py status [container_id]")
349
+ sys.exit(1)
350
+
351
+ container_id = sys.argv[2]
352
+ response = client.get_container_status(container_id)
353
+
354
+ if response["success"]:
355
+ print(f"✅ Container status: {json.dumps(response['data'], indent=2)}")
356
+ else:
357
+ print(f"❌ Failed to get container status: {response['error']}")
358
+
359
+ elif command == "terminate":
360
+ if len(sys.argv) < 3:
361
+ print("Usage: python gitarsenal_proxy_client.py terminate [container_id]")
362
+ sys.exit(1)
363
+
364
+ container_id = sys.argv[2]
365
+ response = client.terminate_container(container_id)
366
+
367
+ if response["success"]:
368
+ print(f"✅ Container terminated: {response['data']['message']}")
369
+ else:
370
+ print(f"❌ Failed to terminate container: {response['error']}")
371
+
372
+ else:
373
+ print(f"❌ Unknown command: {command}")
374
+ print("Available commands: configure, health, create-sandbox, create-ssh, status, terminate")
375
+ sys.exit(1)
@@ -0,0 +1,317 @@
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
+ logger.info("Modal token set in environment")
86
+ return True
87
+ except Exception as e:
88
+ logger.error(f"Error setting up Modal authentication: {e}")
89
+ return False
90
+
91
+ @app.route('/api/health', methods=['GET'])
92
+ def health_check():
93
+ """Health check endpoint"""
94
+ return jsonify({"status": "ok", "message": "Modal proxy service is running"})
95
+
96
+ @app.route('/api/create-api-key', methods=['POST'])
97
+ def create_api_key():
98
+ """Create a new API key (protected by admin key)"""
99
+ admin_key = request.headers.get('X-Admin-Key')
100
+ if not admin_key or admin_key != os.environ.get("ADMIN_KEY"):
101
+ return jsonify({"error": "Unauthorized"}), 401
102
+
103
+ new_key = generate_api_key()
104
+ API_KEYS[new_key] = True
105
+
106
+ return jsonify({"api_key": new_key})
107
+
108
+ @app.route('/api/create-sandbox', methods=['POST'])
109
+ def create_sandbox():
110
+ """Create a Modal sandbox"""
111
+ if not authenticate_request():
112
+ return jsonify({"error": "Unauthorized"}), 401
113
+
114
+ if not setup_modal_auth():
115
+ return jsonify({"error": "Failed to set up Modal authentication"}), 500
116
+
117
+ try:
118
+ data = request.json
119
+ gpu_type = data.get('gpu_type', 'A10G')
120
+ repo_url = data.get('repo_url')
121
+ repo_name = data.get('repo_name')
122
+ setup_commands = data.get('setup_commands', [])
123
+ volume_name = data.get('volume_name')
124
+
125
+ # Import the sandbox creation function from your module
126
+ from test_modalSandboxScript import create_modal_sandbox
127
+
128
+ logger.info(f"Creating sandbox with GPU: {gpu_type}, Repo: {repo_url}")
129
+
130
+ # Create a unique ID for this sandbox
131
+ sandbox_id = str(uuid.uuid4())
132
+
133
+ # Start sandbox creation in a separate thread
134
+ def create_sandbox_thread():
135
+ try:
136
+ result = create_modal_sandbox(
137
+ gpu_type,
138
+ repo_url=repo_url,
139
+ repo_name=repo_name,
140
+ setup_commands=setup_commands,
141
+ volume_name=volume_name
142
+ )
143
+
144
+ if result:
145
+ active_containers[sandbox_id] = {
146
+ "container_id": result.get("container_id"),
147
+ "sandbox_id": result.get("sandbox_id"),
148
+ "created_at": time.time(),
149
+ "type": "sandbox"
150
+ }
151
+ logger.info(f"Sandbox created successfully: {result.get('container_id')}")
152
+ else:
153
+ logger.error("Failed to create sandbox")
154
+ except Exception as e:
155
+ logger.error(f"Error in sandbox creation thread: {e}")
156
+
157
+ thread = threading.Thread(target=create_sandbox_thread)
158
+ thread.daemon = True
159
+ thread.start()
160
+
161
+ return jsonify({
162
+ "message": "Sandbox creation started",
163
+ "sandbox_id": sandbox_id
164
+ })
165
+
166
+ except Exception as e:
167
+ logger.error(f"Error creating sandbox: {e}")
168
+ return jsonify({"error": str(e)}), 500
169
+
170
+ @app.route('/api/create-ssh-container', methods=['POST'])
171
+ def create_ssh_container():
172
+ """Create a Modal SSH container"""
173
+ if not authenticate_request():
174
+ return jsonify({"error": "Unauthorized"}), 401
175
+
176
+ if not setup_modal_auth():
177
+ return jsonify({"error": "Failed to set up Modal authentication"}), 500
178
+
179
+ try:
180
+ data = request.json
181
+ gpu_type = data.get('gpu_type', 'A10G')
182
+ repo_url = data.get('repo_url')
183
+ repo_name = data.get('repo_name')
184
+ setup_commands = data.get('setup_commands', [])
185
+ volume_name = data.get('volume_name')
186
+ timeout_minutes = data.get('timeout', 60)
187
+
188
+ # Generate a random password for SSH
189
+ ssh_password = generate_random_password()
190
+
191
+ # Import the SSH container creation function
192
+ from test_modalSandboxScript import create_modal_ssh_container
193
+
194
+ logger.info(f"Creating SSH container with GPU: {gpu_type}, Repo: {repo_url}")
195
+
196
+ # Create a unique ID for this container
197
+ container_id = str(uuid.uuid4())
198
+
199
+ # Start container creation in a separate thread
200
+ def create_container_thread():
201
+ try:
202
+ result = create_modal_ssh_container(
203
+ gpu_type,
204
+ repo_url=repo_url,
205
+ repo_name=repo_name,
206
+ setup_commands=setup_commands,
207
+ volume_name=volume_name,
208
+ timeout_minutes=timeout_minutes,
209
+ ssh_password=ssh_password
210
+ )
211
+
212
+ if result:
213
+ active_containers[container_id] = {
214
+ "container_id": result.get("app_name"),
215
+ "ssh_password": ssh_password,
216
+ "created_at": time.time(),
217
+ "type": "ssh"
218
+ }
219
+ logger.info(f"SSH container created successfully: {result.get('app_name')}")
220
+ else:
221
+ logger.error("Failed to create SSH container")
222
+ except Exception as e:
223
+ logger.error(f"Error in SSH container creation thread: {e}")
224
+
225
+ thread = threading.Thread(target=create_container_thread)
226
+ thread.daemon = True
227
+ thread.start()
228
+
229
+ return jsonify({
230
+ "message": "SSH container creation started",
231
+ "container_id": container_id,
232
+ "ssh_password": ssh_password
233
+ })
234
+
235
+ except Exception as e:
236
+ logger.error(f"Error creating SSH container: {e}")
237
+ return jsonify({"error": str(e)}), 500
238
+
239
+ @app.route('/api/container-status/<container_id>', methods=['GET'])
240
+ def container_status(container_id):
241
+ """Get status of a container"""
242
+ if not authenticate_request():
243
+ return jsonify({"error": "Unauthorized"}), 401
244
+
245
+ if container_id in active_containers:
246
+ # Remove sensitive information like passwords
247
+ container_info = active_containers[container_id].copy()
248
+ if "ssh_password" in container_info:
249
+ del container_info["ssh_password"]
250
+
251
+ return jsonify({
252
+ "status": "active",
253
+ "info": container_info
254
+ })
255
+ else:
256
+ return jsonify({
257
+ "status": "not_found",
258
+ "message": "Container not found or has been terminated"
259
+ }), 404
260
+
261
+ @app.route('/api/terminate-container', methods=['POST'])
262
+ def terminate_container():
263
+ """Terminate a Modal container"""
264
+ if not authenticate_request():
265
+ return jsonify({"error": "Unauthorized"}), 401
266
+
267
+ if not setup_modal_auth():
268
+ return jsonify({"error": "Failed to set up Modal authentication"}), 500
269
+
270
+ try:
271
+ data = request.json
272
+ container_id = data.get('container_id')
273
+
274
+ if not container_id:
275
+ return jsonify({"error": "Container ID is required"}), 400
276
+
277
+ if container_id not in active_containers:
278
+ return jsonify({"error": "Container not found"}), 404
279
+
280
+ modal_container_id = active_containers[container_id].get("container_id")
281
+
282
+ # Terminate the container using Modal CLI
283
+ import subprocess
284
+ result = subprocess.run(
285
+ ["modal", "container", "terminate", modal_container_id],
286
+ capture_output=True,
287
+ text=True
288
+ )
289
+
290
+ if result.returncode == 0:
291
+ # Remove from active containers
292
+ del active_containers[container_id]
293
+ logger.info(f"Container terminated successfully: {modal_container_id}")
294
+ return jsonify({"message": "Container terminated successfully"})
295
+ else:
296
+ logger.error(f"Failed to terminate container: {result.stderr}")
297
+ return jsonify({"error": f"Failed to terminate container: {result.stderr}"}), 500
298
+
299
+ except Exception as e:
300
+ logger.error(f"Error terminating container: {e}")
301
+ return jsonify({"error": str(e)}), 500
302
+
303
+ if __name__ == '__main__':
304
+ # Check if Modal token is set
305
+ if not MODAL_TOKEN:
306
+ logger.error("MODAL_TOKEN environment variable must be set!")
307
+ exit(1)
308
+
309
+ # Generate an admin key if not set
310
+ if not os.environ.get("ADMIN_KEY"):
311
+ admin_key = generate_api_key()
312
+ os.environ["ADMIN_KEY"] = admin_key
313
+ logger.info(f"Generated admin key: {admin_key}")
314
+ print(f"Admin key: {admin_key}")
315
+
316
+ port = int(os.environ.get("PORT", 5001)) # Default to 5001 to avoid macOS AirPlay conflict
317
+ app.run(host='0.0.0.0', port=port)
@@ -0,0 +1,6 @@
1
+ modal>=0.56.4
2
+ requests>=2.31.0
3
+ pathlib>=1.0.1
4
+ python-dotenv>=1.0.0
5
+ flask>=2.0.0
6
+ flask-cors>=3.0.0