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.
@@ -0,0 +1,511 @@
1
+ """Worker HTTP server for managing ComfyUI instances.
2
+
3
+ Provides REST API for creating, starting, stopping, and terminating instances.
4
+ """
5
+
6
+ import asyncio
7
+ import secrets
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from aiohttp import web
13
+
14
+ from .. import __version__
15
+ from .native_manager import NativeManager
16
+ from .state import InstanceState, PortAllocator, WorkerState
17
+
18
+
19
+ def generate_instance_id() -> str:
20
+ """Generate unique instance ID."""
21
+ return f"inst_{secrets.token_hex(4)}"
22
+
23
+
24
+ def generate_instance_name(user_name: str | None) -> str:
25
+ """Generate instance name with timestamp."""
26
+ import re
27
+ base = user_name or "unnamed"
28
+ # Sanitize: lowercase, replace non-alphanumeric with hyphen, collapse multiples
29
+ base = re.sub(r'[^a-z0-9]+', '-', base.lower()).strip('-')[:32] or "unnamed"
30
+ date = datetime.now(timezone.utc).strftime("%Y%m%d")
31
+ suffix = secrets.token_hex(2)
32
+ return f"deploy-{base}-{date}-{suffix}"
33
+
34
+
35
+ class WorkerServer:
36
+ """Worker HTTP server managing ComfyUI instances."""
37
+
38
+ def __init__(
39
+ self,
40
+ api_key: str,
41
+ workspace_path: Path,
42
+ default_mode: str = "docker",
43
+ port_range_start: int = 8200,
44
+ port_range_end: int = 8210,
45
+ state_dir: Path | None = None,
46
+ ):
47
+ """Initialize worker server.
48
+
49
+ Args:
50
+ api_key: API key for authentication
51
+ workspace_path: ComfyGit workspace path
52
+ default_mode: Default instance mode (docker/native)
53
+ port_range_start: First port for instances
54
+ port_range_end: Last port for instances
55
+ state_dir: Directory for state files
56
+ """
57
+ self.api_key = api_key
58
+ self.workspace_path = workspace_path
59
+ self.default_mode = default_mode
60
+ self.port_range_start = port_range_start
61
+ self.port_range_end = port_range_end
62
+
63
+ state_dir = state_dir or Path.home() / ".config" / "comfygit" / "deploy"
64
+ state_dir.mkdir(parents=True, exist_ok=True)
65
+
66
+ self.state = WorkerState(
67
+ state_dir / "instances.json", workspace_path=workspace_path
68
+ )
69
+ self.port_allocator = PortAllocator(
70
+ state_dir / "instances.json",
71
+ base_port=port_range_start,
72
+ max_instances=port_range_end - port_range_start,
73
+ )
74
+
75
+ # Instance managers by mode
76
+ self.native_manager = NativeManager(workspace_path)
77
+ # self.docker_manager = DockerManager(workspace_path) # Future
78
+
79
+
80
+ @web.middleware
81
+ async def auth_middleware(
82
+ request: web.Request, handler: Any
83
+ ) -> web.StreamResponse:
84
+ """Validate API key in Authorization header."""
85
+ # Skip auth for certain paths if needed
86
+ auth_header = request.headers.get("Authorization", "")
87
+ expected_key = request.app["worker"].api_key
88
+
89
+ if not auth_header.startswith("Bearer "):
90
+ return web.json_response({"error": "Missing authorization"}, status=401)
91
+
92
+ provided_key = auth_header[7:]
93
+ if provided_key != expected_key:
94
+ return web.json_response({"error": "Invalid API key"}, status=401)
95
+
96
+ response: web.StreamResponse = await handler(request)
97
+ return response
98
+
99
+
100
+ async def handle_health(request: web.Request) -> web.Response:
101
+ """GET /api/v1/health - Health check endpoint."""
102
+ return web.json_response({"status": "ok", "worker_version": __version__})
103
+
104
+
105
+ async def handle_system_info(request: web.Request) -> web.Response:
106
+ """GET /api/v1/system/info - System information."""
107
+ worker: WorkerServer = request.app["worker"]
108
+
109
+ # Count instance states
110
+ instances = worker.state.instances
111
+ running = sum(1 for i in instances.values() if i.status == "running")
112
+ stopped = sum(1 for i in instances.values() if i.status == "stopped")
113
+
114
+ return web.json_response({
115
+ "worker_version": __version__,
116
+ "workspace_path": str(worker.workspace_path),
117
+ "default_mode": worker.default_mode,
118
+ "instances": {
119
+ "total": len(instances),
120
+ "running": running,
121
+ "stopped": stopped,
122
+ },
123
+ "ports": {
124
+ "range_start": worker.port_range_start,
125
+ "range_end": worker.port_range_end,
126
+ "allocated": list(worker.port_allocator.allocated.values()),
127
+ "available": (worker.port_range_end - worker.port_range_start)
128
+ - len(worker.port_allocator.allocated),
129
+ },
130
+ })
131
+
132
+
133
+ async def handle_list_instances(request: web.Request) -> web.Response:
134
+ """GET /api/v1/instances - List all instances."""
135
+ worker: WorkerServer = request.app["worker"]
136
+
137
+ instances = [
138
+ {
139
+ "id": inst.id,
140
+ "name": inst.name,
141
+ "status": inst.status,
142
+ "mode": inst.mode,
143
+ "assigned_port": inst.assigned_port,
144
+ "comfyui_url": f"http://localhost:{inst.assigned_port}"
145
+ if inst.status == "running"
146
+ else None,
147
+ "created_at": inst.created_at,
148
+ }
149
+ for inst in worker.state.instances.values()
150
+ ]
151
+
152
+ return web.json_response({
153
+ "instances": instances,
154
+ "port_range": {
155
+ "start": worker.port_range_start,
156
+ "end": worker.port_range_end,
157
+ },
158
+ "ports_available": (worker.port_range_end - worker.port_range_start)
159
+ - len(worker.port_allocator.allocated),
160
+ })
161
+
162
+
163
+ async def handle_create_instance(request: web.Request) -> web.Response:
164
+ """POST /api/v1/instances - Create new instance."""
165
+ worker: WorkerServer = request.app["worker"]
166
+
167
+ try:
168
+ data = await request.json()
169
+ except Exception:
170
+ return web.json_response({"error": "Invalid JSON"}, status=400)
171
+
172
+ import_source = data.get("import_source")
173
+ if not import_source:
174
+ return web.json_response(
175
+ {"error": "import_source is required"}, status=400
176
+ )
177
+
178
+ name = data.get("name")
179
+ mode = data.get("mode", worker.default_mode)
180
+ branch = data.get("branch")
181
+
182
+ # Generate IDs and allocate port
183
+ instance_id = generate_instance_id()
184
+ instance_name = generate_instance_name(name)
185
+
186
+ try:
187
+ port = worker.port_allocator.allocate(instance_id)
188
+ except RuntimeError as e:
189
+ return web.json_response({"error": str(e)}, status=503)
190
+
191
+ # Create instance state
192
+ instance = InstanceState(
193
+ id=instance_id,
194
+ name=instance_name,
195
+ environment_name=instance_name,
196
+ mode=mode,
197
+ assigned_port=port,
198
+ import_source=import_source,
199
+ branch=branch,
200
+ status="deploying",
201
+ )
202
+
203
+ worker.state.add_instance(instance)
204
+ worker.state.save()
205
+
206
+ # Start deployment in background task
207
+ asyncio.create_task(_deploy_instance(worker, instance))
208
+
209
+ return web.json_response(
210
+ {
211
+ "id": instance.id,
212
+ "name": instance.name,
213
+ "environment_name": instance.environment_name,
214
+ "status": instance.status,
215
+ "mode": instance.mode,
216
+ "assigned_port": instance.assigned_port,
217
+ "created_at": instance.created_at,
218
+ },
219
+ status=201,
220
+ )
221
+
222
+
223
+ async def _deploy_instance(worker: WorkerServer, instance: InstanceState) -> None:
224
+ """Background task to deploy and start an instance."""
225
+ try:
226
+ if instance.mode == "native":
227
+ # Deploy environment (may skip if already exists)
228
+ result = await worker.native_manager.deploy(
229
+ instance_id=instance.id,
230
+ environment_name=instance.environment_name,
231
+ import_source=instance.import_source,
232
+ branch=instance.branch,
233
+ )
234
+
235
+ if not result.success:
236
+ worker.state.update_status(instance.id, "error")
237
+ worker.state.save()
238
+ return
239
+
240
+ # Start ComfyUI process
241
+ worker.state.update_status(instance.id, "starting")
242
+ worker.state.save()
243
+
244
+ proc_info = worker.native_manager.start(
245
+ instance_id=instance.id,
246
+ environment_name=instance.environment_name,
247
+ port=instance.assigned_port,
248
+ )
249
+
250
+ if not proc_info:
251
+ worker.state.update_status(instance.id, "error")
252
+ worker.state.save()
253
+ return
254
+
255
+ # Wait for ComfyUI to become ready
256
+ is_ready = await worker.native_manager.wait_for_ready(
257
+ port=instance.assigned_port,
258
+ timeout_seconds=120.0,
259
+ poll_interval=2.0,
260
+ )
261
+
262
+ if is_ready:
263
+ worker.state.update_status(instance.id, "running", pid=proc_info.pid)
264
+ else:
265
+ # Process started but HTTP not responding
266
+ worker.state.update_status(instance.id, "error")
267
+ else:
268
+ # Docker mode - not yet implemented
269
+ worker.state.update_status(instance.id, "error")
270
+
271
+ worker.state.save()
272
+
273
+ except Exception as e:
274
+ print(f"Deployment failed for {instance.id}: {e}")
275
+ worker.state.update_status(instance.id, "error")
276
+ worker.state.save()
277
+
278
+
279
+ async def handle_get_instance(request: web.Request) -> web.Response:
280
+ """GET /api/v1/instances/{id} - Get instance details."""
281
+ worker: WorkerServer = request.app["worker"]
282
+ instance_id = request.match_info["id"]
283
+
284
+ instance = worker.state.instances.get(instance_id)
285
+ if not instance:
286
+ return web.json_response({"error": "Instance not found"}, status=404)
287
+
288
+ return web.json_response({
289
+ "id": instance.id,
290
+ "name": instance.name,
291
+ "environment_name": instance.environment_name,
292
+ "status": instance.status,
293
+ "mode": instance.mode,
294
+ "assigned_port": instance.assigned_port,
295
+ "import_source": instance.import_source,
296
+ "branch": instance.branch,
297
+ "container_id": instance.container_id,
298
+ "pid": instance.pid,
299
+ "created_at": instance.created_at,
300
+ "comfyui_url": f"http://localhost:{instance.assigned_port}"
301
+ if instance.status == "running"
302
+ else None,
303
+ })
304
+
305
+
306
+ async def handle_stop_instance(request: web.Request) -> web.Response:
307
+ """POST /api/v1/instances/{id}/stop - Stop instance."""
308
+ worker: WorkerServer = request.app["worker"]
309
+ instance_id = request.match_info["id"]
310
+
311
+ instance = worker.state.instances.get(instance_id)
312
+ if not instance:
313
+ return web.json_response({"error": "Instance not found"}, status=404)
314
+
315
+ # Stop based on mode
316
+ if instance.mode == "native":
317
+ worker.native_manager.stop(instance_id, pid=instance.pid)
318
+ # Docker mode would go here
319
+
320
+ worker.state.update_status(instance_id, "stopped")
321
+ worker.state.save()
322
+
323
+ return web.json_response({
324
+ "id": instance.id,
325
+ "status": "stopped",
326
+ "assigned_port": instance.assigned_port,
327
+ "message": f"Instance stopped. Port {instance.assigned_port} remains reserved.",
328
+ })
329
+
330
+
331
+ async def handle_start_instance(request: web.Request) -> web.Response:
332
+ """POST /api/v1/instances/{id}/start - Start stopped instance."""
333
+ worker: WorkerServer = request.app["worker"]
334
+ instance_id = request.match_info["id"]
335
+
336
+ instance = worker.state.instances.get(instance_id)
337
+ if not instance:
338
+ return web.json_response({"error": "Instance not found"}, status=404)
339
+
340
+ # Start based on mode
341
+ if instance.mode == "native":
342
+ proc_info = worker.native_manager.start(
343
+ instance_id=instance_id,
344
+ environment_name=instance.environment_name,
345
+ port=instance.assigned_port,
346
+ )
347
+ if proc_info:
348
+ worker.state.update_status(instance_id, "running", pid=proc_info.pid)
349
+ else:
350
+ return web.json_response({"error": "Failed to start instance"}, status=500)
351
+ else:
352
+ return web.json_response({"error": "Docker mode not yet supported"}, status=501)
353
+
354
+ worker.state.save()
355
+
356
+ return web.json_response({
357
+ "id": instance.id,
358
+ "status": "running",
359
+ "assigned_port": instance.assigned_port,
360
+ "comfyui_url": f"http://localhost:{instance.assigned_port}",
361
+ "message": f"Instance started on port {instance.assigned_port}.",
362
+ })
363
+
364
+
365
+ async def handle_terminate_instance(request: web.Request) -> web.Response:
366
+ """DELETE /api/v1/instances/{id} - Terminate instance."""
367
+ worker: WorkerServer = request.app["worker"]
368
+ instance_id = request.match_info["id"]
369
+ keep_env = request.query.get("keep_env", "false").lower() == "true"
370
+
371
+ instance = worker.state.instances.get(instance_id)
372
+ if not instance:
373
+ return web.json_response({"error": "Instance not found"}, status=404)
374
+
375
+ # Terminate based on mode
376
+ if instance.mode == "native":
377
+ worker.native_manager.terminate(instance_id, pid=instance.pid)
378
+ if not keep_env:
379
+ worker.native_manager.delete_environment(instance.environment_name)
380
+ # Docker mode would go here
381
+
382
+ # Release port and remove from state
383
+ worker.port_allocator.release(instance_id)
384
+ worker.state.remove_instance(instance_id)
385
+ worker.state.save()
386
+
387
+ msg = f"Instance terminated. Port {instance.assigned_port} released."
388
+ if not keep_env:
389
+ msg += f" Environment '{instance.environment_name}' deleted."
390
+
391
+ return web.json_response({
392
+ "id": instance_id,
393
+ "status": "terminated",
394
+ "message": msg,
395
+ })
396
+
397
+
398
+ async def handle_logs(request: web.Request) -> web.Response | web.WebSocketResponse:
399
+ """Handle /api/v1/instances/{id}/logs - GET for fetch, WebSocket for streaming."""
400
+ # Check if this is a WebSocket upgrade request
401
+ if request.headers.get("Upgrade", "").lower() == "websocket":
402
+ return await _handle_logs_websocket(request)
403
+
404
+ # Regular HTTP GET request
405
+ worker: WorkerServer = request.app["worker"]
406
+ instance_id = request.match_info["id"]
407
+
408
+ instance = worker.state.instances.get(instance_id)
409
+ if not instance:
410
+ return web.json_response({"error": "Instance not found"}, status=404)
411
+
412
+ lines = int(request.query.get("lines", "100"))
413
+
414
+ if instance.mode == "native":
415
+ process_logs = worker.native_manager.get_logs(instance_id, lines=lines)
416
+ logs = [{"level": "INFO", "message": line} for line in process_logs.stdout]
417
+ else:
418
+ logs = []
419
+
420
+ return web.json_response({"logs": logs})
421
+
422
+
423
+ async def _handle_logs_websocket(request: web.Request) -> web.WebSocketResponse:
424
+ """WebSocket /api/v1/instances/{id}/logs - Stream instance logs."""
425
+ worker: WorkerServer = request.app["worker"]
426
+ instance_id = request.match_info["id"]
427
+
428
+ instance = worker.state.instances.get(instance_id)
429
+ if not instance:
430
+ raise web.HTTPNotFound(text="Instance not found")
431
+
432
+ ws = web.WebSocketResponse()
433
+ await ws.prepare(request)
434
+
435
+ # Stream logs (no initial connection message - tests expect first message to be log type)
436
+ last_index = 0
437
+ try:
438
+ while not ws.closed:
439
+ if instance.mode == "native":
440
+ buf = worker.native_manager._log_buffers.get(instance_id, [])
441
+ # Send new lines since last check
442
+ if len(buf) > last_index:
443
+ for line in buf[last_index:]:
444
+ await ws.send_json({
445
+ "type": "log",
446
+ "timestamp": datetime.now(timezone.utc).isoformat(),
447
+ "level": "INFO",
448
+ "message": line,
449
+ })
450
+ last_index = len(buf)
451
+
452
+ await asyncio.sleep(0.5)
453
+ except asyncio.CancelledError:
454
+ # Server shutdown - close websocket gracefully
455
+ await ws.close()
456
+ except Exception:
457
+ pass
458
+ finally:
459
+ if not ws.closed:
460
+ await ws.close()
461
+
462
+ return ws
463
+
464
+
465
+ def create_worker_app(
466
+ api_key: str,
467
+ workspace_path: Path,
468
+ default_mode: str = "docker",
469
+ port_range_start: int = 8200,
470
+ port_range_end: int = 8210,
471
+ state_dir: Path | None = None,
472
+ ) -> web.Application:
473
+ """Create aiohttp application for worker server.
474
+
475
+ Args:
476
+ api_key: API key for authentication
477
+ workspace_path: ComfyGit workspace path
478
+ default_mode: Default instance mode
479
+ port_range_start: First port for instances
480
+ port_range_end: Last port for instances
481
+ state_dir: Directory for state files
482
+
483
+ Returns:
484
+ Configured aiohttp Application
485
+ """
486
+ app = web.Application(middlewares=[auth_middleware])
487
+
488
+ # Create worker server instance
489
+ worker = WorkerServer(
490
+ api_key=api_key,
491
+ workspace_path=workspace_path,
492
+ default_mode=default_mode,
493
+ port_range_start=port_range_start,
494
+ port_range_end=port_range_end,
495
+ state_dir=state_dir,
496
+ )
497
+ app["worker"] = worker
498
+
499
+ # Register routes
500
+ app.router.add_get("/api/v1/health", handle_health)
501
+ app.router.add_get("/api/v1/system/info", handle_system_info)
502
+ app.router.add_get("/api/v1/instances", handle_list_instances)
503
+ app.router.add_post("/api/v1/instances", handle_create_instance)
504
+ app.router.add_get("/api/v1/instances/{id}", handle_get_instance)
505
+ app.router.add_post("/api/v1/instances/{id}/stop", handle_stop_instance)
506
+ app.router.add_post("/api/v1/instances/{id}/start", handle_start_instance)
507
+ app.router.add_delete("/api/v1/instances/{id}", handle_terminate_instance)
508
+ # Combined handler for both HTTP GET and WebSocket upgrade
509
+ app.router.add_get("/api/v1/instances/{id}/logs", handle_logs)
510
+
511
+ return app