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,268 @@
1
+ """Worker instance state management with persistent port allocation.
2
+
3
+ Manages instance lifecycle and persists state to JSON for recovery across restarts.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import signal
9
+ import socket
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ @dataclass
17
+ class InstanceState:
18
+ """State for a single ComfyUI instance."""
19
+
20
+ id: str
21
+ name: str
22
+ environment_name: str
23
+ mode: str # "docker" | "native"
24
+ assigned_port: int
25
+ import_source: str
26
+ branch: str | None = None
27
+ status: str = "stopped" # "deploying" | "running" | "stopped" | "error"
28
+ container_id: str | None = None
29
+ pid: int | None = None
30
+ created_at: str = field(
31
+ default_factory=lambda: datetime.now(timezone.utc).isoformat()
32
+ )
33
+
34
+ def to_dict(self) -> dict[str, Any]:
35
+ """Serialize to dict for JSON storage."""
36
+ return {
37
+ "id": self.id,
38
+ "name": self.name,
39
+ "environment_name": self.environment_name,
40
+ "mode": self.mode,
41
+ "assigned_port": self.assigned_port,
42
+ "import_source": self.import_source,
43
+ "branch": self.branch,
44
+ "status": self.status,
45
+ "container_id": self.container_id,
46
+ "pid": self.pid,
47
+ "created_at": self.created_at,
48
+ }
49
+
50
+ @classmethod
51
+ def from_dict(cls, data: dict[str, Any]) -> "InstanceState":
52
+ """Deserialize from dict."""
53
+ return cls(
54
+ id=data["id"],
55
+ name=data["name"],
56
+ environment_name=data["environment_name"],
57
+ mode=data["mode"],
58
+ assigned_port=data["assigned_port"],
59
+ import_source=data["import_source"],
60
+ branch=data.get("branch"),
61
+ status=data.get("status", "stopped"),
62
+ container_id=data.get("container_id"),
63
+ pid=data.get("pid"),
64
+ created_at=data.get(
65
+ "created_at", datetime.now(timezone.utc).isoformat()
66
+ ),
67
+ )
68
+
69
+
70
+ class PortAllocator:
71
+ """Manages port allocation for instances.
72
+
73
+ Ports are allocated at instance creation and persist across stop/start.
74
+ Only released when instance is terminated.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ state_file: Path,
80
+ base_port: int = 8188,
81
+ max_instances: int = 10,
82
+ ):
83
+ """Initialize port allocator.
84
+
85
+ Args:
86
+ state_file: Path to state JSON file
87
+ base_port: First port in range
88
+ max_instances: Maximum concurrent instances
89
+ """
90
+ self.state_file = state_file
91
+ self.base_port = base_port
92
+ self.max_port = base_port + max_instances
93
+ self.allocated: dict[str, int] = {}
94
+ self._load()
95
+
96
+ def _load(self) -> None:
97
+ """Load allocated ports from state file."""
98
+ if not self.state_file.exists():
99
+ return
100
+
101
+ try:
102
+ data = json.loads(self.state_file.read_text())
103
+ instances = data.get("instances", {})
104
+ for inst_id, inst_data in instances.items():
105
+ if "assigned_port" in inst_data:
106
+ self.allocated[inst_id] = inst_data["assigned_port"]
107
+ except (json.JSONDecodeError, OSError):
108
+ pass
109
+
110
+ def _persist(self) -> None:
111
+ """Persist port allocations (called from WorkerState.save)."""
112
+ pass # WorkerState handles persistence
113
+
114
+ def _is_port_in_use(self, port: int) -> bool:
115
+ """Check if port is currently in use by another process."""
116
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
117
+ try:
118
+ s.bind(("127.0.0.1", port))
119
+ return False
120
+ except OSError:
121
+ return True
122
+
123
+ def allocate(self, instance_id: str) -> int:
124
+ """Allocate port for instance.
125
+
126
+ Finds a port that is both not allocated to another instance AND
127
+ not currently in use by any process (handles orphan processes).
128
+
129
+ Args:
130
+ instance_id: Unique instance identifier
131
+
132
+ Returns:
133
+ Allocated port number
134
+
135
+ Raises:
136
+ RuntimeError: If no ports available
137
+ """
138
+ # Return existing allocation if present
139
+ if instance_id in self.allocated:
140
+ return self.allocated[instance_id]
141
+
142
+ # Find next available port (not allocated AND not in use)
143
+ used_ports = set(self.allocated.values())
144
+ for port in range(self.base_port, self.max_port):
145
+ if port not in used_ports and not self._is_port_in_use(port):
146
+ self.allocated[instance_id] = port
147
+ return port
148
+
149
+ raise RuntimeError("No available ports")
150
+
151
+ def release(self, instance_id: str) -> None:
152
+ """Release port when instance terminated."""
153
+ self.allocated.pop(instance_id, None)
154
+
155
+
156
+ class WorkerState:
157
+ """Persistent state for all instances managed by this worker."""
158
+
159
+ def __init__(self, state_file: Path, workspace_path: Path | None = None):
160
+ """Initialize worker state.
161
+
162
+ Args:
163
+ state_file: Path to instances.json
164
+ workspace_path: ComfyGit workspace path (for environment validation)
165
+ """
166
+ self.state_file = state_file
167
+ self.workspace_path = workspace_path
168
+ self.instances: dict[str, InstanceState] = {}
169
+ self._load()
170
+ if workspace_path:
171
+ self._validate_instances()
172
+
173
+ def _validate_instances(self) -> None:
174
+ """Remove instances whose environments no longer exist.
175
+
176
+ Checks for .cec/.complete marker file to confirm environment is valid.
177
+ Kills any running processes before removing instances.
178
+ Persists cleanup to disk if any instances were removed.
179
+ """
180
+ if not self.workspace_path:
181
+ return
182
+
183
+ envs_dir = self.workspace_path / "environments"
184
+ orphans = []
185
+
186
+ for inst_id, inst in self.instances.items():
187
+ marker = envs_dir / inst.environment_name / ".cec" / ".complete"
188
+ if not marker.exists():
189
+ orphans.append((inst_id, inst))
190
+
191
+ if orphans:
192
+ for inst_id, inst in orphans:
193
+ # Kill any running process before removing
194
+ self._kill_instance_process(inst)
195
+ del self.instances[inst_id]
196
+ self.save()
197
+
198
+ def _kill_instance_process(self, inst: InstanceState) -> None:
199
+ """Kill a running instance process by PID.
200
+
201
+ Args:
202
+ inst: Instance to kill
203
+ """
204
+ if not inst.pid:
205
+ return
206
+
207
+ try:
208
+ # Send SIGTERM to process group
209
+ os.killpg(os.getpgid(inst.pid), signal.SIGTERM)
210
+ except (ProcessLookupError, PermissionError, OSError):
211
+ # Process already gone or we can't kill it
212
+ pass
213
+
214
+ def _load(self) -> None:
215
+ """Load state from disk."""
216
+ if not self.state_file.exists():
217
+ return
218
+
219
+ try:
220
+ data = json.loads(self.state_file.read_text())
221
+ for inst_id, inst_data in data.get("instances", {}).items():
222
+ self.instances[inst_id] = InstanceState.from_dict(inst_data)
223
+ except (json.JSONDecodeError, OSError):
224
+ pass
225
+
226
+ def save(self) -> None:
227
+ """Persist state to disk."""
228
+ self.state_file.parent.mkdir(parents=True, exist_ok=True)
229
+
230
+ data = {
231
+ "version": "1",
232
+ "instances": {
233
+ inst_id: inst.to_dict() for inst_id, inst in self.instances.items()
234
+ },
235
+ }
236
+ self.state_file.write_text(json.dumps(data, indent=2))
237
+
238
+ def add_instance(self, instance: InstanceState) -> None:
239
+ """Add instance to state."""
240
+ self.instances[instance.id] = instance
241
+
242
+ def remove_instance(self, instance_id: str) -> None:
243
+ """Remove instance from state."""
244
+ self.instances.pop(instance_id, None)
245
+
246
+ def update_status(
247
+ self,
248
+ instance_id: str,
249
+ status: str,
250
+ container_id: str | None = None,
251
+ pid: int | None = None,
252
+ ) -> None:
253
+ """Update instance status.
254
+
255
+ Args:
256
+ instance_id: Instance to update
257
+ status: New status
258
+ container_id: Container ID (for docker mode)
259
+ pid: Process ID (for native mode)
260
+ """
261
+ if instance_id not in self.instances:
262
+ return
263
+
264
+ self.instances[instance_id].status = status
265
+ if container_id is not None:
266
+ self.instances[instance_id].container_id = container_id
267
+ if pid is not None:
268
+ self.instances[instance_id].pid = pid
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: comfygit-deploy
3
+ Version: 0.3.4
4
+ Summary: ComfyGit Deploy - Remote deployment and worker management CLI
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: aiohttp>=3.9.0
7
+ Requires-Dist: comfygit==0.3.4
8
+ Requires-Dist: zeroconf>=0.131.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # ComfyGit Deploy
12
+
13
+ Remote deployment and worker management CLI for ComfyGit.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install comfygit-deploy
19
+ # or
20
+ uv tool install comfygit-deploy
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ # Configure RunPod
27
+ cg-deploy runpod config --api-key <your-key>
28
+
29
+ # Deploy to RunPod
30
+ cg-deploy runpod deploy <git-url> --gpu "RTX 4090"
31
+
32
+ # Manage instances
33
+ cg-deploy instances
34
+ cg-deploy stop <instance-id>
35
+ cg-deploy terminate <instance-id>
36
+ ```
37
+
38
+ See the [documentation](../../docs/comfygit-docs/) for full usage information.
@@ -0,0 +1,23 @@
1
+ comfygit_deploy/__init__.py,sha256=Q7wYvVHTPr7HycCd3BE-urzDRTES1CRA7Gr7qLxpAIg,92
2
+ comfygit_deploy/cli.py,sha256=_bRjuxBrIpzOzuOXpjXXZsRL_Ptv11nIstNxJDRzVpk,15221
3
+ comfygit_deploy/config.py,sha256=64KWd0Ry5z7Iqpzx8YiE_c51Cs8iYVecQe8UxzZPUVk,3582
4
+ comfygit_deploy/commands/__init__.py,sha256=0bI5lasM3-FC-xS7vwsTMAInfkNqzvI6BztYQ_SvBes,139
5
+ comfygit_deploy/commands/custom.py,sha256=UyOngaDsZlzKLSNW873HD2ma7W0nFv_yYx9deud10Qk,6234
6
+ comfygit_deploy/commands/dev.py,sha256=3fQoppdMtxOiyN9Y--TT5GKTiiOWEI5JF1xYdE76x_0,11138
7
+ comfygit_deploy/commands/instances.py,sha256=R7edUNUZSgt_dB7OWUMQBwOg-dvCLR_dvax107OvsqE,16632
8
+ comfygit_deploy/commands/runpod.py,sha256=OJWk46I2yoCsu_o95C_ktmJ6EnPXU4rki4SraBUeaKo,6309
9
+ comfygit_deploy/commands/worker.py,sha256=CJaaSQ0w-dcLQI52ePSZgzYB4yf-2oC_2fXCl5Na-Ec,7657
10
+ comfygit_deploy/providers/__init__.py,sha256=75-usN95nTBMf_aEGkEZOFlNoz56TXZNSpL8huBDDdk,264
11
+ comfygit_deploy/providers/custom.py,sha256=DeOIvNy1xgN4GSU8lWlaLdKsue65c5tFTFJSoMrBfZo,7817
12
+ comfygit_deploy/providers/runpod.py,sha256=dFTSMzGzL3fY7GKzZ799ESjp-QvguQKmwvHMN923-lM,19220
13
+ comfygit_deploy/startup/__init__.py,sha256=qFZ7-9E7m8p6ulyvNak4Iz5B5m8pX2DhQGJFmgb17Bc,49
14
+ comfygit_deploy/startup/scripts.py,sha256=OsWIgOqQ-GbcIJ8S72cvL_e3r433z-LkM9Ycqft8c34,6899
15
+ comfygit_deploy/worker/__init__.py,sha256=ty_5VXMQXAr5w7XWi_m65e1Vw0NUlIwuxIGo24KadE8,294
16
+ comfygit_deploy/worker/mdns.py,sha256=sN6VnGVW8R-TyMrphZo4_WMGWEuhe5nrGDMR0hzIEtA,4899
17
+ comfygit_deploy/worker/native_manager.py,sha256=ltvjY5PI4bUBF9JkGHzfbMv1I6DncxzCWEPAsAVf4Sw,13304
18
+ comfygit_deploy/worker/server.py,sha256=UI1rdcMkM4xY1rDtm9ayAzKJauVD8323LpO4OwkQsYk,17503
19
+ comfygit_deploy/worker/state.py,sha256=HxqPNG5_ltf_KL2VM-3-ZVzUnZTqCTLgzF9AvIcvWqo,8655
20
+ comfygit_deploy-0.3.4.dist-info/METADATA,sha256=Hpc_tFfNo4BZp-s-kg87RQOTzVY4wXKt12fv_5kcg00,802
21
+ comfygit_deploy-0.3.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
22
+ comfygit_deploy-0.3.4.dist-info/entry_points.txt,sha256=ce_A6mSttdZGySZbWok6zySv93sjtDaKX6De23i3ylM,55
23
+ comfygit_deploy-0.3.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cg-deploy = comfygit_deploy.cli:main