comfygit-deploy 0.3.4__py3-none-any.whl
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.
- comfygit_deploy/__init__.py +3 -0
- comfygit_deploy/cli.py +374 -0
- comfygit_deploy/commands/__init__.py +5 -0
- comfygit_deploy/commands/custom.py +218 -0
- comfygit_deploy/commands/dev.py +356 -0
- comfygit_deploy/commands/instances.py +506 -0
- comfygit_deploy/commands/runpod.py +203 -0
- comfygit_deploy/commands/worker.py +266 -0
- comfygit_deploy/config.py +122 -0
- comfygit_deploy/providers/__init__.py +11 -0
- comfygit_deploy/providers/custom.py +238 -0
- comfygit_deploy/providers/runpod.py +549 -0
- comfygit_deploy/startup/__init__.py +1 -0
- comfygit_deploy/startup/scripts.py +210 -0
- comfygit_deploy/worker/__init__.py +12 -0
- comfygit_deploy/worker/mdns.py +154 -0
- comfygit_deploy/worker/native_manager.py +438 -0
- comfygit_deploy/worker/server.py +511 -0
- comfygit_deploy/worker/state.py +268 -0
- comfygit_deploy-0.3.4.dist-info/METADATA +38 -0
- comfygit_deploy-0.3.4.dist-info/RECORD +23 -0
- comfygit_deploy-0.3.4.dist-info/WHEEL +4 -0
- comfygit_deploy-0.3.4.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""Instance management CLI command implementations.
|
|
2
|
+
|
|
3
|
+
Provides unified instance listing and management across RunPod and custom workers.
|
|
4
|
+
Instance IDs use namespacing: worker_name:instance_id for custom, plain id for RunPod.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
import webbrowser
|
|
11
|
+
from argparse import Namespace
|
|
12
|
+
|
|
13
|
+
from ..config import DeployConfig
|
|
14
|
+
from ..providers.custom import CustomWorkerClient, CustomWorkerError
|
|
15
|
+
from ..providers.runpod import RunPodAPIError, RunPodClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_instance_id(instance_id: str) -> tuple[str | None, str]:
|
|
19
|
+
"""Parse instance ID into (worker_name, local_id).
|
|
20
|
+
|
|
21
|
+
Format: "worker_name:local_id" for custom workers, plain id for RunPod.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
(worker_name, local_id) - worker_name is None for RunPod instances
|
|
25
|
+
"""
|
|
26
|
+
if ":" in instance_id:
|
|
27
|
+
worker_name, local_id = instance_id.split(":", 1)
|
|
28
|
+
return worker_name, local_id
|
|
29
|
+
return None, instance_id
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _convert_runpod_to_unified(pod: dict) -> dict:
|
|
33
|
+
"""Convert RunPod pod to unified instance format."""
|
|
34
|
+
status_map = {"RUNNING": "running", "EXITED": "stopped", "PENDING": "deploying"}
|
|
35
|
+
return {
|
|
36
|
+
"id": pod.get("id"),
|
|
37
|
+
"provider": "runpod",
|
|
38
|
+
"worker_name": None,
|
|
39
|
+
"name": pod.get("name"),
|
|
40
|
+
"status": status_map.get(pod.get("desiredStatus", ""), pod.get("desiredStatus")),
|
|
41
|
+
"gpu": pod.get("machine", {}).get("gpuDisplayName") if pod.get("machine") else None,
|
|
42
|
+
"cost_per_hour": pod.get("costPerHr", 0),
|
|
43
|
+
"comfyui_url": RunPodClient.get_comfyui_url(pod),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _convert_worker_to_unified(worker_name: str, instance: dict) -> dict:
|
|
48
|
+
"""Convert custom worker instance to unified format with namespaced ID."""
|
|
49
|
+
return {
|
|
50
|
+
"id": f"{worker_name}:{instance.get('id')}",
|
|
51
|
+
"provider": "custom",
|
|
52
|
+
"worker_name": worker_name,
|
|
53
|
+
"name": instance.get("name"),
|
|
54
|
+
"status": instance.get("status"),
|
|
55
|
+
"gpu": None, # Could be fetched from worker system info if needed
|
|
56
|
+
"cost_per_hour": 0, # Self-hosted
|
|
57
|
+
"comfyui_url": instance.get("comfyui_url"),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _fetch_all_instances(
|
|
62
|
+
config: DeployConfig, provider_filter: str | None
|
|
63
|
+
) -> list[dict]:
|
|
64
|
+
"""Fetch instances from all configured providers.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
config: Deploy configuration
|
|
68
|
+
provider_filter: Optional filter ('runpod' or 'custom')
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
List of unified instance dicts
|
|
72
|
+
"""
|
|
73
|
+
instances = []
|
|
74
|
+
|
|
75
|
+
# Fetch RunPod instances
|
|
76
|
+
if provider_filter in (None, "runpod"):
|
|
77
|
+
api_key = config.runpod_api_key
|
|
78
|
+
if api_key:
|
|
79
|
+
try:
|
|
80
|
+
client = RunPodClient(api_key)
|
|
81
|
+
pods = await client.list_pods()
|
|
82
|
+
instances.extend(_convert_runpod_to_unified(pod) for pod in pods)
|
|
83
|
+
except RunPodAPIError as e:
|
|
84
|
+
print(f"Warning: RunPod error: {e}")
|
|
85
|
+
|
|
86
|
+
# Fetch custom worker instances
|
|
87
|
+
if provider_filter in (None, "custom"):
|
|
88
|
+
for worker_name, worker_config in config.workers.items():
|
|
89
|
+
try:
|
|
90
|
+
client = CustomWorkerClient(
|
|
91
|
+
worker_config["host"],
|
|
92
|
+
worker_config["port"],
|
|
93
|
+
worker_config["api_key"],
|
|
94
|
+
)
|
|
95
|
+
worker_instances = await client.list_instances()
|
|
96
|
+
instances.extend(
|
|
97
|
+
_convert_worker_to_unified(worker_name, inst)
|
|
98
|
+
for inst in worker_instances
|
|
99
|
+
)
|
|
100
|
+
except Exception:
|
|
101
|
+
# Worker offline or unreachable - skip silently
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
return instances
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def handle_instances(args: Namespace) -> int:
|
|
108
|
+
"""Handle 'instances' command - list all instances from all providers."""
|
|
109
|
+
config = DeployConfig()
|
|
110
|
+
provider_filter = getattr(args, "provider", None)
|
|
111
|
+
|
|
112
|
+
# Check if we have any providers configured
|
|
113
|
+
has_runpod = config.runpod_api_key is not None
|
|
114
|
+
has_workers = len(config.workers) > 0
|
|
115
|
+
|
|
116
|
+
if not has_runpod and not has_workers:
|
|
117
|
+
print("No providers configured.")
|
|
118
|
+
print("Run: cg-deploy runpod config --api-key <key>")
|
|
119
|
+
print(" or: cg-deploy custom add <name> --host <ip> --api-key <key>")
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
# Require RunPod key only if specifically filtering for runpod
|
|
123
|
+
if provider_filter == "runpod" and not has_runpod:
|
|
124
|
+
print("Error: RunPod API key not configured.")
|
|
125
|
+
print("Run: cg-deploy runpod config --api-key <your-key>")
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
# Fetch all instances
|
|
129
|
+
instances = asyncio.run(_fetch_all_instances(config, provider_filter))
|
|
130
|
+
|
|
131
|
+
# Filter by status if requested
|
|
132
|
+
status_filter = getattr(args, "status", None)
|
|
133
|
+
if status_filter:
|
|
134
|
+
instances = [i for i in instances if i.get("status") == status_filter]
|
|
135
|
+
|
|
136
|
+
# JSON output
|
|
137
|
+
if getattr(args, "json", False):
|
|
138
|
+
print(json.dumps(instances, indent=2))
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
if not instances:
|
|
142
|
+
print("No instances found.")
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
# Table output
|
|
146
|
+
print(f"{'ID':<25} {'Name':<25} {'Provider':<10} {'Status':<10} {'$/hr':>8}")
|
|
147
|
+
print("-" * 80)
|
|
148
|
+
|
|
149
|
+
for inst in instances:
|
|
150
|
+
inst_id = inst.get("id", "?")[:25]
|
|
151
|
+
name = (inst.get("name") or "?")[:25]
|
|
152
|
+
provider = inst.get("provider", "?")
|
|
153
|
+
if inst.get("worker_name"):
|
|
154
|
+
provider = inst["worker_name"][:10]
|
|
155
|
+
status = inst.get("status", "?")
|
|
156
|
+
cost = inst.get("cost_per_hour", 0)
|
|
157
|
+
|
|
158
|
+
print(f"{inst_id:<25} {name:<25} {provider:<10} {status:<10} ${cost:>7.2f}")
|
|
159
|
+
|
|
160
|
+
# Show URL for running instances
|
|
161
|
+
if status == "running" and inst.get("comfyui_url"):
|
|
162
|
+
print(f" -> {inst['comfyui_url']}")
|
|
163
|
+
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _get_custom_client(config: DeployConfig, worker_name: str) -> CustomWorkerClient | None:
|
|
168
|
+
"""Get CustomWorkerClient for a registered worker."""
|
|
169
|
+
worker = config.get_worker(worker_name)
|
|
170
|
+
if not worker:
|
|
171
|
+
print(f"Error: Worker '{worker_name}' not found.")
|
|
172
|
+
print("Run: cg-deploy custom list")
|
|
173
|
+
return None
|
|
174
|
+
return CustomWorkerClient(worker["host"], worker["port"], worker["api_key"])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def handle_start(args: Namespace) -> int:
|
|
178
|
+
"""Handle 'start' command - start a stopped instance."""
|
|
179
|
+
config = DeployConfig()
|
|
180
|
+
worker_name, local_id = parse_instance_id(args.instance_id)
|
|
181
|
+
|
|
182
|
+
if worker_name:
|
|
183
|
+
# Custom worker instance
|
|
184
|
+
client = _get_custom_client(config, worker_name)
|
|
185
|
+
if not client:
|
|
186
|
+
return 1
|
|
187
|
+
|
|
188
|
+
print(f"Starting instance {local_id} on {worker_name}...")
|
|
189
|
+
try:
|
|
190
|
+
result = asyncio.run(client.start_instance(local_id))
|
|
191
|
+
print(f"Status: {result.get('status')}")
|
|
192
|
+
if result.get("comfyui_url"):
|
|
193
|
+
print(f"URL: {result['comfyui_url']}")
|
|
194
|
+
return 0
|
|
195
|
+
except CustomWorkerError as e:
|
|
196
|
+
print(f"Error: {e}")
|
|
197
|
+
return 1
|
|
198
|
+
else:
|
|
199
|
+
# RunPod instance
|
|
200
|
+
api_key = config.runpod_api_key
|
|
201
|
+
if not api_key:
|
|
202
|
+
print("Error: RunPod API key not configured.")
|
|
203
|
+
return 1
|
|
204
|
+
|
|
205
|
+
client = RunPodClient(api_key)
|
|
206
|
+
print(f"Starting instance {local_id}...")
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
result = asyncio.run(client.start_pod(local_id))
|
|
210
|
+
print(f"Status: {result.get('desiredStatus')}")
|
|
211
|
+
if result.get("costPerHr"):
|
|
212
|
+
print(f"Cost: ${result['costPerHr']:.2f}/hr")
|
|
213
|
+
return 0
|
|
214
|
+
except RunPodAPIError as e:
|
|
215
|
+
print(f"Error: {e}")
|
|
216
|
+
return 1
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def handle_stop(args: Namespace) -> int:
|
|
220
|
+
"""Handle 'stop' command - stop a running instance."""
|
|
221
|
+
config = DeployConfig()
|
|
222
|
+
worker_name, local_id = parse_instance_id(args.instance_id)
|
|
223
|
+
|
|
224
|
+
if worker_name:
|
|
225
|
+
# Custom worker instance
|
|
226
|
+
client = _get_custom_client(config, worker_name)
|
|
227
|
+
if not client:
|
|
228
|
+
return 1
|
|
229
|
+
|
|
230
|
+
print(f"Stopping instance {local_id} on {worker_name}...")
|
|
231
|
+
try:
|
|
232
|
+
result = asyncio.run(client.stop_instance(local_id))
|
|
233
|
+
print(f"Status: {result.get('status')}")
|
|
234
|
+
return 0
|
|
235
|
+
except CustomWorkerError as e:
|
|
236
|
+
print(f"Error: {e}")
|
|
237
|
+
return 1
|
|
238
|
+
else:
|
|
239
|
+
# RunPod instance
|
|
240
|
+
api_key = config.runpod_api_key
|
|
241
|
+
if not api_key:
|
|
242
|
+
print("Error: RunPod API key not configured.")
|
|
243
|
+
return 1
|
|
244
|
+
|
|
245
|
+
client = RunPodClient(api_key)
|
|
246
|
+
print(f"Stopping instance {local_id}...")
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
result = asyncio.run(client.stop_pod(local_id))
|
|
250
|
+
print(f"Status: {result.get('desiredStatus')}")
|
|
251
|
+
return 0
|
|
252
|
+
except RunPodAPIError as e:
|
|
253
|
+
print(f"Error: {e}")
|
|
254
|
+
return 1
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def handle_terminate(args: Namespace) -> int:
|
|
258
|
+
"""Handle 'terminate' command - terminate and remove an instance."""
|
|
259
|
+
config = DeployConfig()
|
|
260
|
+
worker_name, local_id = parse_instance_id(args.instance_id)
|
|
261
|
+
keep_env = getattr(args, "keep_env", False)
|
|
262
|
+
|
|
263
|
+
# Confirm unless --force
|
|
264
|
+
if not getattr(args, "force", False):
|
|
265
|
+
confirm = input(f"Terminate instance {args.instance_id}? This cannot be undone. [y/N]: ")
|
|
266
|
+
if confirm.lower() != "y":
|
|
267
|
+
print("Cancelled.")
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
if worker_name:
|
|
271
|
+
# Custom worker instance
|
|
272
|
+
client = _get_custom_client(config, worker_name)
|
|
273
|
+
if not client:
|
|
274
|
+
return 1
|
|
275
|
+
|
|
276
|
+
print(f"Terminating instance {local_id} on {worker_name}...")
|
|
277
|
+
try:
|
|
278
|
+
result = asyncio.run(client.terminate_instance(local_id, keep_env=keep_env))
|
|
279
|
+
print(result.get("message", "Instance terminated."))
|
|
280
|
+
return 0
|
|
281
|
+
except CustomWorkerError as e:
|
|
282
|
+
print(f"Error: {e}")
|
|
283
|
+
return 1
|
|
284
|
+
else:
|
|
285
|
+
# RunPod instance
|
|
286
|
+
api_key = config.runpod_api_key
|
|
287
|
+
if not api_key:
|
|
288
|
+
print("Error: RunPod API key not configured.")
|
|
289
|
+
return 1
|
|
290
|
+
|
|
291
|
+
client = RunPodClient(api_key)
|
|
292
|
+
print(f"Terminating instance {local_id}...")
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
asyncio.run(client.delete_pod(local_id))
|
|
296
|
+
print("Instance terminated.")
|
|
297
|
+
return 0
|
|
298
|
+
except RunPodAPIError as e:
|
|
299
|
+
print(f"Error: {e}")
|
|
300
|
+
return 1
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def handle_open(args: Namespace) -> int:
|
|
304
|
+
"""Handle 'open' command - open ComfyUI URL in browser."""
|
|
305
|
+
config = DeployConfig()
|
|
306
|
+
worker_name, local_id = parse_instance_id(args.instance_id)
|
|
307
|
+
|
|
308
|
+
if worker_name:
|
|
309
|
+
# Custom worker instance
|
|
310
|
+
client = _get_custom_client(config, worker_name)
|
|
311
|
+
if not client:
|
|
312
|
+
return 1
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
instance = asyncio.run(client.get_instance(local_id))
|
|
316
|
+
url = instance.get("comfyui_url")
|
|
317
|
+
if not url:
|
|
318
|
+
print(f"Instance {local_id} is not running or URL not available.")
|
|
319
|
+
return 1
|
|
320
|
+
print(f"Opening: {url}")
|
|
321
|
+
webbrowser.open(url)
|
|
322
|
+
return 0
|
|
323
|
+
except CustomWorkerError as e:
|
|
324
|
+
print(f"Error: {e}")
|
|
325
|
+
return 1
|
|
326
|
+
else:
|
|
327
|
+
# RunPod instance
|
|
328
|
+
api_key = config.runpod_api_key
|
|
329
|
+
if not api_key:
|
|
330
|
+
print("Error: RunPod API key not configured.")
|
|
331
|
+
return 1
|
|
332
|
+
|
|
333
|
+
client = RunPodClient(api_key)
|
|
334
|
+
try:
|
|
335
|
+
pod = asyncio.run(client.get_pod(local_id))
|
|
336
|
+
except RunPodAPIError as e:
|
|
337
|
+
print(f"Error: {e}")
|
|
338
|
+
return 1
|
|
339
|
+
|
|
340
|
+
url = RunPodClient.get_comfyui_url(pod)
|
|
341
|
+
if not url:
|
|
342
|
+
print(f"Instance {local_id} is not running or URL not available.")
|
|
343
|
+
return 1
|
|
344
|
+
|
|
345
|
+
print(f"Opening: {url}")
|
|
346
|
+
webbrowser.open(url)
|
|
347
|
+
return 0
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def handle_wait(args: Namespace) -> int:
|
|
351
|
+
"""Handle 'wait' command - wait for instance to be ready."""
|
|
352
|
+
config = DeployConfig()
|
|
353
|
+
worker_name, local_id = parse_instance_id(args.instance_id)
|
|
354
|
+
timeout = getattr(args, "timeout", 300)
|
|
355
|
+
start_time = time.time()
|
|
356
|
+
|
|
357
|
+
print(f"Waiting for instance {args.instance_id} to be ready (timeout: {timeout}s)...")
|
|
358
|
+
|
|
359
|
+
if worker_name:
|
|
360
|
+
# Custom worker instance
|
|
361
|
+
client = _get_custom_client(config, worker_name)
|
|
362
|
+
if not client:
|
|
363
|
+
return 1
|
|
364
|
+
|
|
365
|
+
while time.time() - start_time < timeout:
|
|
366
|
+
try:
|
|
367
|
+
instance = asyncio.run(client.get_instance(local_id))
|
|
368
|
+
status = instance.get("status")
|
|
369
|
+
|
|
370
|
+
if status == "running":
|
|
371
|
+
url = instance.get("comfyui_url")
|
|
372
|
+
if url:
|
|
373
|
+
print("\nInstance ready!")
|
|
374
|
+
print(f"ComfyUI URL: {url}")
|
|
375
|
+
return 0
|
|
376
|
+
|
|
377
|
+
elapsed = int(time.time() - start_time)
|
|
378
|
+
print(f"\r Status: {status} ({elapsed}s)", end="", flush=True)
|
|
379
|
+
|
|
380
|
+
except CustomWorkerError as e:
|
|
381
|
+
print(f"\nWarning: {e}")
|
|
382
|
+
|
|
383
|
+
time.sleep(5)
|
|
384
|
+
else:
|
|
385
|
+
# RunPod instance
|
|
386
|
+
api_key = config.runpod_api_key
|
|
387
|
+
if not api_key:
|
|
388
|
+
print("Error: RunPod API key not configured.")
|
|
389
|
+
return 1
|
|
390
|
+
|
|
391
|
+
client = RunPodClient(api_key)
|
|
392
|
+
while time.time() - start_time < timeout:
|
|
393
|
+
try:
|
|
394
|
+
pod = asyncio.run(client.get_pod(local_id))
|
|
395
|
+
status = pod.get("desiredStatus")
|
|
396
|
+
|
|
397
|
+
if status == "RUNNING":
|
|
398
|
+
url = RunPodClient.get_comfyui_url(pod)
|
|
399
|
+
if url:
|
|
400
|
+
print("\nInstance ready!")
|
|
401
|
+
print(f"ComfyUI URL: {url}")
|
|
402
|
+
return 0
|
|
403
|
+
|
|
404
|
+
elapsed = int(time.time() - start_time)
|
|
405
|
+
print(f"\r Status: {status} ({elapsed}s)", end="", flush=True)
|
|
406
|
+
|
|
407
|
+
except RunPodAPIError as e:
|
|
408
|
+
print(f"\nWarning: {e}")
|
|
409
|
+
|
|
410
|
+
time.sleep(5)
|
|
411
|
+
|
|
412
|
+
print(f"\nTimeout: Instance not ready after {timeout}s")
|
|
413
|
+
return 1
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def stream_worker_logs(
|
|
417
|
+
host: str, port: int, api_key: str, instance_id: str, follow: bool
|
|
418
|
+
) -> None:
|
|
419
|
+
"""Stream logs from a custom worker instance.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
host: Worker host
|
|
423
|
+
port: Worker port
|
|
424
|
+
api_key: API key
|
|
425
|
+
instance_id: Instance ID (local, not namespaced)
|
|
426
|
+
follow: If True, stream continuously; if False, just print and exit
|
|
427
|
+
"""
|
|
428
|
+
from ..providers.custom import CustomWorkerClient
|
|
429
|
+
|
|
430
|
+
async def _stream():
|
|
431
|
+
client = CustomWorkerClient(host=host, port=port, api_key=api_key)
|
|
432
|
+
try:
|
|
433
|
+
async for entry in client.stream_logs(instance_id):
|
|
434
|
+
print(f"[{entry.level}] {entry.message}")
|
|
435
|
+
except KeyboardInterrupt:
|
|
436
|
+
pass
|
|
437
|
+
|
|
438
|
+
asyncio.run(_stream())
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def fetch_worker_logs(
|
|
442
|
+
host: str, port: int, api_key: str, instance_id: str, lines: int
|
|
443
|
+
) -> list[dict]:
|
|
444
|
+
"""Fetch recent logs from a custom worker instance.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
host: Worker host
|
|
448
|
+
port: Worker port
|
|
449
|
+
api_key: API key
|
|
450
|
+
instance_id: Instance ID (local, not namespaced)
|
|
451
|
+
lines: Number of lines to fetch
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
List of log entries
|
|
455
|
+
"""
|
|
456
|
+
from ..providers.custom import CustomWorkerClient
|
|
457
|
+
|
|
458
|
+
async def _fetch():
|
|
459
|
+
client = CustomWorkerClient(host=host, port=port, api_key=api_key)
|
|
460
|
+
return await client.get_logs(instance_id, lines=lines)
|
|
461
|
+
|
|
462
|
+
return asyncio.run(_fetch())
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def handle_logs(args: Namespace) -> int:
|
|
466
|
+
"""Handle 'logs' command."""
|
|
467
|
+
config = DeployConfig()
|
|
468
|
+
worker_name, local_id = parse_instance_id(args.instance_id)
|
|
469
|
+
follow = getattr(args, "follow", False)
|
|
470
|
+
lines = getattr(args, "lines", 100)
|
|
471
|
+
|
|
472
|
+
if worker_name:
|
|
473
|
+
# Custom worker logs
|
|
474
|
+
worker = config.get_worker(worker_name)
|
|
475
|
+
if not worker:
|
|
476
|
+
print(f"Error: Worker '{worker_name}' not found.")
|
|
477
|
+
return 1
|
|
478
|
+
|
|
479
|
+
if follow:
|
|
480
|
+
# Stream logs via WebSocket
|
|
481
|
+
stream_worker_logs(
|
|
482
|
+
host=worker["host"],
|
|
483
|
+
port=worker["port"],
|
|
484
|
+
api_key=worker["api_key"],
|
|
485
|
+
instance_id=local_id,
|
|
486
|
+
follow=True,
|
|
487
|
+
)
|
|
488
|
+
else:
|
|
489
|
+
# Fetch recent logs
|
|
490
|
+
logs = fetch_worker_logs(
|
|
491
|
+
host=worker["host"],
|
|
492
|
+
port=worker["port"],
|
|
493
|
+
api_key=worker["api_key"],
|
|
494
|
+
instance_id=local_id,
|
|
495
|
+
lines=lines,
|
|
496
|
+
)
|
|
497
|
+
for entry in logs:
|
|
498
|
+
level = entry.get("level", "INFO")
|
|
499
|
+
message = entry.get("message", "")
|
|
500
|
+
print(f"[{level}] {message}")
|
|
501
|
+
else:
|
|
502
|
+
# RunPod doesn't have a direct logs API - users need to use the console
|
|
503
|
+
print("Log streaming not available via API.")
|
|
504
|
+
print(f"View logs in RunPod console: https://www.runpod.io/console/pods/{local_id}")
|
|
505
|
+
|
|
506
|
+
return 0
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""RunPod CLI command implementations."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from argparse import Namespace
|
|
6
|
+
|
|
7
|
+
from ..config import DeployConfig
|
|
8
|
+
from ..providers.runpod import RunPodAPIError, RunPodClient
|
|
9
|
+
from ..startup.scripts import generate_deployment_id, generate_startup_script
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_client() -> RunPodClient:
|
|
13
|
+
"""Get RunPod client with configured API key."""
|
|
14
|
+
config = DeployConfig()
|
|
15
|
+
api_key = config.runpod_api_key
|
|
16
|
+
if not api_key:
|
|
17
|
+
print("Error: RunPod API key not configured.")
|
|
18
|
+
print("Run: cg-deploy runpod config --api-key <your-key>")
|
|
19
|
+
sys.exit(1)
|
|
20
|
+
return RunPodClient(api_key)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def handle_config(args: Namespace) -> int:
|
|
24
|
+
"""Handle 'runpod config' command."""
|
|
25
|
+
config = DeployConfig()
|
|
26
|
+
|
|
27
|
+
if args.api_key:
|
|
28
|
+
config.runpod_api_key = args.api_key
|
|
29
|
+
config.save()
|
|
30
|
+
# Test the connection
|
|
31
|
+
client = RunPodClient(args.api_key)
|
|
32
|
+
result = asyncio.run(client.test_connection())
|
|
33
|
+
if result["success"]:
|
|
34
|
+
print(f"API key saved. Credit balance: ${result['credit_balance']:.2f}")
|
|
35
|
+
else:
|
|
36
|
+
print(f"Warning: API key saved but connection test failed: {result['error']}")
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
if args.clear:
|
|
40
|
+
config.runpod_api_key = None
|
|
41
|
+
config.save()
|
|
42
|
+
print("API key cleared.")
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
if args.show or (not args.api_key and not args.clear):
|
|
46
|
+
api_key = config.runpod_api_key
|
|
47
|
+
if api_key:
|
|
48
|
+
# Mask the key for display
|
|
49
|
+
masked = api_key[:8] + "..." + api_key[-4:] if len(api_key) > 12 else "***"
|
|
50
|
+
print(f"API Key: {masked}")
|
|
51
|
+
# Test connection
|
|
52
|
+
client = RunPodClient(api_key)
|
|
53
|
+
result = asyncio.run(client.test_connection())
|
|
54
|
+
if result["success"]:
|
|
55
|
+
print("Status: Connected")
|
|
56
|
+
print(f"Credit Balance: ${result['credit_balance']:.2f}")
|
|
57
|
+
else:
|
|
58
|
+
print(f"Status: Error - {result['error']}")
|
|
59
|
+
else:
|
|
60
|
+
print("No API key configured.")
|
|
61
|
+
print("Run: cg-deploy runpod config --api-key <your-key>")
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def handle_gpus(args: Namespace) -> int:
|
|
68
|
+
"""Handle 'runpod gpus' command."""
|
|
69
|
+
client = _get_client()
|
|
70
|
+
region = getattr(args, "region", None)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
gpus = asyncio.run(client.get_gpu_types_with_pricing(region))
|
|
74
|
+
except RunPodAPIError as e:
|
|
75
|
+
print(f"Error: {e}")
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
# Sort by price (cheapest first)
|
|
79
|
+
gpus = sorted(gpus, key=lambda g: g.get("securePrice") or 999)
|
|
80
|
+
|
|
81
|
+
print(f"{'GPU Type':<35} {'VRAM':>6} {'Secure':>8} {'Spot':>8} {'Stock':>10}")
|
|
82
|
+
print("-" * 75)
|
|
83
|
+
|
|
84
|
+
for gpu in gpus:
|
|
85
|
+
name = gpu.get("displayName", gpu.get("id", "Unknown"))[:35]
|
|
86
|
+
vram = gpu.get("memoryInGb", "?")
|
|
87
|
+
secure_price = gpu.get("securePrice")
|
|
88
|
+
spot_price = gpu.get("secureSpotPrice")
|
|
89
|
+
stock = gpu.get("lowestPrice", {}).get("stockStatus", "?")
|
|
90
|
+
|
|
91
|
+
secure_str = f"${secure_price:.2f}/hr" if secure_price else "N/A"
|
|
92
|
+
spot_str = f"${spot_price:.2f}/hr" if spot_price else "N/A"
|
|
93
|
+
|
|
94
|
+
print(f"{name:<35} {vram:>4}GB {secure_str:>8} {spot_str:>8} {stock:>10}")
|
|
95
|
+
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def handle_volumes(args: Namespace) -> int:
|
|
100
|
+
"""Handle 'runpod volumes' command."""
|
|
101
|
+
client = _get_client()
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
volumes = asyncio.run(client.list_network_volumes())
|
|
105
|
+
except RunPodAPIError as e:
|
|
106
|
+
print(f"Error: {e}")
|
|
107
|
+
return 1
|
|
108
|
+
|
|
109
|
+
if not volumes:
|
|
110
|
+
print("No network volumes found.")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
print(f"{'ID':<15} {'Name':<25} {'Size':>8} {'Region':<15}")
|
|
114
|
+
print("-" * 65)
|
|
115
|
+
|
|
116
|
+
for vol in volumes:
|
|
117
|
+
vol_id = vol.get("id", "?")
|
|
118
|
+
name = vol.get("name", "?")[:25]
|
|
119
|
+
size = vol.get("size", "?")
|
|
120
|
+
region = vol.get("dataCenterId", vol.get("dataCenter", "?"))
|
|
121
|
+
|
|
122
|
+
print(f"{vol_id:<15} {name:<25} {size:>6}GB {region:<15}")
|
|
123
|
+
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def handle_regions(args: Namespace) -> int:
|
|
128
|
+
"""Handle 'runpod regions' command."""
|
|
129
|
+
client = _get_client()
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
regions = asyncio.run(client.get_data_centers())
|
|
133
|
+
except RunPodAPIError as e:
|
|
134
|
+
print(f"Error: {e}")
|
|
135
|
+
return 1
|
|
136
|
+
|
|
137
|
+
print(f"{'ID':<15} {'Name':<35} {'Available':>10}")
|
|
138
|
+
print("-" * 62)
|
|
139
|
+
|
|
140
|
+
for dc in regions:
|
|
141
|
+
dc_id = dc.get("id", "?")
|
|
142
|
+
name = dc.get("name", "?")[:35]
|
|
143
|
+
available = "Yes" if dc.get("available", True) else "No"
|
|
144
|
+
|
|
145
|
+
print(f"{dc_id:<15} {name:<35} {available:>10}")
|
|
146
|
+
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def handle_deploy(args: Namespace) -> int:
|
|
151
|
+
"""Handle 'runpod deploy' command."""
|
|
152
|
+
client = _get_client()
|
|
153
|
+
|
|
154
|
+
# Generate deployment ID
|
|
155
|
+
name = getattr(args, "name", None) or args.import_source.split("/")[-1].replace(".git", "")
|
|
156
|
+
deployment_id = generate_deployment_id(name)
|
|
157
|
+
|
|
158
|
+
# Generate startup script
|
|
159
|
+
startup_script = generate_startup_script(
|
|
160
|
+
deployment_id=deployment_id,
|
|
161
|
+
import_source=args.import_source,
|
|
162
|
+
branch=getattr(args, "branch", None),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Create pod
|
|
166
|
+
print(f"Creating deployment: {deployment_id}")
|
|
167
|
+
print(f"GPU: {args.gpu}")
|
|
168
|
+
print(f"Source: {args.import_source}")
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
# Use spot pricing if requested
|
|
172
|
+
pricing_type = getattr(args, "pricing_type", "ON_DEMAND")
|
|
173
|
+
cloud_type = getattr(args, "cloud_type", "SECURE")
|
|
174
|
+
volume_id = getattr(args, "volume", None)
|
|
175
|
+
|
|
176
|
+
pod = asyncio.run(
|
|
177
|
+
client.create_pod(
|
|
178
|
+
name=deployment_id,
|
|
179
|
+
image_name="runpod/pytorch:2.1.0-py3.10-cuda11.8.0-devel-ubuntu22.04",
|
|
180
|
+
gpu_type_id=args.gpu,
|
|
181
|
+
cloud_type=cloud_type,
|
|
182
|
+
ports=["8188/http", "22/tcp"],
|
|
183
|
+
docker_start_cmd=["/bin/bash", "-c", startup_script],
|
|
184
|
+
network_volume_id=volume_id,
|
|
185
|
+
interruptible=(pricing_type == "SPOT"),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
except RunPodAPIError as e:
|
|
189
|
+
print(f"Error creating pod: {e}")
|
|
190
|
+
return 1
|
|
191
|
+
|
|
192
|
+
pod_id = pod.get("id")
|
|
193
|
+
print(f"\nPod created: {pod_id}")
|
|
194
|
+
print(f"Status: {pod.get('desiredStatus')}")
|
|
195
|
+
|
|
196
|
+
url = RunPodClient.get_comfyui_url(pod)
|
|
197
|
+
if url:
|
|
198
|
+
print(f"ComfyUI URL: {url}")
|
|
199
|
+
|
|
200
|
+
print(f"\nConsole: https://www.runpod.io/console/pods/{pod_id}")
|
|
201
|
+
print("\nUse 'cg-deploy wait {pod_id}' to wait for deployment to complete.")
|
|
202
|
+
|
|
203
|
+
return 0
|