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,438 @@
|
|
|
1
|
+
"""Native process manager for running ComfyUI without Docker.
|
|
2
|
+
|
|
3
|
+
Uses `cg` CLI directly to import environments and run ComfyUI processes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import aiohttp
|
|
16
|
+
|
|
17
|
+
from ..commands.dev import get_dev_nodes
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ProcessInfo:
|
|
22
|
+
"""Info about a running ComfyUI process."""
|
|
23
|
+
|
|
24
|
+
pid: int
|
|
25
|
+
port: int
|
|
26
|
+
returncode: int | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class DeployResult:
|
|
31
|
+
"""Result of a deploy operation."""
|
|
32
|
+
|
|
33
|
+
success: bool
|
|
34
|
+
skipped: bool = False
|
|
35
|
+
error: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ProcessLogs:
|
|
40
|
+
"""Captured process output."""
|
|
41
|
+
|
|
42
|
+
stdout: list[str]
|
|
43
|
+
stderr: list[str]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NativeManager:
|
|
47
|
+
"""Manages ComfyUI instances as native processes."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, workspace_path: Path):
|
|
50
|
+
"""Initialize native manager.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
workspace_path: Path to ComfyGit workspace
|
|
54
|
+
"""
|
|
55
|
+
self.workspace_path = workspace_path
|
|
56
|
+
self._processes: dict[str, subprocess.Popen] = {}
|
|
57
|
+
self._log_buffers: dict[str, list[str]] = {} # Ring buffers for log capture
|
|
58
|
+
self._max_log_lines: int = 1000 # Keep last N lines per instance
|
|
59
|
+
|
|
60
|
+
def environment_exists(self, environment_name: str) -> bool:
|
|
61
|
+
"""Check if an environment is fully set up.
|
|
62
|
+
|
|
63
|
+
An environment is considered to exist if its ComfyUI directory is present.
|
|
64
|
+
This matches the RunPod script behavior for restart detection.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
environment_name: Name of the environment
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
True if environment has ComfyUI installed
|
|
71
|
+
"""
|
|
72
|
+
comfyui_path = (
|
|
73
|
+
self.workspace_path / "environments" / environment_name / "ComfyUI"
|
|
74
|
+
)
|
|
75
|
+
return comfyui_path.is_dir()
|
|
76
|
+
|
|
77
|
+
def delete_environment(self, environment_name: str) -> bool:
|
|
78
|
+
"""Delete an environment directory.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
environment_name: Name of the environment to delete
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
True if deleted, False if not found
|
|
85
|
+
"""
|
|
86
|
+
import shutil
|
|
87
|
+
|
|
88
|
+
env_path = self.workspace_path / "environments" / environment_name
|
|
89
|
+
if env_path.is_dir():
|
|
90
|
+
shutil.rmtree(env_path)
|
|
91
|
+
return True
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
async def deploy(
|
|
95
|
+
self,
|
|
96
|
+
instance_id: str,
|
|
97
|
+
environment_name: str,
|
|
98
|
+
import_source: str,
|
|
99
|
+
branch: str | None = None,
|
|
100
|
+
) -> DeployResult:
|
|
101
|
+
"""Deploy an environment by cloning from git.
|
|
102
|
+
|
|
103
|
+
If the environment already exists (has ComfyUI directory), skips import
|
|
104
|
+
and returns success with skipped=True.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
instance_id: Unique instance identifier
|
|
108
|
+
environment_name: Name for the environment
|
|
109
|
+
import_source: Git URL to import from
|
|
110
|
+
branch: Optional branch/tag to checkout
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
DeployResult with success/skipped/error status
|
|
114
|
+
"""
|
|
115
|
+
# Check if environment already exists (e.g., worker restart)
|
|
116
|
+
if self.environment_exists(environment_name):
|
|
117
|
+
# Still apply dev nodes in case config changed
|
|
118
|
+
self._apply_dev_nodes(environment_name)
|
|
119
|
+
return DeployResult(success=True, skipped=True)
|
|
120
|
+
|
|
121
|
+
# Build import command
|
|
122
|
+
cmd = [
|
|
123
|
+
"cg",
|
|
124
|
+
"import",
|
|
125
|
+
import_source,
|
|
126
|
+
"--name",
|
|
127
|
+
environment_name,
|
|
128
|
+
"-y",
|
|
129
|
+
"--models",
|
|
130
|
+
"all",
|
|
131
|
+
]
|
|
132
|
+
if branch:
|
|
133
|
+
cmd.extend(["--branch", branch])
|
|
134
|
+
|
|
135
|
+
env = os.environ.copy()
|
|
136
|
+
env["COMFYGIT_HOME"] = str(self.workspace_path)
|
|
137
|
+
|
|
138
|
+
# Run import in subprocess
|
|
139
|
+
proc = await asyncio.create_subprocess_exec(
|
|
140
|
+
*cmd,
|
|
141
|
+
env=env,
|
|
142
|
+
stdout=asyncio.subprocess.PIPE,
|
|
143
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Wait for completion
|
|
147
|
+
stdout, _ = await proc.communicate()
|
|
148
|
+
|
|
149
|
+
if proc.returncode != 0:
|
|
150
|
+
output = stdout.decode() if stdout else ""
|
|
151
|
+
return DeployResult(
|
|
152
|
+
success=False,
|
|
153
|
+
error=f"Import failed for {instance_id}: {output}",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Apply dev nodes if configured
|
|
157
|
+
self._apply_dev_nodes(environment_name)
|
|
158
|
+
|
|
159
|
+
return DeployResult(success=True, skipped=False)
|
|
160
|
+
|
|
161
|
+
def _apply_dev_nodes(self, environment_name: str) -> None:
|
|
162
|
+
"""Apply configured dev nodes to an environment.
|
|
163
|
+
|
|
164
|
+
Creates symlinks and tracks with cg node add --dev.
|
|
165
|
+
"""
|
|
166
|
+
dev_nodes = get_dev_nodes()
|
|
167
|
+
if not dev_nodes:
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
env_path = self.workspace_path / "environments" / environment_name
|
|
171
|
+
custom_nodes = env_path / "ComfyUI" / "custom_nodes"
|
|
172
|
+
|
|
173
|
+
if not custom_nodes.exists():
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
env = os.environ.copy()
|
|
177
|
+
env["COMFYGIT_HOME"] = str(self.workspace_path)
|
|
178
|
+
|
|
179
|
+
for node in dev_nodes:
|
|
180
|
+
target = custom_nodes / node.name
|
|
181
|
+
source = Path(node.path)
|
|
182
|
+
|
|
183
|
+
if not source.exists():
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
# Create/update symlink
|
|
187
|
+
if target.is_symlink():
|
|
188
|
+
if target.resolve() != source.resolve():
|
|
189
|
+
target.unlink()
|
|
190
|
+
target.symlink_to(source)
|
|
191
|
+
elif target.exists():
|
|
192
|
+
shutil.rmtree(target)
|
|
193
|
+
target.symlink_to(source)
|
|
194
|
+
else:
|
|
195
|
+
target.symlink_to(source)
|
|
196
|
+
|
|
197
|
+
# Track with cg node add --dev
|
|
198
|
+
cmd = ["cg", "-e", environment_name, "node", "add", node.name, "--dev"]
|
|
199
|
+
subprocess.run(cmd, env=env, capture_output=True)
|
|
200
|
+
|
|
201
|
+
def start(
|
|
202
|
+
self,
|
|
203
|
+
instance_id: str,
|
|
204
|
+
environment_name: str,
|
|
205
|
+
port: int,
|
|
206
|
+
listen_host: str = "0.0.0.0",
|
|
207
|
+
) -> ProcessInfo | None:
|
|
208
|
+
"""Start ComfyUI process for an environment.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
instance_id: Instance identifier for tracking
|
|
212
|
+
environment_name: Environment to run
|
|
213
|
+
port: Port for ComfyUI
|
|
214
|
+
listen_host: Host to listen on
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
ProcessInfo if started successfully, None otherwise
|
|
218
|
+
"""
|
|
219
|
+
if instance_id in self._processes:
|
|
220
|
+
proc = self._processes[instance_id]
|
|
221
|
+
if proc.poll() is None:
|
|
222
|
+
# Already running
|
|
223
|
+
return ProcessInfo(pid=proc.pid, port=port)
|
|
224
|
+
|
|
225
|
+
cmd = [
|
|
226
|
+
"cg",
|
|
227
|
+
"-e",
|
|
228
|
+
environment_name,
|
|
229
|
+
"run",
|
|
230
|
+
"--no-sync", # Skip sync since we just imported
|
|
231
|
+
"--listen",
|
|
232
|
+
listen_host,
|
|
233
|
+
"--port",
|
|
234
|
+
str(port),
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
env = os.environ.copy()
|
|
238
|
+
env["COMFYGIT_HOME"] = str(self.workspace_path)
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
proc = subprocess.Popen(
|
|
242
|
+
cmd,
|
|
243
|
+
env=env,
|
|
244
|
+
stdout=subprocess.PIPE,
|
|
245
|
+
stderr=subprocess.STDOUT,
|
|
246
|
+
start_new_session=True, # Detach from parent
|
|
247
|
+
text=True,
|
|
248
|
+
bufsize=1, # Line buffered
|
|
249
|
+
)
|
|
250
|
+
self._processes[instance_id] = proc
|
|
251
|
+
self._log_buffers[instance_id] = []
|
|
252
|
+
|
|
253
|
+
# Start background thread to read output
|
|
254
|
+
import threading
|
|
255
|
+
def read_output():
|
|
256
|
+
try:
|
|
257
|
+
for line in proc.stdout:
|
|
258
|
+
buf = self._log_buffers.get(instance_id, [])
|
|
259
|
+
buf.append(line.rstrip())
|
|
260
|
+
if len(buf) > self._max_log_lines:
|
|
261
|
+
buf.pop(0)
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
thread = threading.Thread(target=read_output, daemon=True)
|
|
266
|
+
thread.start()
|
|
267
|
+
|
|
268
|
+
return ProcessInfo(pid=proc.pid, port=port)
|
|
269
|
+
except Exception as e:
|
|
270
|
+
print(f"Failed to start {instance_id}: {e}")
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
def stop(self, instance_id: str, pid: int | None = None) -> bool:
|
|
274
|
+
"""Stop a running ComfyUI process.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
instance_id: Instance to stop
|
|
278
|
+
pid: Optional PID to kill if process not tracked in memory
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
True if stopped (or wasn't running)
|
|
282
|
+
"""
|
|
283
|
+
proc = self._processes.get(instance_id)
|
|
284
|
+
|
|
285
|
+
# If we have a tracked process, use it
|
|
286
|
+
if proc:
|
|
287
|
+
if proc.poll() is not None:
|
|
288
|
+
# Already dead
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
# Send SIGTERM to process group
|
|
293
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
294
|
+
|
|
295
|
+
# Wait up to 5 seconds for graceful shutdown
|
|
296
|
+
try:
|
|
297
|
+
proc.wait(timeout=5)
|
|
298
|
+
except subprocess.TimeoutExpired:
|
|
299
|
+
# Force kill
|
|
300
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
301
|
+
proc.wait(timeout=2)
|
|
302
|
+
|
|
303
|
+
return True
|
|
304
|
+
except ProcessLookupError:
|
|
305
|
+
return True
|
|
306
|
+
except Exception as e:
|
|
307
|
+
print(f"Error stopping {instance_id}: {e}")
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
# No tracked process - try to kill by PID if provided
|
|
311
|
+
if pid:
|
|
312
|
+
try:
|
|
313
|
+
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
314
|
+
# Give it a moment to die
|
|
315
|
+
time.sleep(0.5)
|
|
316
|
+
# Check if still alive and force kill
|
|
317
|
+
try:
|
|
318
|
+
os.kill(pid, 0) # Check if process exists
|
|
319
|
+
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
|
320
|
+
except ProcessLookupError:
|
|
321
|
+
pass # Already dead
|
|
322
|
+
return True
|
|
323
|
+
except ProcessLookupError:
|
|
324
|
+
return True # Already dead
|
|
325
|
+
except (PermissionError, OSError):
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
return True
|
|
329
|
+
|
|
330
|
+
def terminate(self, instance_id: str, pid: int | None = None) -> bool:
|
|
331
|
+
"""Terminate instance and remove tracking.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
instance_id: Instance to terminate
|
|
335
|
+
pid: Optional PID to kill if process not tracked in memory
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
True if terminated successfully
|
|
339
|
+
"""
|
|
340
|
+
result = self.stop(instance_id, pid=pid)
|
|
341
|
+
self._processes.pop(instance_id, None)
|
|
342
|
+
return result
|
|
343
|
+
|
|
344
|
+
def is_running(self, instance_id: str) -> bool:
|
|
345
|
+
"""Check if instance process is running.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
instance_id: Instance to check
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
True if process is alive
|
|
352
|
+
"""
|
|
353
|
+
proc = self._processes.get(instance_id)
|
|
354
|
+
if not proc:
|
|
355
|
+
return False
|
|
356
|
+
return proc.poll() is None
|
|
357
|
+
|
|
358
|
+
def get_pid(self, instance_id: str) -> int | None:
|
|
359
|
+
"""Get PID of running instance.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
instance_id: Instance to check
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
PID if running, None otherwise
|
|
366
|
+
"""
|
|
367
|
+
proc = self._processes.get(instance_id)
|
|
368
|
+
if proc and proc.poll() is None:
|
|
369
|
+
return proc.pid
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
def recover_process(self, instance_id: str, pid: int) -> bool:
|
|
373
|
+
"""Attempt to recover tracking for a process from a previous run.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
instance_id: Instance identifier
|
|
377
|
+
pid: PID from previous run
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
True if process is still alive and now tracked
|
|
381
|
+
"""
|
|
382
|
+
try:
|
|
383
|
+
os.kill(pid, 0) # Check if process exists
|
|
384
|
+
# We can't recover the Popen object, but we can track the PID
|
|
385
|
+
# For now, just report if it's alive
|
|
386
|
+
return True
|
|
387
|
+
except (ProcessLookupError, PermissionError):
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
def get_logs(self, instance_id: str, lines: int = 100) -> ProcessLogs:
|
|
391
|
+
"""Get recent logs from an instance.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
instance_id: Instance to get logs for
|
|
395
|
+
lines: Maximum number of lines to return
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
ProcessLogs with stdout/stderr content
|
|
399
|
+
"""
|
|
400
|
+
buf = self._log_buffers.get(instance_id, [])
|
|
401
|
+
# Return last N lines
|
|
402
|
+
return ProcessLogs(stdout=buf[-lines:], stderr=[])
|
|
403
|
+
|
|
404
|
+
async def wait_for_ready(
|
|
405
|
+
self,
|
|
406
|
+
port: int,
|
|
407
|
+
timeout_seconds: float = 120.0,
|
|
408
|
+
poll_interval: float = 2.0,
|
|
409
|
+
) -> bool:
|
|
410
|
+
"""Wait for ComfyUI to become ready by polling HTTP endpoint.
|
|
411
|
+
|
|
412
|
+
Polls the ComfyUI HTTP endpoint until it responds successfully
|
|
413
|
+
or the timeout is reached.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
port: Port ComfyUI is listening on
|
|
417
|
+
timeout_seconds: Maximum time to wait (default 2 minutes)
|
|
418
|
+
poll_interval: Time between polling attempts
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
True if ComfyUI is ready, False if timeout expired
|
|
422
|
+
"""
|
|
423
|
+
url = f"http://localhost:{port}/"
|
|
424
|
+
deadline = time.monotonic() + timeout_seconds
|
|
425
|
+
|
|
426
|
+
async with aiohttp.ClientSession() as session:
|
|
427
|
+
while time.monotonic() < deadline:
|
|
428
|
+
try:
|
|
429
|
+
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
|
430
|
+
if resp.status == 200:
|
|
431
|
+
return True
|
|
432
|
+
except Exception:
|
|
433
|
+
# Connection refused, timeout, etc - keep trying
|
|
434
|
+
pass
|
|
435
|
+
|
|
436
|
+
await asyncio.sleep(poll_interval)
|
|
437
|
+
|
|
438
|
+
return False
|