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/__init__.py +23 -0
- pactown/cli.py +347 -0
- pactown/config.py +158 -0
- pactown/deploy/__init__.py +17 -0
- pactown/deploy/base.py +263 -0
- pactown/deploy/compose.py +359 -0
- pactown/deploy/docker.py +299 -0
- pactown/deploy/kubernetes.py +449 -0
- pactown/deploy/podman.py +400 -0
- pactown/generator.py +212 -0
- pactown/network.py +245 -0
- pactown/orchestrator.py +455 -0
- pactown/parallel.py +268 -0
- pactown/registry/__init__.py +12 -0
- pactown/registry/client.py +253 -0
- pactown/registry/models.py +150 -0
- pactown/registry/server.py +207 -0
- pactown/resolver.py +160 -0
- pactown/sandbox_manager.py +328 -0
- pactown-0.1.4.dist-info/METADATA +308 -0
- pactown-0.1.4.dist-info/RECORD +24 -0
- pactown-0.1.4.dist-info/WHEEL +4 -0
- pactown-0.1.4.dist-info/entry_points.txt +3 -0
- pactown-0.1.4.dist-info/licenses/LICENSE +201 -0
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
|
pactown/orchestrator.py
ADDED
|
@@ -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
|