stackhelx 1.0.0__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.
stackhelx/runner.py ADDED
@@ -0,0 +1,877 @@
1
+ """Arranque secuenciado de los servicios de un stack.
2
+
3
+ Cada servicio corre en su propio proceso; un hilo por servicio bombea su salida
4
+ hacia una cola que el hilo principal drena e imprime con prefijo de color.
5
+
6
+ ponytail: no hay dashboard con `rich.live`. Los logs prefijados son el 90% del
7
+ valor y no pelean con el scroll de la terminal. Un panel fijo se agrega si
8
+ alguien lo pide con un caso concreto.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import queue
15
+ import re
16
+ import subprocess
17
+ import threading
18
+ import time
19
+ import urllib.error
20
+ import urllib.request
21
+ from collections import deque
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ import psutil
26
+ from rich.console import Console
27
+
28
+ from . import config, detect, ports
29
+ from .config import Service, Stack
30
+
31
+
32
+ def build_env(service: Service, extra_env: dict[str, str] | None = None) -> dict[str, str]:
33
+ """Construye el entorno de ejecucion con precedencia clara:
34
+ 1. os.environ
35
+ 2. ~/.portmaster/env.global (si existe)
36
+ 3. service.env_file (en orden)
37
+ 4. service.env (declarado explicito)
38
+ 5. extra_env (e.g. desde --env-file en CLI)
39
+ """
40
+ env = dict(os.environ)
41
+ global_env = Path.home() / ".portmaster" / "env.global"
42
+ if global_env.is_file():
43
+ env.update(config.parse_env_file(global_env))
44
+ for env_path in service.env_file:
45
+ if env_path.is_file():
46
+ env.update(config.parse_env_file(env_path))
47
+ env.update(service.env)
48
+ if extra_env:
49
+ env.update(extra_env)
50
+ env["PYTHONUNBUFFERED"] = "1"
51
+ env["FORCE_COLOR"] = "1"
52
+ return env
53
+
54
+
55
+ def service_url(service: Service, extra_env: dict[str, str] | None = None) -> str | None:
56
+ """Adonde lleva "Abrir" para este servicio, o None si no se puede saber.
57
+
58
+ Sin `url:` devuelve None y el que llama arma el default de siempre con el
59
+ puerto. Con `url:`, expande `${VAR}` y `${VAR:-default}` desde el entorno
60
+ que `build_env` ya compone, que es el mismo con el que corre el servicio: si
61
+ la URL necesita un token, es el token que el proceso recibio.
62
+
63
+ Una variable sin valor tambien devuelve None. Abrir el navegador en una URL
64
+ con un `${TOKEN}` literal adentro es peor que no ofrecer el boton: parece
65
+ que funciono.
66
+
67
+ Toca disco (`build_env` lee `env.global` y cada `env_file`), asi que no se
68
+ llama en el sondeo de la interfaz salvo para servicios ya abribles.
69
+ """
70
+ if not service.url:
71
+ return None
72
+
73
+ env = build_env(service, extra_env=extra_env)
74
+ faltante = False
75
+
76
+ def resolve(match: re.Match) -> str:
77
+ nonlocal faltante
78
+ valor = env.get(match.group(1))
79
+ if valor:
80
+ return valor
81
+ if match.group(2) is not None:
82
+ return match.group(2)
83
+ faltante = True
84
+ return match.group(0)
85
+
86
+ expandida = detect.VARIABLE.sub(resolve, service.url)
87
+ return None if faltante else expandida
88
+
89
+
90
+ COLORS = ("cyan", "magenta", "green", "yellow", "blue", "bright_red")
91
+ SHUTDOWN_TIMEOUT = 5
92
+ POLL = 0.15
93
+
94
+ # Un comando detached tiene su propio presupuesto, mucho mas largo que el del
95
+ # healthcheck: la primera vez que corre, `docker compose up -d` construye la
96
+ # imagen y eso tarda minutos sin que nada este mal.
97
+ DETACHED_TIMEOUT = 900
98
+
99
+ # Lo que se espera entre fijar la linea base de CPU de un proceso nuevo y leerla.
100
+ # Solo se paga la primera vez que un servicio aparece, y una vez por lote.
101
+ CPU_MUESTRA = 0.1
102
+
103
+ # `docker compose stop` le da su gracia a cada contenedor antes de matarlo, y con
104
+ # varios no entra en los 5s del apagado del arbol.
105
+ STOP_TIMEOUT = 90
106
+
107
+
108
+ class StartupError(Exception):
109
+ """Un servicio no arranco o no llego a estar listo."""
110
+
111
+
112
+ @dataclass
113
+ class Proc:
114
+ service: Service
115
+ popen: subprocess.Popen
116
+ color: str
117
+ ready: bool = False
118
+ matched_log: bool = False
119
+ port: int | None = None # descubierto, para los servicios con ready: listen
120
+ http: bool = False # el puerto contesta HTTP, o sea que se puede abrir
121
+ # El puerto ya aceptaba conexiones antes de arrancar. Ver _spawn_proc.
122
+ port_taken: bool = False
123
+ # Ultimas lineas de salida, para que el error diga la causa y no solo el
124
+ # codigo: "fallo con codigo 1" sin el motivo obliga a abrir los logs.
125
+ tail: deque[str] = field(default_factory=lambda: deque(maxlen=5))
126
+ # Ya lo reclamo alguien para apagarlo. Ver Runner._stop_one.
127
+ claimed: bool = False
128
+
129
+ @property
130
+ def known_port(self) -> int | None:
131
+ return self.service.port or self.port
132
+
133
+
134
+ @dataclass
135
+ class Runner:
136
+ stack: Stack
137
+ console: Console = field(default_factory=Console)
138
+ timeout: float = 60.0
139
+ procs: list[Proc] = field(default_factory=list)
140
+ extra_env: dict[str, str] = field(default_factory=dict)
141
+ _logs: queue.Queue = field(default_factory=queue.Queue)
142
+ _width: int = 8
143
+ _cancel: threading.Event = field(default_factory=threading.Event)
144
+ _down: bool = False
145
+ restarting: bool = False
146
+ _retries: dict[str, int] = field(default_factory=dict)
147
+ # psutil.Process por pid. Vive entre llamadas porque cpu_percent mide
148
+ # el delta contra la lectura anterior del mismo objeto.
149
+ _ps_cache: dict[int, psutil.Process] = field(default_factory=dict)
150
+ # Los hooks sincronos (pre_start, post_start) que estan corriendo ahora.
151
+ # Sin esto, `down` no tenia a quien matar y el hook sobrevivia al apagado.
152
+ _hooks: set[subprocess.Popen] = field(default_factory=set)
153
+ # Protege `procs`, `_down` y `_hooks` entre los hilos de un mismo nivel y el
154
+ # apagado.
155
+ _procs_lock: threading.Lock = field(default_factory=threading.Lock)
156
+
157
+ def up(self, profile: str | None = None) -> None:
158
+ """Arranca por niveles. Si algo falla, apaga lo ya levantado.
159
+
160
+ Los servicios que no dependen entre si arrancan juntos: el stack tarda
161
+ el healthcheck mas lento de cada nivel en vez de la suma de todos.
162
+ """
163
+ services = self.stack.resolve(profile)
164
+ self._width = max(len(s.name) for s in services)
165
+ # Color por posicion, antes de arrancar nada. `COLORS[len(self.procs) %
166
+ # ...]` era un read-modify-write: dos servicios del mismo nivel podian
167
+ # salir del mismo color, que es justo lo que hace legibles los logs
168
+ # cuando se entreveran.
169
+ colors = {s.name: COLORS[i % len(COLORS)] for i, s in enumerate(services)}
170
+ try:
171
+ for level in _levels(services):
172
+ self._abort_if_cancelled()
173
+ self._start_level(level, colors)
174
+ except BaseException:
175
+ self.down()
176
+ raise
177
+
178
+ def _start_level(self, level: list[Service], colors: dict[str, str]) -> None:
179
+ if len(level) == 1:
180
+ self._launch(level[0], colors[level[0].name])
181
+ return
182
+
183
+ fallos: list[BaseException] = []
184
+ threads = [
185
+ threading.Thread(target=self._launch_capturing, args=(s, colors[s.name], fallos))
186
+ for s in level
187
+ ]
188
+ for thread in threads:
189
+ thread.start()
190
+ # Se esperan todos aunque uno falle: cortar antes dejaria a los hermanos
191
+ # arrancando detras del apagado, que es el mismo bug que `cancel` vino a
192
+ # resolver para el arranque entero.
193
+ for thread in threads:
194
+ thread.join()
195
+
196
+ if fallos:
197
+ if len(fallos) > 1:
198
+ otros = ", ".join(str(f) for f in fallos[1:])
199
+ raise StartupError(f"{fallos[0]} (y ademas: {otros})") from fallos[0]
200
+ raise fallos[0]
201
+
202
+ def _launch_capturing(
203
+ self, service: Service, color: str, fallos: list[BaseException]
204
+ ) -> None:
205
+ try:
206
+ self._launch(service, color)
207
+ except BaseException as exc: # el hilo no debe morir en silencio
208
+ fallos.append(exc)
209
+
210
+ def _launch(self, service: Service, color: str) -> None:
211
+ proc = self._spawn_proc(service, color)
212
+ if not self._register(proc):
213
+ # El apagado ya paso por la lista: este proceso no lo va a ver nadie
214
+ # mas, asi que lo baja quien lo arranco.
215
+ self._stop_one(proc)
216
+ raise StartupError("apagado pedido durante el arranque")
217
+ self._wait_ready(proc)
218
+
219
+ def _register(self, proc: Proc) -> bool:
220
+ with self._procs_lock:
221
+ if self._down:
222
+ return False
223
+ self.procs.append(proc)
224
+ return True
225
+
226
+ def cancel(self) -> None:
227
+ """Aborta un arranque en curso desde otro hilo.
228
+
229
+ Sin esto, apagar mientras arranca no apaga nada: la lista de procesos
230
+ todavia esta vacia y el arranque sigue levantando servicios detras del
231
+ apagado. El propio `up` es el que baja lo que alcanzo a levantar.
232
+
233
+ Tambien corta `follow`, para que el hilo termine y se lo pueda esperar
234
+ sin importar en que fase estaba.
235
+ """
236
+ self._cancel.set()
237
+ # `_abort_if_cancelled` solo mira entre niveles y en la espera, asi que
238
+ # un hook en curso no se enteraria hasta terminar.
239
+ self._matar_hooks()
240
+
241
+ def _abort_if_cancelled(self) -> None:
242
+ if self._cancel.is_set():
243
+ raise StartupError("apagado pedido durante el arranque")
244
+
245
+ def follow(self) -> None:
246
+ """Sigue imprimiendo logs hasta Ctrl-C o hasta que no quede nada vivo."""
247
+ while not self._cancel.is_set():
248
+ with self._procs_lock:
249
+ if self._down:
250
+ break
251
+ # Watchdog de reinicio para servicios caidos
252
+ restarted_any = False
253
+ for p in list(self.procs):
254
+ with self._procs_lock:
255
+ if self._down or self._cancel.is_set():
256
+ break
257
+ if p.popen.poll() is not None and not p.service.detached:
258
+ retries = self._retries.get(p.service.name, 0)
259
+ if p.service.restart in ("on-failure", "always") and retries < p.service.max_retries:
260
+ exit_code = p.popen.poll()
261
+ if p.service.restart == "always" or exit_code != 0:
262
+ with self._procs_lock:
263
+ if self._down or self._cancel.is_set():
264
+ break
265
+ self._retries[p.service.name] = retries + 1
266
+ self._say(
267
+ p,
268
+ f"proceso terminado con codigo {exit_code}. Reiniciando automaticamente (intento {retries + 1}/{p.service.max_retries})...",
269
+ )
270
+ try:
271
+ self.restart(p.service.name)
272
+ restarted_any = True
273
+ except Exception as exc:
274
+ self._say(p, f"reintento fallo: {exc}")
275
+
276
+ with self._procs_lock:
277
+ if self._down or self._cancel.is_set():
278
+ break
279
+
280
+ # Si nadie esta vivo, no estamos reiniciando y no se disparo ningun reinicio, salimos
281
+ if not self.restarting and not restarted_any and not any(p.popen.poll() is None for p in list(self.procs)):
282
+ break
283
+
284
+ if not self._drain():
285
+ time.sleep(POLL)
286
+ self._drain()
287
+
288
+ def restart(self, name: str) -> None:
289
+ """Reinicia un servicio sin tocar el resto del stack."""
290
+ with self._procs_lock:
291
+ if self._down or self._cancel.is_set():
292
+ raise StartupError("apagado en curso, no se puede reiniciar")
293
+ index = next((i for i, p in enumerate(self.procs) if p.service.name == name), None)
294
+ if index is None:
295
+ raise StartupError(f"{name} no esta corriendo en este stack")
296
+ old = self.procs[index]
297
+ self.restarting = True
298
+
299
+ try:
300
+ self._stop_one(old)
301
+ # `_spawn_proc` corre `pre_start`, y su presupuesto es
302
+ # DETACHED_TIMEOUT: 900s. Con el lock tomado, un apagado pedido
303
+ # mientras tanto se quedaba esperando ese comando, porque `down`
304
+ # necesita el mismo lock para copiar la lista.
305
+ proc = self._spawn_proc(old.service, old.color)
306
+ with self._procs_lock:
307
+ tarde = self._down or self._cancel.is_set()
308
+ if not tarde:
309
+ self.procs[index] = proc
310
+ if tarde:
311
+ # El apagado ya paso por la lista: a este proceso no lo va a ver
312
+ # nadie mas, asi que lo baja quien lo arranco. Mismo trato que
313
+ # en `_launch` cuando `_register` devuelve False.
314
+ self._stop_one(proc)
315
+ raise StartupError("apagado en curso, no se puede reiniciar")
316
+ finally:
317
+ self.restarting = False
318
+ self._wait_ready(proc)
319
+
320
+ def resource_stats(self) -> dict[str, dict[str, float]]:
321
+ """Calcula el uso de recursos (CPU % y Memoria RSS en MB) de cada servicio activo."""
322
+ # `cpu_percent(interval=None)` mide contra la lectura anterior del
323
+ # *mismo* objeto Process. Recrearlo en cada llamada devolvia 0.0 para
324
+ # siempre: no habia lectura anterior contra la cual restar. Por eso el
325
+ # cache, y por eso el respiro de abajo.
326
+ arboles: dict[str, list[psutil.Process]] = {}
327
+ estrenados = False
328
+ for p in list(self.procs):
329
+ if p.popen.poll() is not None:
330
+ continue
331
+ arbol = []
332
+ for pid in self._pids_del_arbol(p.popen.pid):
333
+ proc = self._ps_cache.get(pid)
334
+ if proc is None:
335
+ try:
336
+ proc = psutil.Process(pid)
337
+ # Fija la linea base y descarta el 0.0 obligado.
338
+ proc.cpu_percent(interval=None)
339
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
340
+ continue
341
+ self._ps_cache[pid] = proc
342
+ estrenados = True
343
+ arbol.append(proc)
344
+ arboles[p.service.name] = arbol
345
+
346
+ if estrenados:
347
+ # Un solo respiro para todo el lote, no uno por proceso: sin esto,
348
+ # la primera lectura de un servicio recien arrancado seria 0.0 y
349
+ # `portmaster stats`, que hace una sola consulta, nunca mediria nada.
350
+ time.sleep(CPU_MUESTRA)
351
+
352
+ vivos = {proc.pid for arbol in arboles.values() for proc in arbol}
353
+ for pid in list(self._ps_cache):
354
+ if pid not in vivos:
355
+ # pop y no del: /api/state y /metrics pueden podar a la vez.
356
+ self._ps_cache.pop(pid, None)
357
+
358
+ stats: dict[str, dict[str, float]] = {}
359
+ for p in list(self.procs):
360
+ total_cpu = 0.0
361
+ total_rss = 0
362
+ for proc in arboles.get(p.service.name, ()):
363
+ try:
364
+ total_cpu += proc.cpu_percent(interval=None)
365
+ total_rss += proc.memory_info().rss
366
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
367
+ continue
368
+ stats[p.service.name] = {
369
+ "cpu_percent": round(total_cpu, 1),
370
+ "memory_mb": round(total_rss / (1024 * 1024), 1),
371
+ "pid": p.popen.pid,
372
+ }
373
+ return stats
374
+
375
+ def _pids_del_arbol(self, pid: int) -> list[int]:
376
+ """El pid del servicio y los de su descendencia.
377
+
378
+ Con `shell=True` el hijo directo es el shell y el servidor de verdad es
379
+ un nieto, asi que sumar solo el padre daria una memoria de juguete.
380
+ """
381
+ try:
382
+ parent = psutil.Process(pid)
383
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
384
+ return []
385
+ pids = [pid]
386
+ try:
387
+ pids.extend(hijo.pid for hijo in parent.children(recursive=True))
388
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
389
+ pass
390
+ return pids
391
+
392
+ def down(self) -> None:
393
+ """Apaga en orden inverso al de arranque. Correrlo dos veces no hace nada."""
394
+ with self._procs_lock:
395
+ if self._down:
396
+ return
397
+ self._down = True
398
+ # Copia bajo el lock: si un hilo del nivel appendea mientras iteramos,
399
+ # ese proc queda fuera de la lista y lo baja `_launch`, que ve el
400
+ # `_down` y no lo registra.
401
+ pendientes = list(reversed(self.procs))
402
+ # Antes que los servicios: un hook en curso puede estar levantando algo
403
+ # que el apagado ya paso a buscar.
404
+ self._matar_hooks()
405
+ for proc in pendientes:
406
+ self._stop_one(proc)
407
+ self._drain()
408
+
409
+ def _stop_one(self, proc: Proc) -> None:
410
+ # Un solo responsable por proceso. `restart` baja el viejo con el lock
411
+ # suelto, y en esa ventana `down` puede copiar la lista y encontrarlo
412
+ # todavia ahi: los dos corrian el `stop:` del servicio a la vez. La
413
+ # guarda va aca y no en `restart` porque el que llama son cuatro.
414
+ with self._procs_lock:
415
+ if proc.claimed:
416
+ return
417
+ proc.claimed = True
418
+ if proc.service.stop:
419
+ self._stop_command(proc)
420
+ if proc.popen.poll() is None:
421
+ self._say(proc, "apagando")
422
+ _terminate_tree(proc.popen)
423
+
424
+ def _stop_command(self, proc: Proc) -> None:
425
+ """Apagado propio del servicio.
426
+
427
+ Matar el arbol no alcanza cuando lo que quedo vivo no es hijo nuestro:
428
+ `docker compose up -d` termina enseguida y los contenedores siguen
429
+ corriendo. Sin este comando, "apagar" no apaga nada.
430
+ """
431
+ self._say(proc, f"$ {proc.service.stop}")
432
+ done = run_stop(proc.service)
433
+ if done is None:
434
+ self._say(proc, f"el apagado no termino en {STOP_TIMEOUT}s")
435
+ return
436
+ for line in (done.stdout or "").splitlines():
437
+ self._write(proc, line.rstrip())
438
+ if done.returncode != 0:
439
+ self._say(proc, f"el apagado fallo con codigo {done.returncode}")
440
+
441
+ # arranque -------------------------------------------------------------
442
+
443
+ def _spawn_proc(self, service: Service, color: str) -> Proc:
444
+ # Una sola muestra, antes de arrancar. Con `ready: port` el servicio se
445
+ # declara listo apenas alguien acepte en el puerto, y si ya habia alguien
446
+ # ahi el verde puede estar señalando a un proceso ajeno. Saber quien es
447
+ # el dueño costaria un recorrido de todos los procesos por sondeo, y en
448
+ # macOS la tabla de conexiones pide root; saber si el puerto ya estaba
449
+ # tomado cuesta un connect y contesta lo mismo para el que mira.
450
+ #
451
+ # No es un error: `docker compose up -d` sobre un contenedor que ya esta
452
+ # arriba cae aca y es el caso legitimo. Por eso avisa y no cancela.
453
+ if service.pre_start:
454
+ self._say_raw(service.name, color, f"$ pre_start: {service.pre_start}")
455
+ res = self._run_hook(service, service.pre_start, "pre_start")
456
+ for line in (res.stdout or "").splitlines():
457
+ self._write_raw(service.name, color, line)
458
+ if res.returncode != 0:
459
+ raise StartupError(f"{service.name} pre_start fallo con codigo {res.returncode}")
460
+
461
+ taken = service.ready == "port" and ports.accepts(service.port)
462
+ proc = Proc(service, self._spawn(service), color, port_taken=taken)
463
+ self._say(proc, f"$ {service.command}")
464
+ threading.Thread(target=self._pump, args=(proc,), daemon=True).start()
465
+ return proc
466
+
467
+ def _run_hook(self, service: Service, comando: str, etiqueta: str) -> subprocess.CompletedProcess:
468
+ """Corre un hook sincrono sin perderle el rastro.
469
+
470
+ Con `subprocess.run` no queda handle, asi que un apagado pedido mientras
471
+ el hook corria volvia en el acto y lo dejaba vivo hasta su presupuesto
472
+ de DETACHED_TIMEOUT: 900s de `npm run build` huerfano. Es el mismo
473
+ agujero que dejaba tuneles publicando el puerto, y por eso `CLAUDE.md`
474
+ pide que todo lo que se lance con `shell=True` se baje con
475
+ `_terminate_tree`.
476
+ """
477
+ proc = subprocess.Popen(
478
+ comando,
479
+ shell=True,
480
+ cwd=service.cwd,
481
+ env=build_env(service, extra_env=self.extra_env),
482
+ stdout=subprocess.PIPE,
483
+ stderr=subprocess.STDOUT,
484
+ text=True,
485
+ errors="replace",
486
+ )
487
+ with self._procs_lock:
488
+ tarde = self._down or self._cancel.is_set()
489
+ if not tarde:
490
+ self._hooks.add(proc)
491
+ if tarde:
492
+ # El apagado ya paso por la lista de hooks: a este lo baja quien lo
493
+ # arranco, igual que en `_launch` y en `restart`.
494
+ _terminate_tree(proc)
495
+ proc.communicate()
496
+ raise StartupError("apagado pedido durante el arranque")
497
+
498
+ try:
499
+ salida, _ = proc.communicate(timeout=DETACHED_TIMEOUT)
500
+ except subprocess.TimeoutExpired:
501
+ _terminate_tree(proc)
502
+ salida, _ = proc.communicate()
503
+ # Sin esto el timeout salia crudo. Un `npm run build` colgado rompia
504
+ # el arranque con un traceback en vez de decir que servicio y que
505
+ # hook se quedaron esperando, que es lo unico que hace falta para
506
+ # saber donde mirar.
507
+ raise StartupError(
508
+ f"{service.name} {etiqueta} no termino en {DETACHED_TIMEOUT:.0f}s"
509
+ ) from None
510
+ finally:
511
+ with self._procs_lock:
512
+ self._hooks.discard(proc)
513
+ return subprocess.CompletedProcess(comando, proc.returncode, salida, None)
514
+
515
+ def _matar_hooks(self) -> None:
516
+ with self._procs_lock:
517
+ pendientes = list(self._hooks)
518
+ self._hooks.clear()
519
+ for hook in pendientes:
520
+ _terminate_tree(hook)
521
+
522
+ def _spawn(self, service: Service) -> subprocess.Popen:
523
+ # shell=True es deliberado: `npm run dev` y `docker compose up -d` no son
524
+ # ejecutables, y stack.yaml ya es codigo ejecutable por diseño. El README
525
+ # documenta el modelo de confianza.
526
+ return subprocess.Popen(
527
+ service.command,
528
+ shell=True,
529
+ cwd=service.cwd,
530
+ env=build_env(service, extra_env=self.extra_env),
531
+ stdout=subprocess.PIPE,
532
+ stderr=subprocess.STDOUT,
533
+ text=True,
534
+ bufsize=1,
535
+ errors="replace",
536
+ )
537
+
538
+ def _pump(self, proc: Proc) -> None:
539
+ assert proc.popen.stdout is not None
540
+ for line in proc.popen.stdout:
541
+ self._logs.put((proc, line.rstrip()))
542
+ proc.popen.stdout.close()
543
+
544
+ # espera ---------------------------------------------------------------
545
+
546
+ def _wait_ready(self, proc: Proc) -> None:
547
+ service = proc.service
548
+
549
+ if service.detached:
550
+ self._await_exit(proc)
551
+
552
+ deadline = time.monotonic() + self.timeout
553
+ while True:
554
+ self._abort_if_cancelled()
555
+ self._drain()
556
+ if self._is_ready(proc):
557
+ proc.ready = True
558
+ port = proc.known_port
559
+ if port:
560
+ proc.http = speaks_http(port)
561
+ detail = f" ({port})" if port else ""
562
+ if proc.http:
563
+ detail += f" · http://localhost:{port}"
564
+ if proc.port_taken:
565
+ detail += " · el puerto ya estaba ocupado antes de arrancar"
566
+
567
+ if service.post_start:
568
+ self._say(proc, f"$ post_start: {service.post_start}")
569
+ res = self._run_hook(service, service.post_start, "post_start")
570
+ for line in (res.stdout or "").splitlines():
571
+ self._write(proc, line)
572
+ if res.returncode != 0:
573
+ raise StartupError(f"{service.name} post_start fallo con codigo {res.returncode}")
574
+
575
+ self._say(proc, "listo" + detail)
576
+ return
577
+ if not service.detached and proc.popen.poll() is not None:
578
+ raise StartupError(
579
+ f"{service.name} termino con codigo {proc.popen.returncode} "
580
+ f"antes de estar listo{_why(proc)}"
581
+ )
582
+ if time.monotonic() > deadline:
583
+ raise StartupError(
584
+ f"{service.name} no estuvo listo en {self.timeout:.0f}s "
585
+ f"(ready: {service.ready}){_port_hint(proc)}{_why(proc)}"
586
+ )
587
+ time.sleep(POLL)
588
+
589
+ def _await_exit(self, proc: Proc) -> None:
590
+ """Un servicio detached corre un comando que termina y deja algo vivo."""
591
+ budget = max(self.timeout, DETACHED_TIMEOUT)
592
+ try:
593
+ code = proc.popen.wait(budget)
594
+ except subprocess.TimeoutExpired:
595
+ raise StartupError(
596
+ f"{proc.service.name} es detached pero no termino en {budget:.0f}s"
597
+ ) from None
598
+ self._drain()
599
+ if code != 0:
600
+ raise StartupError(f"{proc.service.name} fallo con codigo {code}{_why(proc)}")
601
+
602
+ def _is_ready(self, proc: Proc) -> bool:
603
+ ready = proc.service.ready
604
+ if ready == "none":
605
+ return True
606
+ if ready == "port":
607
+ # `accepts` y no `not is_free`: is_free tambien cuenta como ocupado
608
+ # un socket bindeado que todavia no llamo a listen(), y ahi el
609
+ # servicio se declaraba listo antes de aceptar a nadie. El sondeo
610
+ # HTTP que sigue se comia un connection refused y el servicio
611
+ # perdia el boton Abrir. Ver ports.accepts.
612
+ return ports.accepts(proc.service.port)
613
+ if ready == "listen":
614
+ proc.port = ports.listening(proc.popen.pid)
615
+ return proc.port is not None
616
+ if ready.startswith("log:"):
617
+ return proc.matched_log
618
+ return _http_ok(ready)
619
+
620
+ # salida ---------------------------------------------------------------
621
+
622
+ def _drain(self) -> bool:
623
+ """Imprime lo que haya en la cola. Devuelve si imprimio algo."""
624
+ printed = False
625
+ while True:
626
+ try:
627
+ proc, line = self._logs.get_nowait()
628
+ except queue.Empty:
629
+ return printed
630
+ marker = proc.service.ready
631
+ if marker.startswith("log:") and marker[4:] in line:
632
+ proc.matched_log = True
633
+ proc.tail.append(line)
634
+ self._write(proc, line)
635
+ printed = True
636
+
637
+ def _say(self, proc: Proc, message: str) -> None:
638
+ self._say_raw(proc.service.name, proc.color, message)
639
+
640
+ def _write(self, proc: Proc, text: str) -> None:
641
+ self._write_raw(proc.service.name, proc.color, text)
642
+
643
+ def _say_raw(self, name: str, color: str, message: str) -> None:
644
+ self._write_raw(name, color, f"[dim]{message}[/]")
645
+
646
+ def _write_raw(self, name: str, color: str, text: str) -> None:
647
+ padded = name.ljust(self._width)
648
+ self.console.print(f"[{color}]{padded}[/] [dim]|[/] {text}", highlight=False)
649
+
650
+
651
+ def _levels(services: list[Service]) -> list[list[Service]]:
652
+ """Agrupa el orden de arranque en tandas que pueden arrancar juntas.
653
+
654
+ `services` ya viene en orden topologico, asi que cuando se mira un servicio
655
+ todas sus dependencias tienen nivel asignado y alcanza con una pasada.
656
+ """
657
+ nivel_de: dict[str, int] = {}
658
+ levels: list[list[Service]] = []
659
+ for service in services:
660
+ nivel = max((nivel_de[d] + 1 for d in service.needs if d in nivel_de), default=0)
661
+ nivel_de[service.name] = nivel
662
+ while len(levels) <= nivel:
663
+ levels.append([])
664
+ levels[nivel].append(service)
665
+ return levels
666
+
667
+
668
+ def dependency_graph(stack: Stack, profile: str | None = None) -> dict:
669
+ """Calcula nodos, aristas y niveles de arranque topológico del stack."""
670
+ services = stack.resolve(profile)
671
+ levels = _levels(services)
672
+ nivel_de = {s.name: lvl_idx for lvl_idx, lvl in enumerate(levels) for s in lvl}
673
+ return {
674
+ "levels": [[s.name for s in lvl] for lvl in levels],
675
+ "nodes": [
676
+ {
677
+ "name": s.name,
678
+ "level": nivel_de.get(s.name, 0),
679
+ "needs": list(s.needs),
680
+ "port": s.port,
681
+ }
682
+ for s in services
683
+ ],
684
+ "edges": [
685
+ {"from": dep, "to": s.name}
686
+ for s in services
687
+ for dep in s.needs
688
+ ],
689
+ }
690
+
691
+
692
+ def run_stop(service: Service, extra_env: dict[str, str] | None = None) -> subprocess.CompletedProcess | None:
693
+ """Corre el `stop:` de un servicio. None si no termino a tiempo.
694
+
695
+ Vive afuera del `Runner` porque `portmaster down` tiene que poder apagar un
696
+ stack que arranco otro proceso: los contenedores de un `docker compose up -d`
697
+ sobreviven a la terminal que los levanto, y ahi no hay ningun `Proc` vivo del
698
+ que colgarse.
699
+ """
700
+ assert service.stop
701
+ try:
702
+ return subprocess.run(
703
+ service.stop,
704
+ shell=True,
705
+ cwd=service.cwd,
706
+ env=build_env(service, extra_env=extra_env),
707
+ stdout=subprocess.PIPE,
708
+ stderr=subprocess.STDOUT,
709
+ text=True,
710
+ errors="replace",
711
+ timeout=STOP_TIMEOUT,
712
+ )
713
+ except subprocess.TimeoutExpired:
714
+ return None
715
+
716
+
717
+ WHY_MAX = 200
718
+
719
+
720
+ def clean_error_message(text: str) -> str:
721
+ """Simplifica mensajes de error extensos o tecnicos como fallas de Docker daemon."""
722
+ lower = text.lower()
723
+ if any(k in lower for k in ("docker_engine", "docker daemon", "cannot connect to the docker", "is the docker daemon running", "docker.sock")):
724
+ return "Docker no está en ejecución (abrí Docker Desktop)"
725
+ if "address already in use" in lower or "wsaeaddrinuse" in lower:
726
+ return "El puerto ya está ocupado por otro proceso"
727
+ if "permission denied" in lower or "access is denied" in lower:
728
+ return "Permiso denegado al ejecutar el comando"
729
+ return text
730
+
731
+
732
+ def _port_hint(proc: Proc) -> str:
733
+ """Que paso con el puerto declarado, cuando el healthcheck ya se agoto.
734
+
735
+ Un dev server que encuentra su puerto ocupado se corre al siguiente sin
736
+ fallar: vite salta de 5177 a 5178 y sigue como si nada. El arranque queda
737
+ esperando en el puerto viejo hasta el timeout y el error no dice por que.
738
+ Solo corre en el camino del fallo, asi que los escaneos no los paga nadie
739
+ que este arrancando bien.
740
+ """
741
+ declarado = proc.service.port
742
+ if not declarado:
743
+ return ""
744
+
745
+ abiertos = ports.opened_by(proc.popen.pid)
746
+ otros = [p for p in abiertos if p != declarado]
747
+ if otros:
748
+ return (
749
+ f"; declaraste el puerto {declarado} pero abrio {', '.join(map(str, otros))}"
750
+ f" (arrancalo con el puerto fijo, o cambia el port: del stack.yaml)"
751
+ )
752
+
753
+ dueno = ports.scan(declarado)
754
+ if not dueno.free and dueno.pid is not None:
755
+ quien = dueno.name or f"pid {dueno.pid}"
756
+ return (
757
+ f"; el puerto {declarado} ya lo tenia {quien} (pid {dueno.pid}):"
758
+ f" liberalo con `portmaster free {declarado}` y reintenta"
759
+ )
760
+ return ""
761
+
762
+
763
+ def _why(proc: Proc) -> str:
764
+ """Ultima linea con contenido de la salida del servicio.
765
+
766
+ Es la diferencia entre "postgres fallo con codigo 1" y saber que el daemon de
767
+ Docker no esta corriendo. Los avisos de compose sobre variables sin definir se
768
+ saltean: son ruido y tapan la linea que importa.
769
+ """
770
+ for line in reversed(proc.tail):
771
+ text = line.strip()
772
+ if text and "level=warning" not in text:
773
+ cleaned = clean_error_message(text)
774
+ if len(cleaned) > WHY_MAX:
775
+ cleaned = cleaned[: WHY_MAX - 1].rstrip() + "…"
776
+ return f": {cleaned}"
777
+ return ""
778
+
779
+
780
+ # Solo se agota cuando alguien acepta la conexion y no contesta: un puerto
781
+ # cerrado corta en el acto y no cuesta nada. Ese caso es justo el de un servidor
782
+ # que acaba de bindear y todavia no entro a su bucle de atencion, y con 1s
783
+ # bastaba una maquina cargada para darlo por mudo.
784
+ HTTP_PROBE_TIMEOUT = 3.0
785
+ HTTP_PROBE_FIRST_TIMEOUT = 1.0
786
+ HTTP_PROBE_RETRY_DELAY = 0.1
787
+
788
+
789
+ def speaks_http(port: int) -> bool:
790
+ """Si el puerto contesta HTTP, y por lo tanto se puede abrir en el navegador.
791
+
792
+ Un postgres listo no es algo que abrir, y saber cual servicio es el frontend
793
+ por el nombre es adivinar. Se le pregunta al puerto, igual que el puerto se le
794
+ pregunta al proceso.
795
+
796
+ Un 404 cuenta: la mayoria de las APIs no sirven nada en la raiz y siguen
797
+ siendo HTTP. Lo que descarta al servicio es que no conteste.
798
+
799
+ Dos intentos cortos, no uno solo ni muchos: reintentar sin limite costaria
800
+ el timeout entero por cada puerto que no habla HTTP, y lo pagaria el
801
+ arranque. Un puerto cerrado corta con ECONNREFUSED en el acto, asi que los
802
+ dos intentos ahi no cuestan mas que el sleep del medio.
803
+
804
+ El segundo intento es cinturon y tirantes desde que `ready: port` pregunta
805
+ por `ports.accepts`: la carrera que cubria, sondear un socket que bindeo y
806
+ todavia no llamo a listen(), ya no llega hasta aca.
807
+
808
+ Al servidor que contesta tarde porque compila en la primera peticion, como
809
+ el modo dev de Next, lo recupera `Session._probe_late_http` desde su propio
810
+ hilo.
811
+ """
812
+ timeouts = (HTTP_PROBE_FIRST_TIMEOUT, HTTP_PROBE_TIMEOUT - HTTP_PROBE_FIRST_TIMEOUT)
813
+ for i, timeout in enumerate(timeouts):
814
+ try:
815
+ # `localhost` y no 127.0.0.1: un dev server de Node escucha solo en
816
+ # ::1, y preguntando por IPv4 se lo daba por mudo y se quedaba sin
817
+ # boton Abrir. Por nombre, urllib prueba las dos, igual que hace el
818
+ # navegador con el enlace que vamos a ofrecer. Ver ports.LOOPBACK.
819
+ with urllib.request.urlopen(f"http://localhost:{port}", timeout=timeout):
820
+ return True
821
+ except urllib.error.HTTPError:
822
+ return True
823
+ except (urllib.error.URLError, OSError, ValueError):
824
+ if i == len(timeouts) - 1:
825
+ return False
826
+ time.sleep(HTTP_PROBE_RETRY_DELAY)
827
+ return False
828
+
829
+
830
+ def _http_ok(url: str) -> bool:
831
+ try:
832
+ with urllib.request.urlopen(url, timeout=2) as response:
833
+ return response.status < 400
834
+ except (urllib.error.URLError, OSError, ValueError):
835
+ return False
836
+
837
+
838
+ def _terminate_tree(popen: subprocess.Popen, timeout: float = SHUTDOWN_TIMEOUT) -> None:
839
+ """Cierra el proceso y sus descendientes.
840
+
841
+ Con shell=True el hijo directo es el shell, y matarlo solo a el deja
842
+ huerfano al servidor de verdad. Por eso se apaga el arbol entero.
843
+
844
+ Recibe el `Popen` y no el pid, y esa es la guarda. `psutil` verifica el
845
+ reciclado de PID en cada llamada, pero contra la identidad que capturo al
846
+ construir el `Process`: si el pid ya se habia reciclado *antes* de esa
847
+ linea, psutil adopta la identidad del intruso y lo termina sin quejarse.
848
+ Recibiendo un entero suelto no habia forma de saberlo. Con el `Popen` si:
849
+ mientras `poll()` da None el hijo esta vivo y sin cosechar, o sea que el
850
+ sistema todavia no puede darle ese pid a nadie mas. Los hooks eran el
851
+ agujero concreto, que llamaban con un pid pelado y sin ningun poll previo.
852
+
853
+ ponytail: queda la ventana entre el `poll()` y el `psutil.Process`, de
854
+ microsegundos. Cerrarla del todo es guardar el `psutil.Process` en el
855
+ momento del spawn y arrastrarlo por `Proc` y por `_hooks`; se hace si
856
+ alguna vez aparece un sintoma, no antes.
857
+ """
858
+ if popen.poll() is not None:
859
+ return # ya murio: su pid puede ser de otro, y ese otro no es nuestro
860
+ try:
861
+ parent = psutil.Process(popen.pid)
862
+ victims = parent.children(recursive=True) + [parent]
863
+ except psutil.NoSuchProcess:
864
+ return
865
+ for victim in victims:
866
+ try:
867
+ victim.terminate()
868
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
869
+ pass
870
+
871
+ _, alive = psutil.wait_procs(victims, timeout=timeout)
872
+ for victim in alive:
873
+ try:
874
+ victim.kill()
875
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
876
+ pass
877
+ psutil.wait_procs(alive, timeout=min(2.0, timeout))