pactown 0.1.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.
pactown/network.py ADDED
@@ -0,0 +1,245 @@
1
+ """Network management for pactown - dynamic ports and service discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+ import json
7
+ import os
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Optional
11
+ from threading import Lock
12
+
13
+
14
+ @dataclass
15
+ class ServiceEndpoint:
16
+ """Represents a running service's network endpoint."""
17
+ name: str
18
+ host: str
19
+ port: int
20
+ health_check: Optional[str] = None
21
+
22
+ @property
23
+ def url(self) -> str:
24
+ return f"http://{self.host}:{self.port}"
25
+
26
+ @property
27
+ def health_url(self) -> Optional[str]:
28
+ if self.health_check:
29
+ return f"{self.url}{self.health_check}"
30
+ return None
31
+
32
+
33
+ class PortAllocator:
34
+ """Allocates free ports dynamically."""
35
+
36
+ def __init__(self, start_port: int = 10000, end_port: int = 65000):
37
+ self.start_port = start_port
38
+ self.end_port = end_port
39
+ self._allocated: set[int] = set()
40
+ self._lock = Lock()
41
+
42
+ def is_port_free(self, port: int) -> bool:
43
+ """Check if a port is available."""
44
+ if port in self._allocated:
45
+ return False
46
+ try:
47
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
48
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
49
+ s.bind(('0.0.0.0', port))
50
+ return True
51
+ except OSError:
52
+ return False
53
+
54
+ def allocate(self, preferred_port: Optional[int] = None) -> int:
55
+ """
56
+ Allocate a free port.
57
+
58
+ If preferred_port is given and available, use it.
59
+ Otherwise, find the next available port.
60
+ """
61
+ with self._lock:
62
+ # Try preferred port first
63
+ if preferred_port and self.is_port_free(preferred_port):
64
+ self._allocated.add(preferred_port)
65
+ return preferred_port
66
+
67
+ # Find next available port
68
+ for port in range(self.start_port, self.end_port):
69
+ if self.is_port_free(port):
70
+ self._allocated.add(port)
71
+ return port
72
+
73
+ raise RuntimeError("No free ports available")
74
+
75
+ def release(self, port: int) -> None:
76
+ """Release an allocated port."""
77
+ with self._lock:
78
+ self._allocated.discard(port)
79
+
80
+ def release_all(self) -> None:
81
+ """Release all allocated ports."""
82
+ with self._lock:
83
+ self._allocated.clear()
84
+
85
+
86
+ class ServiceRegistry:
87
+ """
88
+ Local service registry for name-based service discovery.
89
+
90
+ Services register with their name and get assigned a port.
91
+ Other services can look up endpoints by name.
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ storage_path: Optional[Path] = None,
97
+ host: str = "127.0.0.1",
98
+ ):
99
+ self.host = host
100
+ self.storage_path = storage_path or Path(".pactown-services.json")
101
+ self._services: dict[str, ServiceEndpoint] = {}
102
+ self._port_allocator = PortAllocator()
103
+ self._lock = Lock()
104
+ self._load()
105
+
106
+ def _load(self) -> None:
107
+ """Load service registry from disk."""
108
+ if self.storage_path.exists():
109
+ try:
110
+ with open(self.storage_path) as f:
111
+ data = json.load(f)
112
+ for name, info in data.get("services", {}).items():
113
+ self._services[name] = ServiceEndpoint(
114
+ name=info["name"],
115
+ host=info["host"],
116
+ port=info["port"],
117
+ health_check=info.get("health_check"),
118
+ )
119
+ except (json.JSONDecodeError, KeyError):
120
+ pass
121
+
122
+ def _save(self) -> None:
123
+ """Persist service registry to disk."""
124
+ data = {
125
+ "services": {
126
+ name: {
127
+ "name": svc.name,
128
+ "host": svc.host,
129
+ "port": svc.port,
130
+ "health_check": svc.health_check,
131
+ }
132
+ for name, svc in self._services.items()
133
+ }
134
+ }
135
+ with open(self.storage_path, "w") as f:
136
+ json.dump(data, f, indent=2)
137
+
138
+ def register(
139
+ self,
140
+ name: str,
141
+ preferred_port: Optional[int] = None,
142
+ health_check: Optional[str] = None,
143
+ ) -> ServiceEndpoint:
144
+ """
145
+ Register a service and allocate a port.
146
+
147
+ If preferred_port is available, use it. Otherwise, allocate dynamically.
148
+ """
149
+ with self._lock:
150
+ # Check if already registered
151
+ if name in self._services:
152
+ existing = self._services[name]
153
+ # Check if existing port is still free
154
+ if self._port_allocator.is_port_free(existing.port):
155
+ return existing
156
+ # Port is taken, need to reallocate
157
+ self._port_allocator.release(existing.port)
158
+
159
+ # Allocate port
160
+ port = self._port_allocator.allocate(preferred_port)
161
+
162
+ endpoint = ServiceEndpoint(
163
+ name=name,
164
+ host=self.host,
165
+ port=port,
166
+ health_check=health_check,
167
+ )
168
+
169
+ self._services[name] = endpoint
170
+ self._save()
171
+
172
+ return endpoint
173
+
174
+ def unregister(self, name: str) -> None:
175
+ """Unregister a service."""
176
+ with self._lock:
177
+ if name in self._services:
178
+ self._port_allocator.release(self._services[name].port)
179
+ del self._services[name]
180
+ self._save()
181
+
182
+ def get(self, name: str) -> Optional[ServiceEndpoint]:
183
+ """Get service endpoint by name."""
184
+ return self._services.get(name)
185
+
186
+ def get_url(self, name: str) -> Optional[str]:
187
+ """Get service URL by name."""
188
+ svc = self.get(name)
189
+ return svc.url if svc else None
190
+
191
+ def list_services(self) -> list[ServiceEndpoint]:
192
+ """List all registered services."""
193
+ return list(self._services.values())
194
+
195
+ def get_environment(self, service_name: str, dependencies: list[str]) -> dict[str, str]:
196
+ """
197
+ Get environment variables for a service.
198
+
199
+ Injects URLs for all dependencies as environment variables.
200
+ """
201
+ env = {}
202
+
203
+ # Add own endpoint info
204
+ if service_name in self._services:
205
+ svc = self._services[service_name]
206
+ env["MARKPACT_PORT"] = str(svc.port)
207
+ env["SERVICE_NAME"] = service_name
208
+ env["SERVICE_URL"] = svc.url
209
+
210
+ # Add dependency URLs
211
+ for dep_name in dependencies:
212
+ if dep_name in self._services:
213
+ dep = self._services[dep_name]
214
+ # Multiple environment variable formats for flexibility
215
+ env_key = dep_name.upper().replace("-", "_").replace(".", "_")
216
+ env[f"{env_key}_URL"] = dep.url
217
+ env[f"{env_key}_HOST"] = dep.host
218
+ env[f"{env_key}_PORT"] = str(dep.port)
219
+
220
+ return env
221
+
222
+ def clear(self) -> None:
223
+ """Clear all registrations."""
224
+ with self._lock:
225
+ self._services.clear()
226
+ self._port_allocator.release_all()
227
+ if self.storage_path.exists():
228
+ self.storage_path.unlink()
229
+
230
+
231
+ def find_free_port(start: int = 10000, end: int = 65000) -> int:
232
+ """Find a single free port."""
233
+ allocator = PortAllocator(start, end)
234
+ return allocator.allocate()
235
+
236
+
237
+ def check_port(port: int) -> bool:
238
+ """Check if a specific port is available."""
239
+ try:
240
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
241
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
242
+ s.bind(('0.0.0.0', port))
243
+ return True
244
+ except OSError:
245
+ return False
@@ -0,0 +1,455 @@
1
+ """Orchestrator for managing pactown service ecosystems."""
2
+
3
+ import asyncio
4
+ import time
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Optional, Callable
8
+ import httpx
9
+
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+ from rich.live import Live
13
+ from rich.panel import Panel
14
+
15
+ from .config import EcosystemConfig, load_config
16
+ from .resolver import DependencyResolver
17
+ from .sandbox_manager import SandboxManager, ServiceProcess
18
+ from .network import ServiceRegistry, ServiceEndpoint
19
+
20
+
21
+ console = Console()
22
+
23
+
24
+ @dataclass
25
+ class ServiceHealth:
26
+ """Health status of a service."""
27
+ name: str
28
+ healthy: bool
29
+ status_code: Optional[int] = None
30
+ response_time_ms: Optional[float] = None
31
+ error: Optional[str] = None
32
+
33
+
34
+ class Orchestrator:
35
+ """Orchestrates the lifecycle of a pactown ecosystem."""
36
+
37
+ def __init__(
38
+ self,
39
+ config: EcosystemConfig,
40
+ base_path: Optional[Path] = None,
41
+ verbose: bool = True,
42
+ dynamic_ports: bool = True,
43
+ ):
44
+ self.config = config
45
+ self.base_path = base_path or Path.cwd()
46
+ self.verbose = verbose
47
+ self.dynamic_ports = dynamic_ports
48
+ self.resolver = DependencyResolver(config)
49
+ self.sandbox_manager = SandboxManager(config.sandbox_root)
50
+ self._running: dict[str, ServiceProcess] = {}
51
+
52
+ # Service registry for dynamic port allocation and discovery
53
+ registry_path = Path(config.sandbox_root) / ".pactown-services.json"
54
+ self.service_registry = ServiceRegistry(storage_path=registry_path)
55
+
56
+ @classmethod
57
+ def from_file(
58
+ cls,
59
+ config_path: str | Path,
60
+ verbose: bool = True,
61
+ dynamic_ports: bool = True,
62
+ ) -> "Orchestrator":
63
+ """Create orchestrator from configuration file."""
64
+ config_path = Path(config_path)
65
+ config = load_config(config_path)
66
+ return cls(config, base_path=config_path.parent, verbose=verbose, dynamic_ports=dynamic_ports)
67
+
68
+ def _get_readme_path(self, service_name: str) -> Path:
69
+ """Get the README path for a service."""
70
+ service = self.config.services[service_name]
71
+ readme_path = self.base_path / service.readme
72
+ if not readme_path.exists():
73
+ raise FileNotFoundError(f"README not found: {readme_path}")
74
+ return readme_path
75
+
76
+ def validate(self) -> bool:
77
+ """Validate the ecosystem configuration."""
78
+ issues = self.resolver.validate()
79
+
80
+ for name, service in self.config.services.items():
81
+ readme_path = self.base_path / service.readme
82
+ if not readme_path.exists():
83
+ issues.append(f"README not found for '{name}': {readme_path}")
84
+
85
+ if issues:
86
+ console.print("[red]Validation failed:[/red]")
87
+ for issue in issues:
88
+ console.print(f" • {issue}")
89
+ return False
90
+
91
+ console.print("[green]✓ Ecosystem configuration is valid[/green]")
92
+ return True
93
+
94
+ def start_service(self, service_name: str) -> ServiceProcess:
95
+ """Start a single service."""
96
+ if service_name not in self.config.services:
97
+ raise ValueError(f"Unknown service: {service_name}")
98
+
99
+ service = self.config.services[service_name]
100
+ readme_path = self._get_readme_path(service_name)
101
+
102
+ # Register service and get allocated port
103
+ if self.dynamic_ports:
104
+ endpoint = self.service_registry.register(
105
+ name=service_name,
106
+ preferred_port=service.port,
107
+ health_check=service.health_check,
108
+ )
109
+ actual_port = endpoint.port
110
+
111
+ if self.verbose and actual_port != service.port:
112
+ console.print(f" [yellow]Port {service.port} busy, using {actual_port}[/yellow]")
113
+ else:
114
+ actual_port = service.port
115
+
116
+ # Get dependencies from config
117
+ dep_names = [d.name for d in service.depends_on]
118
+
119
+ # Build environment with service discovery
120
+ env = self.service_registry.get_environment(service_name, dep_names)
121
+
122
+ # Add any extra env from config
123
+ env.update(service.env)
124
+ env["PACTOWN_ECOSYSTEM"] = self.config.name
125
+
126
+ # Override port in service config for this run
127
+ service_copy = service
128
+ if actual_port != service.port:
129
+ from dataclasses import replace
130
+ service_copy = replace(service, port=actual_port)
131
+
132
+ process = self.sandbox_manager.start_service(
133
+ service_copy, readme_path, env, verbose=self.verbose
134
+ )
135
+ self._running[service_name] = process
136
+ return process
137
+
138
+ def start_all(
139
+ self,
140
+ wait_for_health: bool = True,
141
+ parallel: bool = True,
142
+ max_workers: int = 4,
143
+ ) -> dict[str, ServiceProcess]:
144
+ """
145
+ Start all services in dependency order.
146
+
147
+ Args:
148
+ wait_for_health: Wait for health checks
149
+ parallel: Use parallel execution for independent services
150
+ max_workers: Max parallel workers
151
+ """
152
+ if parallel:
153
+ return self._start_all_parallel(wait_for_health, max_workers)
154
+ else:
155
+ return self._start_all_sequential(wait_for_health)
156
+
157
+ def _start_all_sequential(self, wait_for_health: bool = True) -> dict[str, ServiceProcess]:
158
+ """Start all services sequentially in dependency order."""
159
+ order = self.resolver.get_startup_order()
160
+
161
+ if self.verbose:
162
+ console.print(f"\n[bold]Starting ecosystem: {self.config.name}[/bold]")
163
+ console.print(f"Startup order: {' → '.join(order)}\n")
164
+
165
+ for name in order:
166
+ try:
167
+ self.start_service(name)
168
+
169
+ if wait_for_health:
170
+ service = self.config.services[name]
171
+ if service.health_check:
172
+ self._wait_for_health(name, timeout=service.timeout)
173
+ else:
174
+ time.sleep(1)
175
+
176
+ except Exception as e:
177
+ console.print(f"[red]Failed to start {name}: {e}[/red]")
178
+ self.stop_all()
179
+ raise
180
+
181
+ if self.verbose:
182
+ self.print_status()
183
+
184
+ return self._running
185
+
186
+ def _start_all_parallel(
187
+ self,
188
+ wait_for_health: bool = True,
189
+ max_workers: int = 4,
190
+ ) -> dict[str, ServiceProcess]:
191
+ """
192
+ Start services in parallel waves based on dependencies.
193
+
194
+ Services with no unmet dependencies start together in parallel.
195
+ Once a wave completes, the next wave starts.
196
+ """
197
+ from concurrent.futures import ThreadPoolExecutor, as_completed
198
+
199
+ if self.verbose:
200
+ console.print(f"\n[bold]Starting ecosystem: {self.config.name} (parallel)[/bold]")
201
+
202
+ # Build dependency map
203
+ deps_map: dict[str, list[str]] = {}
204
+ for name, service in self.config.services.items():
205
+ deps_map[name] = [d.name for d in service.depends_on if d.name in self.config.services]
206
+
207
+ started = set()
208
+ remaining = set(self.config.services.keys())
209
+ wave_num = 0
210
+
211
+ while remaining:
212
+ # Find services ready to start (all deps satisfied)
213
+ ready = [
214
+ name for name in remaining
215
+ if all(d in started for d in deps_map.get(name, []))
216
+ ]
217
+
218
+ if not ready:
219
+ raise ValueError(f"Cannot resolve dependencies for: {remaining}")
220
+
221
+ wave_num += 1
222
+ if self.verbose:
223
+ console.print(f"\n[cyan]Wave {wave_num}:[/cyan] {', '.join(ready)}")
224
+
225
+ # Start ready services in parallel
226
+ wave_results = {}
227
+ wave_errors = {}
228
+
229
+ with ThreadPoolExecutor(max_workers=min(max_workers, len(ready))) as executor:
230
+ futures = {}
231
+
232
+ for name in ready:
233
+ future = executor.submit(self._start_service_with_health, name, wait_for_health)
234
+ futures[future] = name
235
+
236
+ for future in as_completed(futures):
237
+ name = futures[future]
238
+ try:
239
+ proc = future.result()
240
+ wave_results[name] = proc
241
+ self._running[name] = proc
242
+ started.add(name)
243
+ remaining.remove(name)
244
+ except Exception as e:
245
+ wave_errors[name] = str(e)
246
+ remaining.remove(name)
247
+
248
+ # Report wave results
249
+ for name in wave_results:
250
+ if self.verbose:
251
+ console.print(f" [green]✓[/green] {name} started")
252
+
253
+ for name, error in wave_errors.items():
254
+ console.print(f" [red]✗[/red] {name}: {error}")
255
+
256
+ # Stop on any failure
257
+ if wave_errors:
258
+ console.print(f"\n[red]Stopping due to errors...[/red]")
259
+ self.stop_all()
260
+ raise RuntimeError(f"Failed to start services: {wave_errors}")
261
+
262
+ if self.verbose:
263
+ console.print()
264
+ self.print_status()
265
+
266
+ return self._running
267
+
268
+ def _start_service_with_health(self, service_name: str, wait_for_health: bool) -> ServiceProcess:
269
+ """Start a service and optionally wait for health check."""
270
+ proc = self.start_service(service_name)
271
+
272
+ if wait_for_health:
273
+ service = self.config.services[service_name]
274
+ if service.health_check:
275
+ self._wait_for_health(service_name, timeout=service.timeout)
276
+ else:
277
+ time.sleep(0.5)
278
+
279
+ return proc
280
+
281
+ def stop_service(self, service_name: str) -> bool:
282
+ """Stop a single service."""
283
+ if service_name not in self._running:
284
+ return False
285
+
286
+ if self.verbose:
287
+ console.print(f"Stopping {service_name}...")
288
+
289
+ success = self.sandbox_manager.stop_service(service_name)
290
+ if success:
291
+ del self._running[service_name]
292
+ return success
293
+
294
+ def stop_all(self) -> None:
295
+ """Stop all services in reverse dependency order."""
296
+ order = self.resolver.get_shutdown_order()
297
+
298
+ if self.verbose:
299
+ console.print(f"\n[bold]Stopping ecosystem: {self.config.name}[/bold]")
300
+
301
+ for name in order:
302
+ if name in self._running:
303
+ self.stop_service(name)
304
+ # Unregister from service registry
305
+ self.service_registry.unregister(name)
306
+
307
+ self.sandbox_manager.stop_all()
308
+ self._running.clear()
309
+
310
+ def restart_service(self, service_name: str) -> ServiceProcess:
311
+ """Restart a single service."""
312
+ self.stop_service(service_name)
313
+ time.sleep(0.5)
314
+ return self.start_service(service_name)
315
+
316
+ def check_health(self, service_name: str) -> ServiceHealth:
317
+ """Check health of a service."""
318
+ if service_name not in self.config.services:
319
+ return ServiceHealth(name=service_name, healthy=False, error="Unknown service")
320
+
321
+ service = self.config.services[service_name]
322
+
323
+ if service_name not in self._running:
324
+ return ServiceHealth(name=service_name, healthy=False, error="Not running")
325
+
326
+ if not self._running[service_name].is_running:
327
+ return ServiceHealth(name=service_name, healthy=False, error="Process died")
328
+
329
+ if not service.health_check:
330
+ return ServiceHealth(name=service_name, healthy=True)
331
+
332
+ # Get actual port from registry (may differ from config if dynamic)
333
+ endpoint = self.service_registry.get(service_name)
334
+ port = endpoint.port if endpoint else service.port
335
+ url = f"http://localhost:{port}{service.health_check}"
336
+
337
+ try:
338
+ start = time.time()
339
+ response = httpx.get(url, timeout=5.0)
340
+ elapsed = (time.time() - start) * 1000
341
+
342
+ return ServiceHealth(
343
+ name=service_name,
344
+ healthy=response.status_code < 400,
345
+ status_code=response.status_code,
346
+ response_time_ms=elapsed,
347
+ )
348
+ except Exception as e:
349
+ return ServiceHealth(
350
+ name=service_name,
351
+ healthy=False,
352
+ error=str(e),
353
+ )
354
+
355
+ def _wait_for_health(self, service_name: str, timeout: int = 60) -> bool:
356
+ """Wait for a service to become healthy."""
357
+ service = self.config.services[service_name]
358
+
359
+ if not service.health_check:
360
+ return True
361
+
362
+ # Get actual port from registry
363
+ endpoint = self.service_registry.get(service_name)
364
+ port = endpoint.port if endpoint else service.port
365
+ url = f"http://localhost:{port}{service.health_check}"
366
+ deadline = time.time() + timeout
367
+
368
+ while time.time() < deadline:
369
+ try:
370
+ response = httpx.get(url, timeout=2.0)
371
+ if response.status_code < 400:
372
+ if self.verbose:
373
+ console.print(f" [green]✓[/green] {service_name} is healthy")
374
+ return True
375
+ except Exception:
376
+ pass
377
+ time.sleep(0.5)
378
+
379
+ console.print(f" [yellow]⚠[/yellow] {service_name} health check timed out")
380
+ return False
381
+
382
+ def print_status(self) -> None:
383
+ """Print status of all services."""
384
+ table = Table(title=f"Ecosystem: {self.config.name}")
385
+ table.add_column("Service", style="cyan")
386
+ table.add_column("Port", style="blue")
387
+ table.add_column("Status", style="green")
388
+ table.add_column("PID")
389
+ table.add_column("Health")
390
+
391
+ for name, service in self.config.services.items():
392
+ # Get actual port from registry
393
+ endpoint = self.service_registry.get(name)
394
+ actual_port = endpoint.port if endpoint else service.port
395
+
396
+ if name in self._running:
397
+ proc = self._running[name]
398
+ running = "🟢 Running" if proc.is_running else "🔴 Stopped"
399
+ pid = str(proc.pid)
400
+
401
+ health = self.check_health(name)
402
+ if health.healthy:
403
+ health_str = f"✓ {health.response_time_ms:.0f}ms" if health.response_time_ms else "✓"
404
+ else:
405
+ health_str = health.error or "✗"
406
+ else:
407
+ running = "⚪ Not started"
408
+ pid = "-"
409
+ health_str = "-"
410
+ actual_port = service.port # Use config port if not registered
411
+
412
+ table.add_row(
413
+ name,
414
+ str(actual_port) if actual_port else "-",
415
+ running,
416
+ pid,
417
+ health_str,
418
+ )
419
+
420
+ console.print(table)
421
+
422
+ def print_graph(self) -> None:
423
+ """Print dependency graph."""
424
+ console.print(Panel(self.resolver.print_graph(), title="Dependency Graph"))
425
+
426
+ def get_logs(self, service_name: str, lines: int = 100) -> Optional[str]:
427
+ """Get recent logs from a service (if available)."""
428
+ if service_name not in self._running:
429
+ return None
430
+
431
+ proc = self._running[service_name]
432
+ if proc.process and proc.process.stdout:
433
+ return proc.process.stdout.read()
434
+ return None
435
+
436
+
437
+ def run_ecosystem(config_path: str | Path, wait: bool = True) -> Orchestrator:
438
+ """Convenience function to start an ecosystem."""
439
+ orch = Orchestrator.from_file(config_path)
440
+
441
+ if not orch.validate():
442
+ raise ValueError("Invalid ecosystem configuration")
443
+
444
+ orch.start_all()
445
+
446
+ if wait:
447
+ try:
448
+ console.print("\n[dim]Press Ctrl+C to stop all services[/dim]\n")
449
+ while True:
450
+ time.sleep(1)
451
+ except KeyboardInterrupt:
452
+ console.print("\n[yellow]Shutting down...[/yellow]")
453
+ orch.stop_all()
454
+
455
+ return orch