gitarsenal-cli 1.1.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitarsenal-cli",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "CLI tool for creating Modal sandboxes with GitHub repositories",
5
5
  "main": "index.js",
6
6
  "bin": {
package/python/README.md CHANGED
@@ -68,6 +68,101 @@ This will guide you through setting up:
68
68
  - `--setup-commands`: Setup commands to run
69
69
  - `--volume-name`: Name of the Modal volume for persistent storage
70
70
  - `--timeout`: Container timeout in minutes (SSH mode only, default: 60)
71
+ - `--use-proxy`: Use Modal proxy service instead of direct Modal access
72
+
73
+ ## Using the Modal Proxy Service
74
+
75
+ GitArsenal CLI now supports using a Modal proxy service, which allows you to use Modal services without having your own Modal token. This is useful for teams or organizations where a single Modal account is shared.
76
+
77
+ ### Setting Up the Proxy Service
78
+
79
+ #### 1. Create an Environment File
80
+
81
+ Copy the example environment file and edit it:
82
+
83
+ ```bash
84
+ cp .env.example .env
85
+ ```
86
+
87
+ Edit the `.env` file to add your Modal token:
88
+
89
+ ```
90
+ MODAL_TOKEN=your_modal_token_here
91
+ ```
92
+
93
+ #### 2. Run the Proxy Service
94
+
95
+ ```bash
96
+ # Start the proxy service
97
+ python modal_proxy_service.py
98
+ ```
99
+
100
+ The service will start on port 5001 by default (to avoid conflicts with macOS AirPlay on port 5000).
101
+
102
+ #### 3. Create an API Key for Clients
103
+
104
+ When the service starts for the first time, it will generate an admin key. Use this key to create API keys for clients:
105
+
106
+ ```bash
107
+ # Create a new API key
108
+ curl -X POST -H "X-Admin-Key: your_admin_key" http://localhost:5001/api/create-api-key
109
+ ```
110
+
111
+ #### 4. Using ngrok for Public Access (Optional)
112
+
113
+ If you want to make your proxy service accessible from outside your network:
114
+
115
+ ```bash
116
+ # Install ngrok if you haven't already
117
+ brew install ngrok # On macOS
118
+
119
+ # Start ngrok to expose your proxy service
120
+ ngrok http 5001
121
+ ```
122
+
123
+ Use the ngrok URL when configuring clients.
124
+
125
+ ### Configuring the Client
126
+
127
+ ```bash
128
+ # Configure the proxy service
129
+ ./gitarsenal.py proxy configure
130
+ ```
131
+
132
+ This will prompt you for the proxy service URL and API key.
133
+
134
+ ### Checking Proxy Service Status
135
+
136
+ ```bash
137
+ # Check if the proxy service is running
138
+ ./gitarsenal.py proxy status
139
+ ```
140
+
141
+ ### Creating a Sandbox through the Proxy
142
+
143
+ ```bash
144
+ # Create a sandbox through the proxy service
145
+ ./gitarsenal.py proxy sandbox --gpu A10G --repo-url "https://github.com/username/repo.git" --wait
146
+ ```
147
+
148
+ ### Creating an SSH Container through the Proxy
149
+
150
+ ```bash
151
+ # Create an SSH container through the proxy service
152
+ ./gitarsenal.py proxy ssh --gpu A10G --repo-url "https://github.com/username/repo.git" --wait
153
+ ```
154
+
155
+ ### Using the Proxy with Standard Commands
156
+
157
+ You can also use the proxy service with the standard `sandbox` and `ssh` commands by adding the `--use-proxy` flag:
158
+
159
+ ```bash
160
+ # Create a sandbox using the proxy service
161
+ ./gitarsenal.py sandbox --gpu A10G --repo-url "https://github.com/username/repo.git" --use-proxy
162
+
163
+ # Create an SSH container using the proxy service
164
+ ./gitarsenal.py ssh --gpu A10G --repo-url "https://github.com/username/repo.git" --use-proxy
165
+ ```
71
166
 
72
167
  ## Managing Credentials
73
168
 
@@ -97,6 +192,8 @@ You can manage your credentials using the following commands:
97
192
 
98
193
  Your credentials are stored securely in `~/.gitarsenal/credentials.json` with restrictive file permissions. The file is only readable by your user account.
99
194
 
195
+ Proxy configuration is stored in `~/.gitarsenal/proxy_config.json` with similar security measures.
196
+
100
197
  ## Troubleshooting
101
198
 
102
199
  ### Modal Authentication Issues
@@ -118,6 +215,29 @@ If you see errors like "Token missing" or "Could not authenticate client":
118
215
  ./gitarsenal.py credentials set modal_token
119
216
  ```
120
217
 
218
+ ### Proxy Service Issues
219
+
220
+ If you're having issues with the proxy service:
221
+
222
+ 1. Check if the proxy service is running:
223
+ ```bash
224
+ ./gitarsenal.py proxy status
225
+ ```
226
+
227
+ 2. Reconfigure the proxy service:
228
+ ```bash
229
+ ./gitarsenal.py proxy configure
230
+ ```
231
+
232
+ 3. Make sure you have a valid API key for the proxy service.
233
+
234
+ 4. Check the proxy service logs:
235
+ ```bash
236
+ cat modal_proxy.log
237
+ ```
238
+
239
+ 5. If using ngrok, make sure the tunnel is active and use the correct URL.
240
+
121
241
  ### API Timeout Issues
122
242
 
123
243
  If the GitArsenal API times out when analyzing repositories, the tool will automatically use fallback setup commands based on the detected programming language and technologies.
@@ -59,6 +59,27 @@ def check_modal_auth():
59
59
  print(f"⚠️ Error checking Modal authentication: {e}")
60
60
  return False
61
61
 
62
+ def check_proxy_config():
63
+ """Check if Modal proxy is configured"""
64
+ try:
65
+ # Import the proxy client
66
+ from gitarsenal_proxy_client import GitArsenalProxyClient
67
+
68
+ # Create client and load config
69
+ client = GitArsenalProxyClient()
70
+ config = client.load_config()
71
+
72
+ # Check if proxy URL and API key are configured
73
+ if "proxy_url" in config and "api_key" in config:
74
+ return True
75
+ else:
76
+ return False
77
+ except ImportError:
78
+ return False
79
+ except Exception as e:
80
+ print(f"⚠️ Error checking proxy configuration: {e}")
81
+ return False
82
+
62
83
  def main():
63
84
  parser = argparse.ArgumentParser(description="GitArsenal CLI - GPU-accelerated cloud environments")
64
85
  subparsers = parser.add_subparsers(dest="command", help="Command to run")
@@ -96,8 +117,10 @@ def main():
96
117
  sandbox_parser.add_argument("--repo-name", type=str, help="Repository name override")
97
118
  sandbox_parser.add_argument("--setup-commands", type=str, nargs="+", help="Setup commands to run")
98
119
  sandbox_parser.add_argument("--volume-name", type=str, help="Name of the Modal volume for persistent storage")
120
+ sandbox_parser.add_argument("--use-api", action="store_true", help="Use API for setup commands")
121
+ sandbox_parser.add_argument("--use-proxy", action="store_true", help="Use Modal proxy service instead of direct Modal access")
99
122
 
100
- # SSH container command
123
+ # SSH command
101
124
  ssh_parser = subparsers.add_parser("ssh", help="Create a Modal SSH container")
102
125
  ssh_parser.add_argument("--gpu", type=str, default="A10G", choices=["A10G", "A100", "H100", "T4", "V100"],
103
126
  help="GPU type (default: A10G)")
@@ -106,123 +129,387 @@ def main():
106
129
  ssh_parser.add_argument("--setup-commands", type=str, nargs="+", help="Setup commands to run")
107
130
  ssh_parser.add_argument("--volume-name", type=str, help="Name of the Modal volume for persistent storage")
108
131
  ssh_parser.add_argument("--timeout", type=int, default=60, help="Container timeout in minutes (default: 60)")
132
+ ssh_parser.add_argument("--use-api", action="store_true", help="Use API for setup commands")
133
+ ssh_parser.add_argument("--use-proxy", action="store_true", help="Use Modal proxy service instead of direct Modal access")
134
+
135
+ # Proxy command
136
+ proxy_parser = subparsers.add_parser("proxy", help="Configure and use Modal proxy service")
137
+ proxy_subparsers = proxy_parser.add_subparsers(dest="proxy_command", help="Proxy command")
138
+
139
+ # Proxy configure command
140
+ proxy_configure_parser = proxy_subparsers.add_parser("configure", help="Configure Modal proxy service")
141
+
142
+ # Proxy status command
143
+ proxy_status_parser = proxy_subparsers.add_parser("status", help="Check Modal proxy service status")
144
+
145
+ # Proxy sandbox command
146
+ proxy_sandbox_parser = proxy_subparsers.add_parser("sandbox", help="Create a sandbox through Modal proxy")
147
+ proxy_sandbox_parser.add_argument("--gpu", type=str, default="A10G", choices=["A10G", "A100", "H100", "T4", "V100"],
148
+ help="GPU type (default: A10G)")
149
+ proxy_sandbox_parser.add_argument("--repo-url", type=str, help="Repository URL to clone")
150
+ proxy_sandbox_parser.add_argument("--repo-name", type=str, help="Repository name override")
151
+ proxy_sandbox_parser.add_argument("--setup-commands", type=str, nargs="+", help="Setup commands to run")
152
+ proxy_sandbox_parser.add_argument("--volume-name", type=str, help="Name of the Modal volume for persistent storage")
153
+ proxy_sandbox_parser.add_argument("--wait", action="store_true", help="Wait for sandbox to be ready")
154
+
155
+ # Proxy ssh command
156
+ proxy_ssh_parser = proxy_subparsers.add_parser("ssh", help="Create an SSH container through Modal proxy")
157
+ proxy_ssh_parser.add_argument("--gpu", type=str, default="A10G", choices=["A10G", "A100", "H100", "T4", "V100"],
158
+ help="GPU type (default: A10G)")
159
+ proxy_ssh_parser.add_argument("--repo-url", type=str, help="Repository URL to clone")
160
+ proxy_ssh_parser.add_argument("--repo-name", type=str, help="Repository name override")
161
+ proxy_ssh_parser.add_argument("--setup-commands", type=str, nargs="+", help="Setup commands to run")
162
+ proxy_ssh_parser.add_argument("--volume-name", type=str, help="Name of the Modal volume for persistent storage")
163
+ proxy_ssh_parser.add_argument("--timeout", type=int, default=60, help="Container timeout in minutes (default: 60)")
164
+ proxy_ssh_parser.add_argument("--wait", action="store_true", help="Wait for container to be ready")
109
165
 
110
166
  args = parser.parse_args()
111
167
 
112
- # If no command is provided, show help
113
168
  if not args.command:
114
169
  parser.print_help()
115
- return 1
116
-
117
- # Handle credentials commands
118
- if args.command == "credentials":
119
- cred_args = []
120
-
121
- if not args.cred_command:
122
- cred_parser.print_help()
123
- return 1
124
-
125
- cred_args.append(args.cred_command)
126
-
127
- if args.cred_command == "set" or args.cred_command == "get":
128
- cred_args.append(args.key)
129
- elif args.cred_command == "clear" and args.key != "all":
130
- cred_args.append(args.key)
131
-
132
- return run_script("manage_credentials.py", cred_args)
170
+ return 0
133
171
 
134
172
  # For sandbox and SSH commands, check Modal authentication first
135
173
  if args.command in ["sandbox", "ssh"]:
136
- # Check if Modal is authenticated
137
- if not check_modal_auth():
138
- print("\n⚠️ Please authenticate with Modal before proceeding.")
139
- return 1
140
-
141
- # Try to load credentials and set Modal token if available
174
+ # Check if using proxy service
175
+ if hasattr(args, 'use_proxy') and args.use_proxy:
176
+ # Check if proxy is configured
177
+ if not check_proxy_config():
178
+ print("\n⚠️ Modal proxy service is not configured.")
179
+ print("Please run './gitarsenal.py proxy configure' first.")
180
+ return 1
181
+ else:
182
+ # Check if Modal is authenticated
183
+ if not check_modal_auth():
184
+ print("\n⚠️ Please authenticate with Modal before proceeding.")
185
+ return 1
186
+
187
+ # Try to load credentials and set Modal token if available
188
+ try:
189
+ from credentials_manager import CredentialsManager
190
+ credentials_manager = CredentialsManager()
191
+ credentials = credentials_manager.load_credentials()
192
+
193
+ if "modal_token" in credentials:
194
+ # Set the Modal token in the environment
195
+ os.environ["MODAL_TOKEN_ID"] = credentials["modal_token"]
196
+ print("✅ Using Modal token from credentials")
197
+
198
+ # Try to authenticate with the token
199
+ try:
200
+ token_result = subprocess.run(
201
+ ["modal", "token", "set", credentials["modal_token"]],
202
+ capture_output=True, text=True
203
+ )
204
+ if token_result.returncode == 0:
205
+ print("✅ Successfully authenticated with Modal")
206
+ except Exception as e:
207
+ print(f"⚠️ Error setting Modal token: {e}")
208
+ except ImportError:
209
+ print("⚠️ Could not load credentials manager")
210
+ except Exception as e:
211
+ print(f"⚠️ Error loading credentials: {e}")
212
+
213
+ # Handle credentials commands
214
+ if args.command == "credentials":
142
215
  try:
143
216
  from credentials_manager import CredentialsManager
144
217
  credentials_manager = CredentialsManager()
145
- credentials = credentials_manager.load_credentials()
146
218
 
147
- if "modal_token" in credentials:
148
- # Set the Modal token in the environment
149
- os.environ["MODAL_TOKEN_ID"] = credentials["modal_token"]
150
- print("✅ Using Modal token from credentials")
219
+ if args.cred_command == "setup":
220
+ # Set up all credentials
221
+ print("🔧 Setting up all credentials")
222
+
223
+ # Modal token
224
+ modal_token = credentials_manager.get_modal_token()
225
+ if modal_token:
226
+ print("✅ Modal token set")
227
+ else:
228
+ print("⚠️ Modal token not set")
229
+
230
+ # OpenAI API key
231
+ openai_api_key = credentials_manager.get_openai_api_key()
232
+ if openai_api_key:
233
+ print("✅ OpenAI API key set")
234
+ else:
235
+ print("⚠️ OpenAI API key not set")
236
+
237
+ # Hugging Face token
238
+ huggingface_token = credentials_manager.get_huggingface_token()
239
+ if huggingface_token:
240
+ print("✅ Hugging Face token set")
241
+ else:
242
+ print("⚠️ Hugging Face token not set")
243
+
244
+ # Weights & Biases API key
245
+ wandb_api_key = credentials_manager.get_wandb_api_key()
246
+ if wandb_api_key:
247
+ print("✅ Weights & Biases API key set")
248
+ else:
249
+ print("⚠️ Weights & Biases API key not set")
250
+
251
+ elif args.cred_command == "set":
252
+ # Set a specific credential
253
+ if args.key == "modal_token":
254
+ modal_token = credentials_manager.get_modal_token()
255
+ if modal_token:
256
+ print("✅ Modal token set")
257
+ else:
258
+ print("⚠️ Modal token not set")
259
+ elif args.key == "openai_api_key":
260
+ openai_api_key = credentials_manager.get_openai_api_key()
261
+ if openai_api_key:
262
+ print("✅ OpenAI API key set")
263
+ else:
264
+ print("⚠️ OpenAI API key not set")
265
+ elif args.key == "huggingface_token":
266
+ huggingface_token = credentials_manager.get_huggingface_token()
267
+ if huggingface_token:
268
+ print("✅ Hugging Face token set")
269
+ else:
270
+ print("⚠️ Hugging Face token not set")
271
+ elif args.key == "wandb_api_key":
272
+ wandb_api_key = credentials_manager.get_wandb_api_key()
273
+ if wandb_api_key:
274
+ print("✅ Weights & Biases API key set")
275
+ else:
276
+ print("⚠️ Weights & Biases API key not set")
277
+
278
+ elif args.cred_command == "get":
279
+ # Get a specific credential
280
+ credentials = credentials_manager.load_credentials()
281
+ if args.key in credentials:
282
+ # Mask the credential for security
283
+ value = credentials[args.key]
284
+ masked_value = value[:4] + "*" * (len(value) - 8) + value[-4:] if len(value) > 8 else "****"
285
+ print(f"{args.key}: {masked_value}")
286
+ else:
287
+ print(f"⚠️ {args.key} not found")
288
+
289
+ elif args.cred_command == "clear":
290
+ # Clear credentials
291
+ if args.key == "all":
292
+ credentials_manager.clear_all_credentials()
293
+ print("✅ All credentials cleared")
294
+ else:
295
+ if credentials_manager.clear_credential(args.key):
296
+ print(f"✅ {args.key} cleared")
297
+ else:
298
+ print(f"⚠️ {args.key} not found")
299
+
300
+ elif args.cred_command == "list":
301
+ # List all credentials
302
+ credentials = credentials_manager.load_credentials()
303
+ if credentials:
304
+ print("📋 Saved credentials:")
305
+ for key in credentials:
306
+ print(f" - {key}")
307
+ else:
308
+ print("⚠️ No credentials found")
309
+
310
+ else:
311
+ print("⚠️ Unknown credentials command")
312
+ return 1
151
313
 
152
- # Try to authenticate with the token
153
- try:
154
- token_result = subprocess.run(
155
- ["modal", "token", "set", credentials["modal_token"]],
156
- capture_output=True, text=True
157
- )
158
- if token_result.returncode == 0:
159
- print("✅ Successfully authenticated with Modal")
160
- except Exception as e:
161
- print(f"⚠️ Error setting Modal token: {e}")
162
314
  except ImportError:
163
- print("⚠️ Could not load credentials manager")
315
+ print(" Could not import credentials_manager module")
316
+ return 1
164
317
  except Exception as e:
165
- print(f"⚠️ Error loading credentials: {e}")
318
+ print(f" Error: {e}")
319
+ return 1
166
320
 
167
321
  # Handle sandbox command
168
- if args.command == "sandbox":
169
- sandbox_args = []
322
+ elif args.command == "sandbox":
323
+ try:
324
+ # Check if using proxy service
325
+ if args.use_proxy:
326
+ # Import proxy client
327
+ try:
328
+ from gitarsenal_proxy_client import GitArsenalProxyClient
329
+ client = GitArsenalProxyClient()
330
+
331
+ # Create sandbox through proxy
332
+ result = client.create_sandbox(
333
+ gpu_type=args.gpu,
334
+ repo_url=args.repo_url,
335
+ repo_name=args.repo_name,
336
+ setup_commands=args.setup_commands,
337
+ volume_name=args.volume_name,
338
+ wait=True # Always wait for sandbox to be ready
339
+ )
340
+
341
+ if not result:
342
+ print("❌ Failed to create sandbox through proxy service")
343
+ return 1
344
+
345
+ print("✅ Sandbox created successfully through proxy service")
346
+
347
+ except ImportError:
348
+ print("❌ Could not import gitarsenal_proxy_client module")
349
+ print("Please make sure gitarsenal_proxy_client.py is in the same directory")
350
+ return 1
351
+ else:
352
+ # Import sandbox creation function
353
+ from test_modalSandboxScript import create_modal_sandbox
354
+
355
+ # Create sandbox directly
356
+ result = create_modal_sandbox(
357
+ args.gpu,
358
+ repo_url=args.repo_url,
359
+ repo_name=args.repo_name,
360
+ setup_commands=args.setup_commands,
361
+ volume_name=args.volume_name
362
+ )
363
+
364
+ if not result:
365
+ print("❌ Failed to create sandbox")
366
+ return 1
367
+
368
+ print("✅ Sandbox created successfully")
170
369
 
171
- if args.gpu:
172
- sandbox_args.extend(["--gpu", args.gpu])
173
- if args.repo_url:
174
- sandbox_args.extend(["--repo-url", args.repo_url])
175
- if args.repo_name:
176
- sandbox_args.extend(["--repo-name", args.repo_name])
177
- if args.setup_commands:
178
- sandbox_args.extend(["--setup-commands"] + args.setup_commands)
179
- if args.volume_name:
180
- sandbox_args.extend(["--volume-name", args.volume_name])
181
-
182
- return run_script("test_modalSandboxScript.py", sandbox_args)
370
+ except ImportError as e:
371
+ print(f"❌ Could not import required module: {e}")
372
+ return 1
373
+ except Exception as e:
374
+ print(f"❌ Error: {e}")
375
+ return 1
183
376
 
184
- # Handle SSH container command
377
+ # Handle SSH command
185
378
  elif args.command == "ssh":
186
- ssh_args = []
379
+ try:
380
+ # Check if using proxy service
381
+ if args.use_proxy:
382
+ # Import proxy client
383
+ try:
384
+ from gitarsenal_proxy_client import GitArsenalProxyClient
385
+ client = GitArsenalProxyClient()
386
+
387
+ # Create SSH container through proxy
388
+ result = client.create_ssh_container(
389
+ gpu_type=args.gpu,
390
+ repo_url=args.repo_url,
391
+ repo_name=args.repo_name,
392
+ setup_commands=args.setup_commands,
393
+ volume_name=args.volume_name,
394
+ timeout=args.timeout,
395
+ wait=True # Always wait for container to be ready
396
+ )
397
+
398
+ if not result:
399
+ print("❌ Failed to create SSH container through proxy service")
400
+ return 1
401
+
402
+ print("✅ SSH container created successfully through proxy service")
403
+
404
+ except ImportError:
405
+ print("❌ Could not import gitarsenal_proxy_client module")
406
+ print("Please make sure gitarsenal_proxy_client.py is in the same directory")
407
+ return 1
408
+ else:
409
+ # Import SSH container creation function
410
+ from test_modalSandboxScript import create_modal_ssh_container
411
+
412
+ # Create SSH container directly
413
+ result = create_modal_ssh_container(
414
+ args.gpu,
415
+ repo_url=args.repo_url,
416
+ repo_name=args.repo_name,
417
+ setup_commands=args.setup_commands,
418
+ volume_name=args.volume_name,
419
+ timeout_minutes=args.timeout
420
+ )
421
+
422
+ if not result:
423
+ print("❌ Failed to create SSH container")
424
+ return 1
425
+
426
+ print("✅ SSH container created successfully")
187
427
 
188
- if args.gpu:
189
- ssh_args.extend(["--gpu", args.gpu])
190
- if args.repo_url:
191
- ssh_args.extend(["--repo-url", args.repo_url])
192
- if args.repo_name:
193
- ssh_args.extend(["--repo-name", args.repo_name])
194
- if args.setup_commands:
195
- ssh_args.extend(["--setup-commands"] + args.setup_commands)
196
- if args.volume_name:
197
- ssh_args.extend(["--volume-name", args.volume_name])
198
- if args.timeout:
199
- ssh_args.extend(["--timeout", str(args.timeout)])
200
-
201
- # Use test_modalSandboxScript.py with SSH mode
202
- ssh_args.extend(["--ssh"])
203
- return run_script("test_modalSandboxScript.py", ssh_args)
204
-
205
- return 0
206
-
207
- def run_script(script_name, args):
208
- """Run a Python script with the given arguments"""
209
- # Get the directory of the current script
210
- script_dir = os.path.dirname(os.path.abspath(__file__))
211
- script_path = os.path.join(script_dir, script_name)
428
+ except ImportError as e:
429
+ print(f"❌ Could not import required module: {e}")
430
+ return 1
431
+ except Exception as e:
432
+ print(f"❌ Error: {e}")
433
+ return 1
212
434
 
213
- # Build the command
214
- cmd = [sys.executable, script_path] + args
435
+ # Handle proxy commands
436
+ elif args.command == "proxy":
437
+ if not args.proxy_command:
438
+ proxy_parser.print_help()
439
+ return 0
440
+
441
+ try:
442
+ # Import proxy client
443
+ from gitarsenal_proxy_client import GitArsenalProxyClient
444
+ client = GitArsenalProxyClient()
445
+
446
+ if args.proxy_command == "configure":
447
+ # Configure proxy service
448
+ client.configure(interactive=True)
449
+
450
+ elif args.proxy_command == "status":
451
+ # Check proxy service status
452
+ response = client.health_check()
453
+ if response["success"]:
454
+ print(f"✅ Proxy service is running: {response['data']['message']}")
455
+ else:
456
+ print(f"❌ Proxy service health check failed: {response['error']}")
457
+ return 1
458
+
459
+ elif args.proxy_command == "sandbox":
460
+ # Create sandbox through proxy
461
+ result = client.create_sandbox(
462
+ gpu_type=args.gpu,
463
+ repo_url=args.repo_url,
464
+ repo_name=args.repo_name,
465
+ setup_commands=args.setup_commands,
466
+ volume_name=args.volume_name,
467
+ wait=args.wait
468
+ )
469
+
470
+ if not result:
471
+ print("❌ Failed to create sandbox through proxy service")
472
+ return 1
473
+
474
+ print("✅ Sandbox created successfully through proxy service")
475
+
476
+ elif args.proxy_command == "ssh":
477
+ # Create SSH container through proxy
478
+ result = client.create_ssh_container(
479
+ gpu_type=args.gpu,
480
+ repo_url=args.repo_url,
481
+ repo_name=args.repo_name,
482
+ setup_commands=args.setup_commands,
483
+ volume_name=args.volume_name,
484
+ timeout=args.timeout,
485
+ wait=args.wait
486
+ )
487
+
488
+ if not result:
489
+ print("❌ Failed to create SSH container through proxy service")
490
+ return 1
491
+
492
+ print("✅ SSH container created successfully through proxy service")
493
+
494
+ else:
495
+ print(f"❌ Unknown proxy command: {args.proxy_command}")
496
+ proxy_parser.print_help()
497
+ return 1
498
+
499
+ except ImportError:
500
+ print("❌ Could not import gitarsenal_proxy_client module")
501
+ print("Please make sure gitarsenal_proxy_client.py is in the same directory")
502
+ return 1
503
+ except Exception as e:
504
+ print(f"❌ Error: {e}")
505
+ return 1
215
506
 
216
- try:
217
- # Run the command
218
- result = subprocess.run(cmd)
219
- return result.returncode
220
- except KeyboardInterrupt:
221
- print("\n⚠️ Command interrupted by user")
222
- return 130
223
- except Exception as e:
224
- print(f"❌ Error running {script_name}: {e}")
507
+ else:
508
+ print(f"❌ Unknown command: {args.command}")
509
+ parser.print_help()
225
510
  return 1
511
+
512
+ return 0
226
513
 
227
514
  if __name__ == "__main__":
228
515
  sys.exit(main())
@@ -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)
@@ -1,4 +1,6 @@
1
1
  modal>=0.56.4
2
2
  requests>=2.31.0
3
3
  pathlib>=1.0.1
4
- python-dotenv>=1.0.0
4
+ python-dotenv>=1.0.0
5
+ flask>=2.0.0
6
+ flask-cors>=3.0.0