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 +1 -1
- package/python/README.md +120 -0
- package/python/gitarsenal.py +381 -94
- package/python/gitarsenal_proxy_client.py +375 -0
- package/python/modal_proxy_service.py +317 -0
- package/python/requirements.txt +3 -1
package/package.json
CHANGED
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.
|
package/python/gitarsenal.py
CHANGED
@@ -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
|
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
|
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
|
137
|
-
if
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
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 "
|
148
|
-
# Set
|
149
|
-
|
150
|
-
|
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("
|
315
|
+
print("❌ Could not import credentials_manager module")
|
316
|
+
return 1
|
164
317
|
except Exception as e:
|
165
|
-
print(f"
|
318
|
+
print(f"❌ Error: {e}")
|
319
|
+
return 1
|
166
320
|
|
167
321
|
# Handle sandbox command
|
168
|
-
|
169
|
-
|
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
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
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
|
377
|
+
# Handle SSH command
|
185
378
|
elif args.command == "ssh":
|
186
|
-
|
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
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
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
|
-
#
|
214
|
-
|
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
|
-
|
217
|
-
|
218
|
-
|
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)
|