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/__init__.py +2 -0
- stackhelx/__main__.py +4 -0
- stackhelx/browse.py +160 -0
- stackhelx/cli.py +1165 -0
- stackhelx/config.py +448 -0
- stackhelx/detect.py +1053 -0
- stackhelx/docker.py +202 -0
- stackhelx/doctor.py +356 -0
- stackhelx/guardrails.py +42 -0
- stackhelx/history.py +86 -0
- stackhelx/mcp.py +467 -0
- stackhelx/ports.py +392 -0
- stackhelx/registry.py +356 -0
- stackhelx/runner.py +877 -0
- stackhelx/scripts.py +123 -0
- stackhelx/server.py +1786 -0
- stackhelx/tunnel.py +207 -0
- stackhelx/web/app.css +1729 -0
- stackhelx/web/app.js +2425 -0
- stackhelx/web/index.html +378 -0
- stackhelx/web/tokens.css +104 -0
- stackhelx-1.0.0.dist-info/METADATA +323 -0
- stackhelx-1.0.0.dist-info/RECORD +26 -0
- stackhelx-1.0.0.dist-info/WHEEL +4 -0
- stackhelx-1.0.0.dist-info/entry_points.txt +3 -0
- stackhelx-1.0.0.dist-info/licenses/LICENSE +21 -0
stackhelx/server.py
ADDED
|
@@ -0,0 +1,1786 @@
|
|
|
1
|
+
"""API local que consume la interfaz web.
|
|
2
|
+
|
|
3
|
+
Este servidor ejecuta comandos arbitrarios definidos en stack.yaml. Se trata
|
|
4
|
+
como superficie sensible aunque solo escuche en loopback:
|
|
5
|
+
|
|
6
|
+
- bind exclusivo a 127.0.0.1
|
|
7
|
+
- token obligatorio en Authorization: Bearer, comparado en tiempo constante
|
|
8
|
+
- validacion del header Host contra rebinding de DNS
|
|
9
|
+
- rate limit en todas las rutas
|
|
10
|
+
- headers de seguridad y CSP estricta
|
|
11
|
+
- logging de cierres de procesos, rechazos de auth y excesos de rate limit
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import contextlib
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import queue
|
|
22
|
+
import secrets
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from collections import defaultdict, deque
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Literal
|
|
32
|
+
|
|
33
|
+
import psutil
|
|
34
|
+
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
|
35
|
+
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
|
36
|
+
from fastapi.staticfiles import StaticFiles
|
|
37
|
+
from pydantic import BaseModel, Field, field_validator
|
|
38
|
+
from rich.console import Console
|
|
39
|
+
|
|
40
|
+
from . import (
|
|
41
|
+
__version__,
|
|
42
|
+
config,
|
|
43
|
+
detect,
|
|
44
|
+
docker,
|
|
45
|
+
doctor,
|
|
46
|
+
history,
|
|
47
|
+
mcp,
|
|
48
|
+
ports,
|
|
49
|
+
registry,
|
|
50
|
+
runner,
|
|
51
|
+
tunnel,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# `browse_module` porque el endpoint de /api/browse ya se llama browse.
|
|
55
|
+
from . import browse as browse_module
|
|
56
|
+
|
|
57
|
+
log = logging.getLogger("portmaster.server")
|
|
58
|
+
|
|
59
|
+
WEB = Path(__file__).parent / "web"
|
|
60
|
+
LOG_LINES = 500
|
|
61
|
+
PAGE_SIZE = 4
|
|
62
|
+
# Esperas entre re-sondeos HTTP, en segundos. Cuatro intentos y se abandona: un
|
|
63
|
+
# servicio que no habla HTTP no va a empezar a hacerlo, y lo que se busca cabe
|
|
64
|
+
# en el primer medio minuto de vida del servicio.
|
|
65
|
+
HTTP_RETRIES = (2, 5, 10, 20)
|
|
66
|
+
|
|
67
|
+
# Cuotas por ventana de 15 minutos. La interfaz sondea el estado cada 2s, que da
|
|
68
|
+
# unas 450 peticiones por ventana: un limite global de 100 la romperia en el uso
|
|
69
|
+
# normal. Las rutas que ejecutan o matan procesos si van cortas.
|
|
70
|
+
WINDOW = 900
|
|
71
|
+
QUOTA_READ = 1800
|
|
72
|
+
QUOTA_WRITE = 60
|
|
73
|
+
QUOTA_KILL = 30
|
|
74
|
+
|
|
75
|
+
# Techo de puertos por cierre en lote. La lista real la limita la cantidad de
|
|
76
|
+
# servicios registrados; esto solo evita que un body enorme llegue a recorrerse.
|
|
77
|
+
MAX_TARGETS = 200
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RateLimit:
|
|
81
|
+
"""Ventana deslizante en memoria.
|
|
82
|
+
|
|
83
|
+
ponytail: un proceso, un usuario, sin persistencia. Si alguna vez hay mas de
|
|
84
|
+
un worker, esto se cambia por un limiter con almacen compartido.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
|
89
|
+
self._lock = threading.Lock()
|
|
90
|
+
|
|
91
|
+
def allow(self, key: str, quota: int, window: int = WINDOW) -> bool:
|
|
92
|
+
now = time.monotonic()
|
|
93
|
+
with self._lock:
|
|
94
|
+
hits = self._hits[key]
|
|
95
|
+
while hits and hits[0] <= now - window:
|
|
96
|
+
hits.popleft()
|
|
97
|
+
if len(hits) >= quota:
|
|
98
|
+
return False
|
|
99
|
+
hits.append(now)
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class _Sink:
|
|
104
|
+
"""Recibe lo que escribe Rich y lo parte en lineas numeradas."""
|
|
105
|
+
|
|
106
|
+
def __init__(self) -> None:
|
|
107
|
+
self.lines: deque[tuple[int, str]] = deque(maxlen=LOG_LINES)
|
|
108
|
+
self.seq = 0
|
|
109
|
+
self._partial = ""
|
|
110
|
+
self._subscribers: set[queue.Queue[tuple[int, str]]] = set()
|
|
111
|
+
self._lock = threading.Lock()
|
|
112
|
+
|
|
113
|
+
def subscribe(self) -> queue.Queue[tuple[int, str]]:
|
|
114
|
+
q: queue.Queue[tuple[int, str]] = queue.Queue(maxsize=1000)
|
|
115
|
+
with self._lock:
|
|
116
|
+
self._subscribers.add(q)
|
|
117
|
+
return q
|
|
118
|
+
|
|
119
|
+
def unsubscribe(self, q: queue.Queue[tuple[int, str]]) -> None:
|
|
120
|
+
with self._lock:
|
|
121
|
+
self._subscribers.discard(q)
|
|
122
|
+
|
|
123
|
+
def write(self, text: str) -> int:
|
|
124
|
+
items = []
|
|
125
|
+
with self._lock:
|
|
126
|
+
self._partial += text
|
|
127
|
+
*complete, self._partial = self._partial.split("\n")
|
|
128
|
+
for line in complete:
|
|
129
|
+
self.seq += 1
|
|
130
|
+
item = (self.seq, line.rstrip())
|
|
131
|
+
self.lines.append(item)
|
|
132
|
+
items.append(item)
|
|
133
|
+
for item in items:
|
|
134
|
+
self._dispatch(item)
|
|
135
|
+
return len(text)
|
|
136
|
+
|
|
137
|
+
def flush(self) -> None:
|
|
138
|
+
item = None
|
|
139
|
+
with self._lock:
|
|
140
|
+
if self._partial:
|
|
141
|
+
self.seq += 1
|
|
142
|
+
item = (self.seq, self._partial.rstrip())
|
|
143
|
+
self.lines.append(item)
|
|
144
|
+
self._partial = ""
|
|
145
|
+
if item is not None:
|
|
146
|
+
self._dispatch(item)
|
|
147
|
+
|
|
148
|
+
def _dispatch(self, item: tuple[int, str]) -> None:
|
|
149
|
+
with self._lock:
|
|
150
|
+
subs = list(self._subscribers)
|
|
151
|
+
for q in subs:
|
|
152
|
+
try:
|
|
153
|
+
q.put_nowait(item)
|
|
154
|
+
except queue.Full:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class Session:
|
|
160
|
+
"""Un stack corriendo, arrancado desde la interfaz."""
|
|
161
|
+
|
|
162
|
+
stack: config.Stack
|
|
163
|
+
profile: str | None
|
|
164
|
+
sink: _Sink = field(default_factory=_Sink)
|
|
165
|
+
state: str = "starting"
|
|
166
|
+
error: str | None = None
|
|
167
|
+
engine: runner.Runner | None = None
|
|
168
|
+
thread: threading.Thread | None = None
|
|
169
|
+
# Servicios que no contestaron HTTP al quedar listos y si al re-sondearlos.
|
|
170
|
+
# Vive aca y no en `Proc.http` para que el hilo del sondeo no le escriba
|
|
171
|
+
# estado al runner por atras.
|
|
172
|
+
late_http: set[str] = field(default_factory=set)
|
|
173
|
+
# Un stack detenido porque el usuario apreto Apagar y uno que se murio solo
|
|
174
|
+
# llegan al mismo estado por caminos distintos. Solo el segundo es noticia.
|
|
175
|
+
stopped_by_user: bool = False
|
|
176
|
+
|
|
177
|
+
def start(self) -> None:
|
|
178
|
+
# force_terminal=False no es redundante con no_color: Rich mira FORCE_COLOR
|
|
179
|
+
# del entorno y decide que el sink es una terminal, y no_color saca los
|
|
180
|
+
# colores pero deja el dim, que llega al navegador como "[2m|[0m".
|
|
181
|
+
console = Console(
|
|
182
|
+
file=self.sink, force_terminal=False, no_color=True, width=160, soft_wrap=True
|
|
183
|
+
)
|
|
184
|
+
self.engine = runner.Runner(self.stack, console=console)
|
|
185
|
+
self.thread = threading.Thread(target=self._run, daemon=True)
|
|
186
|
+
self.thread.start()
|
|
187
|
+
|
|
188
|
+
def _run(self) -> None:
|
|
189
|
+
assert self.engine is not None
|
|
190
|
+
pid = registry.project_id(self.stack.root)
|
|
191
|
+
t0 = time.monotonic()
|
|
192
|
+
try:
|
|
193
|
+
self.engine.up(self.profile)
|
|
194
|
+
dur_total = time.monotonic() - t0
|
|
195
|
+
|
|
196
|
+
# Registrar el éxito del arranque
|
|
197
|
+
history.append(pid, {
|
|
198
|
+
"project": self.stack.name,
|
|
199
|
+
"profile": self.profile,
|
|
200
|
+
"duration_s": round(dur_total, 2),
|
|
201
|
+
"result": "running",
|
|
202
|
+
# Duraciones por servicio se obtendrian si runner guardara .start_time.
|
|
203
|
+
# Como es complejo refactorizar runner, guardamos las cuotas simples.
|
|
204
|
+
"services": [p.service.name for p in self.engine.procs if p.ready],
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
self.state = "running"
|
|
208
|
+
threading.Thread(target=self._probe_late_http, daemon=True).start()
|
|
209
|
+
if all(proc.service.detached for proc in self.engine.procs):
|
|
210
|
+
return
|
|
211
|
+
self.engine.follow()
|
|
212
|
+
if self.state != "stopping":
|
|
213
|
+
self.state = "stopped"
|
|
214
|
+
_save_sessions_state()
|
|
215
|
+
except Exception as exc: # el hilo no debe morir en silencio
|
|
216
|
+
dur_total = time.monotonic() - t0
|
|
217
|
+
self.error = runner.clean_error_message(str(exc))
|
|
218
|
+
self.state = "error"
|
|
219
|
+
history.append(pid, {
|
|
220
|
+
"project": self.stack.name,
|
|
221
|
+
"profile": self.profile,
|
|
222
|
+
"duration_s": round(dur_total, 2),
|
|
223
|
+
"result": "error",
|
|
224
|
+
"error": self.error,
|
|
225
|
+
})
|
|
226
|
+
_save_sessions_state()
|
|
227
|
+
log.warning("stack %s fallo: %s", self.stack.name, exc)
|
|
228
|
+
|
|
229
|
+
def stop_async(self) -> None:
|
|
230
|
+
"""Apaga sin hacer esperar al request.
|
|
231
|
+
|
|
232
|
+
`docker compose stop` tarda decenas de segundos con varios contenedores.
|
|
233
|
+
La interfaz sondea el estado igual que en el arranque, asi que lo unico
|
|
234
|
+
que hace falta es un estado que sepa mostrar mientras tanto.
|
|
235
|
+
"""
|
|
236
|
+
self.state = "stopping"
|
|
237
|
+
threading.Thread(target=self.stop, daemon=True).start()
|
|
238
|
+
|
|
239
|
+
def restart_async(self, name: str) -> None:
|
|
240
|
+
"""Reinicia un servicio. Como el apagado, no hace esperar al request."""
|
|
241
|
+
threading.Thread(target=self._restart, args=(name,), daemon=True).start()
|
|
242
|
+
|
|
243
|
+
def _restart(self, name: str) -> None:
|
|
244
|
+
assert self.engine is not None
|
|
245
|
+
# El servicio vuelve con un `Proc` nuevo y su propio sondeo: lo que
|
|
246
|
+
# sabiamos de la vida anterior deja de valer.
|
|
247
|
+
self.late_http.discard(name)
|
|
248
|
+
try:
|
|
249
|
+
self.engine.restart(name)
|
|
250
|
+
except Exception as exc: # el hilo no debe morir en silencio
|
|
251
|
+
self.error = runner.clean_error_message(str(exc))
|
|
252
|
+
self.state = "error"
|
|
253
|
+
log.warning("reinicio de %s fallo: %s", name, exc)
|
|
254
|
+
|
|
255
|
+
def switch_profile_async(self, new_profile: str | None) -> None:
|
|
256
|
+
"""Conmuta el perfil en caliente apagando servicios y arrancando el nuevo perfil."""
|
|
257
|
+
self.state = "stopping"
|
|
258
|
+
threading.Thread(target=self._switch_profile, args=(new_profile,), daemon=True).start()
|
|
259
|
+
|
|
260
|
+
def _switch_profile(self, new_profile: str | None) -> None:
|
|
261
|
+
self.stop()
|
|
262
|
+
self.profile = new_profile
|
|
263
|
+
self.error = None
|
|
264
|
+
self.late_http.clear()
|
|
265
|
+
self.stopped_by_user = False
|
|
266
|
+
self.state = "starting"
|
|
267
|
+
self.sink.write(
|
|
268
|
+
f"\n[portmaster] Conmutando al perfil '{new_profile or 'default'}'...\n"
|
|
269
|
+
)
|
|
270
|
+
console = Console(
|
|
271
|
+
file=self.sink, force_terminal=False, no_color=True, width=160, soft_wrap=True
|
|
272
|
+
)
|
|
273
|
+
self.engine = runner.Runner(self.stack, console=console)
|
|
274
|
+
self.thread = threading.current_thread()
|
|
275
|
+
self._run()
|
|
276
|
+
|
|
277
|
+
def stop(self) -> None:
|
|
278
|
+
self.stopped_by_user = True
|
|
279
|
+
if self.engine is not None:
|
|
280
|
+
# Apagar mientras arranca: el hilo de arranque es el que sabe que
|
|
281
|
+
# levanto, asi que se le pide que corte y se lo espera. Despues su
|
|
282
|
+
# `down` ya corrio y este no hace nada.
|
|
283
|
+
self.engine.cancel()
|
|
284
|
+
if (
|
|
285
|
+
self.thread is not None
|
|
286
|
+
and self.thread.is_alive()
|
|
287
|
+
and self.thread != threading.current_thread()
|
|
288
|
+
):
|
|
289
|
+
# ponytail: un detached en curso (`docker compose up -d`
|
|
290
|
+
# construyendo) no se puede cortar, se espera a que termine. Si
|
|
291
|
+
# alguna vez molesta, matar el arbol del comando detached.
|
|
292
|
+
self.thread.join(runner.STOP_TIMEOUT)
|
|
293
|
+
self.engine.down()
|
|
294
|
+
else:
|
|
295
|
+
# Sin engine: la sesion sobrevivio a un reinicio del servidor, o
|
|
296
|
+
# nadie arranco esto desde aca y solo hay que bajar lo que este vivo.
|
|
297
|
+
# Un contenedor no es hijo nuestro y no se apaga matando el pid del
|
|
298
|
+
# puerto: ese pid es el proxy de Docker, y matarlo deja el
|
|
299
|
+
# contenedor corriendo sin publicar. El `stop:` del servicio es lo
|
|
300
|
+
# unico que lo baja de verdad.
|
|
301
|
+
sueltos = []
|
|
302
|
+
for service in reversed(list(self.stack.services.values())):
|
|
303
|
+
if service.stop:
|
|
304
|
+
runner.run_stop(service)
|
|
305
|
+
elif service.port:
|
|
306
|
+
sueltos.append(service.port)
|
|
307
|
+
scanned = ports.scan_many(sueltos)
|
|
308
|
+
for status in scanned.values():
|
|
309
|
+
if not status.free and status.pid is not None:
|
|
310
|
+
if ports.proxy_owner(status) is not None:
|
|
311
|
+
# El pid es el proxy de Docker: matarlo deja el
|
|
312
|
+
# contenedor corriendo sin publicar.
|
|
313
|
+
continue
|
|
314
|
+
try:
|
|
315
|
+
ports.kill(status.pid, status.create_time, port=status.port)
|
|
316
|
+
except Exception:
|
|
317
|
+
pass
|
|
318
|
+
self.state = "stopped"
|
|
319
|
+
_save_sessions_state()
|
|
320
|
+
|
|
321
|
+
def _probe_late_http(self) -> None:
|
|
322
|
+
"""Vuelve a preguntarle a los puertos que no contestaron al arrancar.
|
|
323
|
+
|
|
324
|
+
Un dev server de Next compila recien en la primera peticion: el sondeo
|
|
325
|
+
unico de `runner.speaks_http` da falso negativo y el proyecto se queda
|
|
326
|
+
sin boton Abrir hasta el proximo arranque.
|
|
327
|
+
|
|
328
|
+
Corre en su propio hilo y no en `_project_view` a proposito. La vista
|
|
329
|
+
de estado corre una vez por proyecto y por request, cada 2.5s y por
|
|
330
|
+
pestana abierta, en el threadpool que FastAPI comparte con apagar y con
|
|
331
|
+
matar procesos: un puerto que acepta y no contesta lo bloquea el timeout
|
|
332
|
+
entero, y saturarlo dejaria a la herramienta sin poder apagar nada justo
|
|
333
|
+
cuando algo anda mal.
|
|
334
|
+
|
|
335
|
+
Los intentos son finitos por la misma razon. Un servicio que no habla
|
|
336
|
+
HTTP no va a empezar a hacerlo, y sondearlo para siempre le manda un GET
|
|
337
|
+
a un dev server cada tantos segundos, que en Next dispara una
|
|
338
|
+
compilacion.
|
|
339
|
+
"""
|
|
340
|
+
for espera in HTTP_RETRIES:
|
|
341
|
+
time.sleep(espera)
|
|
342
|
+
if self.state != "running" or self.engine is None:
|
|
343
|
+
return
|
|
344
|
+
pendientes = [
|
|
345
|
+
(p.service.name, p.known_port)
|
|
346
|
+
for p in self.engine.procs
|
|
347
|
+
if p.ready and p.known_port and not p.http and p.service.name not in self.late_http
|
|
348
|
+
]
|
|
349
|
+
if not pendientes:
|
|
350
|
+
return
|
|
351
|
+
for name, port in pendientes:
|
|
352
|
+
if runner.speaks_http(port):
|
|
353
|
+
self.late_http.add(name)
|
|
354
|
+
log.info("%s contesto HTTP al re-sondear el puerto %d", name, port)
|
|
355
|
+
|
|
356
|
+
def service_ports(self) -> dict[str, int]:
|
|
357
|
+
"""Puertos descubiertos al arrancar, para los servicios que no los declaran."""
|
|
358
|
+
if self.engine is None:
|
|
359
|
+
return {}
|
|
360
|
+
return {p.service.name: p.port for p in self.engine.procs if p.port}
|
|
361
|
+
|
|
362
|
+
def service_http(self) -> set[str]:
|
|
363
|
+
"""Servicios cuyo puerto contesta HTTP: los unicos que tiene sentido abrir."""
|
|
364
|
+
if self.engine is None:
|
|
365
|
+
return set()
|
|
366
|
+
return {p.service.name for p in self.engine.procs if p.http} | self.late_http
|
|
367
|
+
|
|
368
|
+
def service_taken(self) -> set[str]:
|
|
369
|
+
"""Servicios cuyo puerto ya estaba ocupado antes de arrancar.
|
|
370
|
+
|
|
371
|
+
El `listo` de esos puede ser de un proceso ajeno. Ver runner._spawn_proc.
|
|
372
|
+
"""
|
|
373
|
+
if self.engine is None:
|
|
374
|
+
return set()
|
|
375
|
+
return {p.service.name for p in self.engine.procs if p.port_taken}
|
|
376
|
+
|
|
377
|
+
def service_states(self) -> dict[str, str]:
|
|
378
|
+
if self.state in ("stopped", "error"):
|
|
379
|
+
return {}
|
|
380
|
+
if self.engine is None:
|
|
381
|
+
# Sesion recuperada tras reiniciar el servidor: no tenemos los
|
|
382
|
+
# procesos, tenemos los puertos. Devolver {} dejaba la tarjeta
|
|
383
|
+
# diciendo "corriendo" con la lista de servicios vacia.
|
|
384
|
+
declarados = {n: s.port for n, s in self.stack.services.items() if s.port}
|
|
385
|
+
scanned = ports.scan_many(list(declarados.values()))
|
|
386
|
+
return {
|
|
387
|
+
name: "ready" if not scanned[port].free else "stopped"
|
|
388
|
+
for name, port in declarados.items()
|
|
389
|
+
}
|
|
390
|
+
states = {}
|
|
391
|
+
for proc in self.engine.procs:
|
|
392
|
+
# Durante un reinicio el proceso viejo ya murio y el nuevo todavia no
|
|
393
|
+
# existe. Sin esto, "se cayo solo" se dispararia con cada Reiniciar.
|
|
394
|
+
if self.engine.restarting:
|
|
395
|
+
states[proc.service.name] = "starting"
|
|
396
|
+
elif proc.popen.poll() is not None and not proc.service.detached:
|
|
397
|
+
states[proc.service.name] = "stopped"
|
|
398
|
+
else:
|
|
399
|
+
states[proc.service.name] = "ready" if proc.ready else "starting"
|
|
400
|
+
return states
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
sessions: dict[str, Session] = {}
|
|
404
|
+
selected_profiles: dict[str, str | None] = {}
|
|
405
|
+
sessions_lock = threading.Lock()
|
|
406
|
+
limiter = RateLimit()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _current_profile(pid: str, session: Session | None) -> str | None:
|
|
410
|
+
if pid in selected_profiles:
|
|
411
|
+
return selected_profiles[pid]
|
|
412
|
+
if session is not None:
|
|
413
|
+
return session.profile
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
# Cuanto vale un chequeo del daemon de docker antes de repetirlo.
|
|
417
|
+
DOCKER_TTL = 10.0
|
|
418
|
+
_docker_seen: tuple[float, bool] = (0.0, False)
|
|
419
|
+
_docker_lock = threading.Lock()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _docker_is_down() -> bool:
|
|
423
|
+
"""Si el daemon no contesta, con cache.
|
|
424
|
+
|
|
425
|
+
`doctor._docker` lanza un subproceso y tarda cientos de milisegundos.
|
|
426
|
+
`_project_view` corre una vez por proyecto y por request, y la interfaz
|
|
427
|
+
sondea cada 2.5s por pestana abierta: sin cache, tres proyectos con
|
|
428
|
+
contenedores se llevaban un segundo entero de cada `/api/state` y saturaban
|
|
429
|
+
el threadpool que FastAPI comparte con apagar y con matar procesos. Medido:
|
|
430
|
+
`/api/health` pasaba de 2ms a 9s con la vista de estado bajo carga.
|
|
431
|
+
|
|
432
|
+
El chequeo corre adentro del lock a proposito. Afuera, una tanda de
|
|
433
|
+
requests concurrentes lanza un subproceso cada uno; adentro, el primero lo
|
|
434
|
+
paga y los demas esperan ese mismo resultado.
|
|
435
|
+
"""
|
|
436
|
+
global _docker_seen
|
|
437
|
+
with _docker_lock:
|
|
438
|
+
cuando, valor = _docker_seen
|
|
439
|
+
if time.monotonic() - cuando < DOCKER_TTL:
|
|
440
|
+
return valor
|
|
441
|
+
caido = doctor._docker().level == "fail"
|
|
442
|
+
_docker_seen = (time.monotonic(), caido)
|
|
443
|
+
return caido
|
|
444
|
+
# Cuanto vale una deteccion antes de repetirla. Entre los dos TTL que ya hay, y
|
|
445
|
+
# a proposito: `registry.PORTS_TTL` son 30s porque alimenta un aviso de puertos
|
|
446
|
+
# compartidos, donde llegar tarde no molesta. Esto alimenta la fila principal
|
|
447
|
+
# (el estado y la lista de servicios), asi que se parece mas al `DOCKER_TTL`.
|
|
448
|
+
STACK_TTL = 10.0
|
|
449
|
+
_stack_seen: dict[str, tuple[float, config.Stack]] = {}
|
|
450
|
+
_stack_invalidations: dict[str, float] = {}
|
|
451
|
+
_stack_lock = threading.Lock()
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _stack_para_la_vista(path: Path) -> config.Stack:
|
|
455
|
+
"""El stack del proyecto para la vista de estado, con cache.
|
|
456
|
+
|
|
457
|
+
`detect.stack_for` relee y reparsea `pom.xml`, `package.json`,
|
|
458
|
+
`compose.yaml` y todo lo demas en cada llamada, y `_project_view` corre una
|
|
459
|
+
vez por proyecto y por request con la interfaz sondeando cada 2.5s por
|
|
460
|
+
pestana. Medido sobre un proyecto poliglota: 6.7ms por llamada, o sea 242ms
|
|
461
|
+
de lectura de disco en cada `/api/state` con doce proyectos y tres
|
|
462
|
+
pestanas, releyendo archivos que casi nunca cambian.
|
|
463
|
+
|
|
464
|
+
Cierra un hueco en vez de inventar un patron: los otros dos caminos que
|
|
465
|
+
resuelven stacks en el mismo request ya estaban cacheados hace rato
|
|
466
|
+
(`registry.declared_ports` y `registry.any_uses_docker`, las dos con
|
|
467
|
+
`max_age=PORTS_TTL`). El de `_project_view` era el unico que quedaba
|
|
468
|
+
releyendo en cada sondeo.
|
|
469
|
+
|
|
470
|
+
**Solo para la vista.** `up`, `switch_profile` y `down` siguen llamando a
|
|
471
|
+
`detect.stack_for` directo, y eso no es una omision: arrancar un stack con
|
|
472
|
+
una version cacheada correria los comandos viejos despues de que el usuario
|
|
473
|
+
edito su `stack.yaml`, que es exactamente lo que nadie espera. La vista
|
|
474
|
+
puede estar diez segundos vieja; el arranque no puede estarlo nunca.
|
|
475
|
+
|
|
476
|
+
`Stack` y `Service` son `frozen=True`, asi que compartir la instancia entre
|
|
477
|
+
requests es seguro.
|
|
478
|
+
"""
|
|
479
|
+
ahora = time.monotonic()
|
|
480
|
+
clave = str(path)
|
|
481
|
+
with _stack_lock:
|
|
482
|
+
visto = _stack_seen.get(clave)
|
|
483
|
+
if visto is not None and ahora - visto[0] < STACK_TTL:
|
|
484
|
+
return visto[1]
|
|
485
|
+
# Afuera del lock: la deteccion toca disco, y con doce proyectos adentro
|
|
486
|
+
# del lock la vista se serializa entera contra el proyecto mas lento.
|
|
487
|
+
inicio = time.monotonic()
|
|
488
|
+
stack = detect.stack_for(path)
|
|
489
|
+
with _stack_lock:
|
|
490
|
+
# Si fue invalidado mientras leiamos el disco, no reinyectamos el resultado obsoleto.
|
|
491
|
+
if _stack_invalidations.get(clave, 0.0) <= inicio:
|
|
492
|
+
_stack_seen[clave] = (time.monotonic(), stack)
|
|
493
|
+
return stack
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _olvidar_stack(path: Path) -> None:
|
|
497
|
+
"""Saca el proyecto del cache de la vista.
|
|
498
|
+
|
|
499
|
+
Lo llama `freeze` y `drop_project`: sin esto el usuario apretaba "Congelar"
|
|
500
|
+
o borraba el proyecto y la fila no se enteraba hasta diez segundos despues.
|
|
501
|
+
"""
|
|
502
|
+
clave = str(path)
|
|
503
|
+
with _stack_lock:
|
|
504
|
+
_stack_seen.pop(clave, None)
|
|
505
|
+
_stack_invalidations[clave] = time.monotonic()
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _sessions_file() -> Path:
|
|
509
|
+
"""Resuelto al usarlo y no al importar.
|
|
510
|
+
|
|
511
|
+
Como constante quedaba fijada al `registry.HOME` del momento del import, y
|
|
512
|
+
entonces `mkdir` creaba un directorio y `write_text` escribia en otro. En
|
|
513
|
+
los tests eso mandaba el estado al home real del usuario, y con
|
|
514
|
+
`PORTMASTER_TOKEN` en el entorno, donde nadie llega a crear `~/.portmaster`,
|
|
515
|
+
la persistencia fallaba con un warning que no lee nadie.
|
|
516
|
+
"""
|
|
517
|
+
return registry.HOME / "sessions.json"
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _save_sessions_state() -> None:
|
|
521
|
+
try:
|
|
522
|
+
data = {}
|
|
523
|
+
with sessions_lock:
|
|
524
|
+
for pid, session in sessions.items():
|
|
525
|
+
if session.state in ("starting", "running"):
|
|
526
|
+
data[pid] = {
|
|
527
|
+
"path": str(session.stack.path),
|
|
528
|
+
"profile": session.profile,
|
|
529
|
+
"state": session.state,
|
|
530
|
+
}
|
|
531
|
+
registry.HOME.mkdir(parents=True, exist_ok=True)
|
|
532
|
+
_sessions_file().write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
533
|
+
except Exception as exc:
|
|
534
|
+
log.warning("no se pudo guardar estado de sesiones: %s", exc)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _load_sessions_state() -> None:
|
|
538
|
+
archivo = _sessions_file()
|
|
539
|
+
if not archivo.is_file():
|
|
540
|
+
return
|
|
541
|
+
import json
|
|
542
|
+
try:
|
|
543
|
+
raw = json.loads(archivo.read_text(encoding="utf-8"))
|
|
544
|
+
if not isinstance(raw, dict):
|
|
545
|
+
return
|
|
546
|
+
known_paths = {registry.project_id(p): p for p in registry.paths()}
|
|
547
|
+
with sessions_lock:
|
|
548
|
+
for pid, item in raw.items():
|
|
549
|
+
path = known_paths.get(pid)
|
|
550
|
+
if not path or not path.is_dir():
|
|
551
|
+
continue
|
|
552
|
+
try:
|
|
553
|
+
stack = detect.stack_for(path)
|
|
554
|
+
except config.ConfigError:
|
|
555
|
+
continue
|
|
556
|
+
scanned = ports.scan_many([s.port for s in stack.services.values() if s.port])
|
|
557
|
+
any_busy = any(not status.free for status in scanned.values())
|
|
558
|
+
if any_busy and pid not in sessions:
|
|
559
|
+
session = Session(stack, item.get("profile"))
|
|
560
|
+
session.state = "running"
|
|
561
|
+
sessions[pid] = session
|
|
562
|
+
except Exception as exc:
|
|
563
|
+
log.warning("no se pudo cargar estado de sesiones: %s", exc)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
# seguridad ----------------------------------------------------------------
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def require_token(request: Request) -> None:
|
|
570
|
+
header = request.headers.get("authorization", "")
|
|
571
|
+
supplied = header[7:] if header.lower().startswith("bearer ") else ""
|
|
572
|
+
if not supplied:
|
|
573
|
+
supplied = request.cookies.get("stackhelx_token") or request.cookies.get("portmaster_token", "")
|
|
574
|
+
if not supplied:
|
|
575
|
+
supplied = request.query_params.get("token", "")
|
|
576
|
+
if not secrets.compare_digest(supplied, request.app.state.token):
|
|
577
|
+
log.warning("auth rechazada en %s", request.url.path)
|
|
578
|
+
raise HTTPException(401, "token invalido")
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def quota(name: str, limit: int):
|
|
582
|
+
def dependency(request: Request) -> None:
|
|
583
|
+
client = request.client.host if request.client else "desconocido"
|
|
584
|
+
if not limiter.allow(f"{name}:{client}", limit):
|
|
585
|
+
log.warning("rate limit excedido en %s desde %s", name, client)
|
|
586
|
+
raise HTTPException(429, "Demasiadas peticiones. Espera un momento.")
|
|
587
|
+
|
|
588
|
+
return Depends(dependency)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
# modelos ------------------------------------------------------------------
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
class AddProject(BaseModel):
|
|
595
|
+
path: str = Field(min_length=1, max_length=4096)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
class PathRequest(BaseModel):
|
|
599
|
+
path: str = Field(min_length=1, max_length=4096)
|
|
600
|
+
editor: str | None = Field(default=None, max_length=50)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
class UpRequest(BaseModel):
|
|
606
|
+
profile: str | None = Field(default=None, max_length=100)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
class SwitchProfileRequest(BaseModel):
|
|
610
|
+
profile: str | None = Field(default=None, max_length=100)
|
|
611
|
+
|
|
612
|
+
@field_validator("profile", mode="before")
|
|
613
|
+
def _empty_to_none(cls, v: object) -> object:
|
|
614
|
+
if v == "":
|
|
615
|
+
return None
|
|
616
|
+
return v
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
class KillAllRequest(BaseModel):
|
|
620
|
+
"""Los puertos que el cliente vio en pantalla, como filtro.
|
|
621
|
+
|
|
622
|
+
Obligatorio a proposito. Con un campo opcional, un body mal formado (la
|
|
623
|
+
clave escrita distinto, un null) se leia como "sin filtro" y el endpoint
|
|
624
|
+
pasaba de cerrar lo que el usuario nombro a cerrar todo lo que encontrara.
|
|
625
|
+
Un endpoint que mata procesos falla cerrado: sin lista valida, 422 y nada
|
|
626
|
+
muere. Los puertos son solo un filtro; el pid y el create_time los saca el
|
|
627
|
+
servidor de su propio escaneo.
|
|
628
|
+
"""
|
|
629
|
+
|
|
630
|
+
ports: list[int] = Field(max_length=MAX_TARGETS)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
class CleanRequest(BaseModel):
|
|
634
|
+
"""Que categorias de Docker limpiar.
|
|
635
|
+
|
|
636
|
+
Obligatorio y sin default, por lo mismo que en KillAllRequest: un body mal
|
|
637
|
+
formado no puede leerse como "limpia todo". El `Literal` es la validacion,
|
|
638
|
+
y el comando de cada categoria sale de `docker.TARGETS`, nunca de la red.
|
|
639
|
+
"""
|
|
640
|
+
|
|
641
|
+
targets: list[Literal["containers", "images", "networks", "cache", "volumes"]] = Field(
|
|
642
|
+
min_length=1, max_length=5
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
# app ----------------------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# Tuneles abiertos desde la interfaz. `None` marca un puerto reservado mientras
|
|
650
|
+
# el cliente de tuneles arranca, que tarda segundos: sin la reserva, dos pedidos
|
|
651
|
+
# a la vez dejaban uno de los dos procesos fuera del registro y por lo tanto
|
|
652
|
+
# imposible de cerrar.
|
|
653
|
+
_active_tunnels: dict[int, tunnel.Tunnel | None] = {}
|
|
654
|
+
_tunnels_lock = threading.Lock()
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _docker_view() -> dict:
|
|
658
|
+
"""Estado de Docker sobre todos los proyectos registrados, no sobre la pagina."""
|
|
659
|
+
usan = registry.any_uses_docker(max_age=registry.PORTS_TTL)
|
|
660
|
+
return {"needed": usan, "down": _docker_is_down() if usan else False}
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def tunnels_view() -> list[dict]:
|
|
664
|
+
"""Los tuneles abiertos, para que la interfaz pueda mostrarlos y cerrarlos.
|
|
665
|
+
|
|
666
|
+
De paso saca del registro los que se murieron por su cuenta: el cliente de
|
|
667
|
+
tuneles se puede caer solo, y un puerto que figura expuesto sin estarlo es
|
|
668
|
+
una mentira justo en el panel que existe para no mentir sobre eso.
|
|
669
|
+
"""
|
|
670
|
+
with _tunnels_lock:
|
|
671
|
+
for port, tun in list(_active_tunnels.items()):
|
|
672
|
+
if tun is not None and tun.proc.poll() is not None:
|
|
673
|
+
del _active_tunnels[port]
|
|
674
|
+
log.info("el tunel del puerto %d se cerro solo", port)
|
|
675
|
+
return [
|
|
676
|
+
{"port": port, "url": tun.url, "provider": tun.provider}
|
|
677
|
+
for port, tun in sorted(_active_tunnels.items())
|
|
678
|
+
if tun is not None
|
|
679
|
+
]
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _cerrar_tuneles() -> None:
|
|
683
|
+
"""Cierra todo tunel que siga abierto."""
|
|
684
|
+
with _tunnels_lock:
|
|
685
|
+
vivos = [t for t in _active_tunnels.values() if t is not None]
|
|
686
|
+
_active_tunnels.clear()
|
|
687
|
+
for tun in vivos:
|
|
688
|
+
try:
|
|
689
|
+
tun.stop()
|
|
690
|
+
except Exception as exc: # un tunel roto no puede frenar el apagado
|
|
691
|
+
log.warning("no se pudo cerrar el tunel del puerto %d: %s", tun.port, exc)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
@contextlib.asynccontextmanager
|
|
695
|
+
async def _ciclo_de_vida(app: FastAPI):
|
|
696
|
+
"""Al apagar, cierra los tuneles.
|
|
697
|
+
|
|
698
|
+
Sin esto `portmaster serve` terminaba y el cliente de tuneles seguia vivo,
|
|
699
|
+
con el puerto expuesto a internet, sin nada en pantalla que lo dijera y sin
|
|
700
|
+
forma de cerrarlo que no fuera matarlo a mano.
|
|
701
|
+
"""
|
|
702
|
+
yield
|
|
703
|
+
_cerrar_tuneles()
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def create_app(token: str | None = None) -> FastAPI:
|
|
707
|
+
_load_sessions_state()
|
|
708
|
+
token = token or registry.token()
|
|
709
|
+
app = FastAPI(
|
|
710
|
+
title="PortMaster",
|
|
711
|
+
docs_url=None,
|
|
712
|
+
redoc_url=None,
|
|
713
|
+
openapi_url=None,
|
|
714
|
+
lifespan=_ciclo_de_vida,
|
|
715
|
+
)
|
|
716
|
+
app.state.token = token
|
|
717
|
+
app.state.allowed_hosts = {"127.0.0.1", "localhost", "[::1]"}
|
|
718
|
+
|
|
719
|
+
@app.middleware("http")
|
|
720
|
+
async def guard(request: Request, call_next):
|
|
721
|
+
# Rebinding de DNS: un sitio cualquiera puede resolver su dominio a
|
|
722
|
+
# 127.0.0.1 y hablarle a este servidor desde el navegador de la victima.
|
|
723
|
+
# El token ya lo frena, pero rechazar por Host cierra la puerta antes.
|
|
724
|
+
host = request.headers.get("host", "").rsplit(":", 1)[0]
|
|
725
|
+
if host not in app.state.allowed_hosts:
|
|
726
|
+
log.warning("host rechazado: %r", host)
|
|
727
|
+
return JSONResponse({"detail": "host no permitido"}, status_code=400)
|
|
728
|
+
|
|
729
|
+
response = await call_next(request)
|
|
730
|
+
response.headers["Content-Security-Policy"] = (
|
|
731
|
+
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
|
|
732
|
+
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'"
|
|
733
|
+
)
|
|
734
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
735
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
736
|
+
response.headers["Referrer-Policy"] = "no-referrer"
|
|
737
|
+
response.headers["Cache-Control"] = "no-store"
|
|
738
|
+
# Sin Strict-Transport-Security: el servidor es http en loopback y el
|
|
739
|
+
# header obligaria a https a todo 127.0.0.1, rompiendo otros proyectos.
|
|
740
|
+
return response
|
|
741
|
+
|
|
742
|
+
@app.get("/", include_in_schema=False)
|
|
743
|
+
def index(request: Request, token: str = Query("")) -> FileResponse:
|
|
744
|
+
response = FileResponse(WEB / "index.html")
|
|
745
|
+
# Solo lo que trajo quien pide. El fallback a `app.state.token` que habia
|
|
746
|
+
# aca hacia que un GET / pelado se contestara con el token de verdad en
|
|
747
|
+
# un Set-Cookie: cualquier proceso local conseguia la llave con un curl,
|
|
748
|
+
# y con ella corre comandos, porque stack.yaml es ejecutable por diseno.
|
|
749
|
+
tok = token or request.cookies.get("stackhelx_token") or request.cookies.get("portmaster_token", "")
|
|
750
|
+
if tok and secrets.compare_digest(tok, request.app.state.token):
|
|
751
|
+
response.set_cookie(
|
|
752
|
+
"stackhelx_token",
|
|
753
|
+
tok,
|
|
754
|
+
httponly=False,
|
|
755
|
+
samesite="lax",
|
|
756
|
+
path="/",
|
|
757
|
+
)
|
|
758
|
+
return response
|
|
759
|
+
|
|
760
|
+
app.mount("/static", StaticFiles(directory=WEB), name="static")
|
|
761
|
+
|
|
762
|
+
@app.get("/api/state", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
|
|
763
|
+
def state(
|
|
764
|
+
q: str = Query("", max_length=200),
|
|
765
|
+
status: str = Query("", max_length=20),
|
|
766
|
+
page: int = Query(1, ge=1),
|
|
767
|
+
size: int = Query(PAGE_SIZE, ge=1, le=50),
|
|
768
|
+
) -> dict:
|
|
769
|
+
known = registry.paths()
|
|
770
|
+
paths = _matching(known, q)
|
|
771
|
+
if status:
|
|
772
|
+
paths = [p for p in paths if _status_match(p, status)]
|
|
773
|
+
total = len(paths)
|
|
774
|
+
pages = max(1, -(-total // size))
|
|
775
|
+
page = min(page, pages)
|
|
776
|
+
# Solo se arma la vista de la pagina pedida: cada una lee la config del
|
|
777
|
+
# proyecto y le escanea los puertos, y eso corre cada 2.5 segundos.
|
|
778
|
+
window = paths[(page - 1) * size : page * size]
|
|
779
|
+
return {
|
|
780
|
+
"projects": [_project_view(path) for path in window],
|
|
781
|
+
"total": total,
|
|
782
|
+
# Registrados en total, filtro aparte: es lo que dice el encabezado, y
|
|
783
|
+
# no tiene por que bajar cuando alguien escribe en el buscador.
|
|
784
|
+
"registered": len(known),
|
|
785
|
+
"page": page,
|
|
786
|
+
"pages": pages,
|
|
787
|
+
# Fuera del paginado a proposito: un tunel expone un puerto a
|
|
788
|
+
# internet y no puede quedar escondido en la pagina 2.
|
|
789
|
+
"tunnels": tunnels_view(),
|
|
790
|
+
# Lo mismo con Docker. La interfaz lo sacaba de los cuatro proyectos
|
|
791
|
+
# de la pagina, asi que pasar a la segunda apagaba la fila entera si
|
|
792
|
+
# ahi no habia ninguno con contenedores. Un control que desaparece
|
|
793
|
+
# no distingue "esta en orden" de "esto dejo de funcionar".
|
|
794
|
+
"docker": _docker_view(),
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
@app.get("/api/browse", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
|
|
798
|
+
def browse(path: str = "") -> dict:
|
|
799
|
+
try:
|
|
800
|
+
return browse_module.listing(path)
|
|
801
|
+
except ValueError as exc:
|
|
802
|
+
log.info("browse rechazado: %s", exc)
|
|
803
|
+
raise HTTPException(400, str(exc))
|
|
804
|
+
|
|
805
|
+
@app.get("/api/browse/frecuentes", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
|
|
806
|
+
def browse_frecuentes() -> dict:
|
|
807
|
+
return {"roots": browse_module.frequent_roots(registry.paths())}
|
|
808
|
+
|
|
809
|
+
@app.post("/api/open-folder", dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)])
|
|
810
|
+
def open_folder(req: PathRequest) -> dict:
|
|
811
|
+
p = Path(req.path).expanduser()
|
|
812
|
+
if not p.is_absolute():
|
|
813
|
+
raise HTTPException(400, f"la ruta debe ser absoluta: {req.path}")
|
|
814
|
+
try:
|
|
815
|
+
resolved = p.resolve(strict=True)
|
|
816
|
+
except OSError:
|
|
817
|
+
raise HTTPException(400, f"la carpeta no existe: {req.path}")
|
|
818
|
+
if not resolved.is_dir():
|
|
819
|
+
raise HTTPException(400, f"no es un directorio: {req.path}")
|
|
820
|
+
|
|
821
|
+
p_str = str(resolved)
|
|
822
|
+
try:
|
|
823
|
+
if sys.platform == "win32":
|
|
824
|
+
# explorer.exe fuerza a Windows a crear una ventana en primer plano
|
|
825
|
+
# enfocando la carpeta solicitada en vez de un startfile pasivo.
|
|
826
|
+
subprocess.Popen(["explorer.exe", p_str])
|
|
827
|
+
elif sys.platform == "darwin":
|
|
828
|
+
subprocess.Popen(["open", p_str])
|
|
829
|
+
else:
|
|
830
|
+
subprocess.Popen(["xdg-open", p_str])
|
|
831
|
+
return {"ok": True, "path": p_str}
|
|
832
|
+
except Exception as exc:
|
|
833
|
+
log.warning("fallo al abrir carpeta %s: %s", p_str, exc)
|
|
834
|
+
raise HTTPException(500, f"fallo al abrir el explorador: {exc}")
|
|
835
|
+
|
|
836
|
+
@app.get("/api/editors", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
|
|
837
|
+
def list_editors() -> dict:
|
|
838
|
+
"""Detecta que editores estan instalados en el sistema."""
|
|
839
|
+
available = []
|
|
840
|
+
# VS Code
|
|
841
|
+
if shutil.which("code.cmd") or shutil.which("code"):
|
|
842
|
+
available.append({"id": "code", "name": "VS Code"})
|
|
843
|
+
# Cursor
|
|
844
|
+
if shutil.which("cursor.cmd") or shutil.which("cursor"):
|
|
845
|
+
available.append({"id": "cursor", "name": "Cursor"})
|
|
846
|
+
# Custom
|
|
847
|
+
env_editor = os.environ.get("STACKHELX_EDITOR") or os.environ.get("PORTMASTER_EDITOR") or os.environ.get("EDITOR")
|
|
848
|
+
if env_editor and shutil.which(env_editor):
|
|
849
|
+
available.append({"id": "env", "name": f"Sistema ({env_editor})"})
|
|
850
|
+
return {"editors": available}
|
|
851
|
+
|
|
852
|
+
@app.post("/api/open-editor", dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)])
|
|
853
|
+
def open_editor(req: PathRequest) -> dict:
|
|
854
|
+
p = Path(req.path).expanduser()
|
|
855
|
+
if not p.is_absolute():
|
|
856
|
+
raise HTTPException(400, f"la ruta debe ser absoluta: {req.path}")
|
|
857
|
+
try:
|
|
858
|
+
resolved = p.resolve(strict=True)
|
|
859
|
+
except OSError:
|
|
860
|
+
raise HTTPException(400, f"la carpeta no existe: {req.path}")
|
|
861
|
+
if not resolved.is_dir():
|
|
862
|
+
raise HTTPException(400, f"no es un directorio: {req.path}")
|
|
863
|
+
|
|
864
|
+
# Si el usuario eligio un editor especifico del dropdown
|
|
865
|
+
target = req.editor.lower().strip() if req.editor else ""
|
|
866
|
+
found_editor = None
|
|
867
|
+
editor_display = "Editor"
|
|
868
|
+
|
|
869
|
+
if target == "cursor":
|
|
870
|
+
found_editor = shutil.which("cursor.cmd") or shutil.which("cursor")
|
|
871
|
+
editor_display = "Cursor"
|
|
872
|
+
elif target == "code" or target == "vscode":
|
|
873
|
+
found_editor = shutil.which("code.cmd") or shutil.which("code")
|
|
874
|
+
editor_display = "VS Code"
|
|
875
|
+
elif target == "env":
|
|
876
|
+
cand = os.environ.get("STACKHELX_EDITOR") or os.environ.get("PORTMASTER_EDITOR") or os.environ.get("EDITOR")
|
|
877
|
+
if cand and shutil.which(cand):
|
|
878
|
+
found_editor = cand
|
|
879
|
+
editor_display = cand
|
|
880
|
+
|
|
881
|
+
if not found_editor:
|
|
882
|
+
# Fallback a autodeteccion
|
|
883
|
+
candidates = [
|
|
884
|
+
os.environ.get("STACKHELX_EDITOR"),
|
|
885
|
+
os.environ.get("PORTMASTER_EDITOR"),
|
|
886
|
+
os.environ.get("EDITOR"),
|
|
887
|
+
"cursor.cmd",
|
|
888
|
+
"cursor",
|
|
889
|
+
"code.cmd",
|
|
890
|
+
"code",
|
|
891
|
+
]
|
|
892
|
+
for cand in candidates:
|
|
893
|
+
if cand and shutil.which(cand):
|
|
894
|
+
found_editor = cand
|
|
895
|
+
editor_display = "Cursor" if "cursor" in cand.lower() else ("VS Code" if "code" in cand.lower() else cand)
|
|
896
|
+
break
|
|
897
|
+
|
|
898
|
+
if not found_editor:
|
|
899
|
+
raise HTTPException(404, "No se detectó el editor solicitado en el sistema.")
|
|
900
|
+
|
|
901
|
+
p_str = str(resolved)
|
|
902
|
+
try:
|
|
903
|
+
# Sin shell=True: en Windows metia la ruta por `cmd.exe /c`, y ahi
|
|
904
|
+
# `list2cmdline` sola no alcanza (ver `scripts._entrecomillar`).
|
|
905
|
+
# `shutil.which` devuelve el .cmd completo y CreateProcess lo corre
|
|
906
|
+
# igual, asi que la capa de shell no aportaba nada y solo abria
|
|
907
|
+
# superficie a los metacaracteres de cmd en el nombre de la carpeta.
|
|
908
|
+
subprocess.Popen([found_editor, p_str])
|
|
909
|
+
return {"ok": True, "editor": editor_display, "path": p_str}
|
|
910
|
+
except Exception as exc:
|
|
911
|
+
log.warning("fallo al abrir editor %s en %s: %s", found_editor, p_str, exc)
|
|
912
|
+
raise HTTPException(500, f"fallo al lanzar el editor: {exc}")
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
@app.get("/api/projects/{pid}/history", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
|
|
917
|
+
def get_history(pid: str) -> dict:
|
|
918
|
+
return {"history": history.read(pid)}
|
|
919
|
+
|
|
920
|
+
@app.get("/api/projects/{pid}/metrics", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
|
|
921
|
+
def get_metrics(pid: str) -> dict:
|
|
922
|
+
with sessions_lock:
|
|
923
|
+
session = sessions.get(pid)
|
|
924
|
+
if session is None or session.engine is None:
|
|
925
|
+
return {"metrics": {}}
|
|
926
|
+
return {"metrics": session.engine.resource_stats()}
|
|
927
|
+
|
|
928
|
+
@app.get("/api/projects/{pid}/env-audit", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
|
|
929
|
+
def get_env_audit(pid: str) -> dict:
|
|
930
|
+
path = _lookup(pid)
|
|
931
|
+
examples = [p for p in (path / ".env.example", path / ".env.template") if p.is_file()]
|
|
932
|
+
env_path = path / ".env"
|
|
933
|
+
has_env = env_path.is_file()
|
|
934
|
+
has_example = len(examples) > 0
|
|
935
|
+
example_name = examples[0].name if has_example else None
|
|
936
|
+
|
|
937
|
+
declaradas = doctor._parse_env_keys(examples[0]) if has_example else {}
|
|
938
|
+
propias = doctor._parse_env_keys(env_path) if has_env else {}
|
|
939
|
+
|
|
940
|
+
faltan = [k for k in declaradas if k not in propias]
|
|
941
|
+
vacias = [k for k in declaradas if k in propias and not propias[k]]
|
|
942
|
+
placeholders = [
|
|
943
|
+
k
|
|
944
|
+
for k, v in propias.items()
|
|
945
|
+
if v.strip().strip("'\"").lower() in doctor._PLACEHOLDER_SECRETS
|
|
946
|
+
or v.strip().strip("'\"").lower().startswith(("your_", "your-"))
|
|
947
|
+
] if has_env else []
|
|
948
|
+
|
|
949
|
+
if has_example:
|
|
950
|
+
ok = has_env and len(faltan) == 0 and len(placeholders) == 0
|
|
951
|
+
else:
|
|
952
|
+
ok = (not has_env) or (len(placeholders) == 0)
|
|
953
|
+
|
|
954
|
+
# Cero secretos: sólo nombres de variables y flags booleanos.
|
|
955
|
+
return {
|
|
956
|
+
"ok": ok,
|
|
957
|
+
"has_env": has_env,
|
|
958
|
+
"has_example": has_example,
|
|
959
|
+
"example_file": example_name,
|
|
960
|
+
"missing_keys": faltan,
|
|
961
|
+
"empty_keys": vacias,
|
|
962
|
+
"placeholder_keys": placeholders,
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
@app.get(
|
|
966
|
+
"/api/projects/{pid}/conflicts",
|
|
967
|
+
dependencies=[quota("state", QUOTA_READ), Depends(require_token)],
|
|
968
|
+
)
|
|
969
|
+
def project_conflicts(pid: str) -> dict:
|
|
970
|
+
path = _lookup(pid)
|
|
971
|
+
try:
|
|
972
|
+
stack = detect.stack_for(path)
|
|
973
|
+
except config.ConfigError as exc:
|
|
974
|
+
raise HTTPException(400, str(exc))
|
|
975
|
+
|
|
976
|
+
with sessions_lock:
|
|
977
|
+
session = sessions.get(pid)
|
|
978
|
+
is_ours = session is not None and session.state in ("starting", "running")
|
|
979
|
+
|
|
980
|
+
declared = registry.declared_ports()
|
|
981
|
+
all_declared = set(declared.keys())
|
|
982
|
+
|
|
983
|
+
conflicts = []
|
|
984
|
+
wanted_ports = [s.port for s in stack.services.values() if s.port]
|
|
985
|
+
scanned = ports.scan_many(wanted_ports)
|
|
986
|
+
|
|
987
|
+
for s in stack.services.values():
|
|
988
|
+
if not s.port:
|
|
989
|
+
continue
|
|
990
|
+
status = scanned.get(s.port)
|
|
991
|
+
if status and not status.free and not is_ours:
|
|
992
|
+
occ = _occupant(status, False)
|
|
993
|
+
if occ is not None:
|
|
994
|
+
suggested = ports.suggest_alternative(s.port, exclude=all_declared)
|
|
995
|
+
can_kill = (
|
|
996
|
+
status.pid is not None
|
|
997
|
+
and status.pid not in ports.PROTECTED_PIDS
|
|
998
|
+
and occ.get("proxy") is None
|
|
999
|
+
)
|
|
1000
|
+
conflicts.append(
|
|
1001
|
+
{
|
|
1002
|
+
"service": s.name,
|
|
1003
|
+
"port": s.port,
|
|
1004
|
+
"occupant": occ,
|
|
1005
|
+
"can_kill": can_kill,
|
|
1006
|
+
"suggested_port": suggested,
|
|
1007
|
+
}
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
return {
|
|
1011
|
+
"has_conflicts": len(conflicts) > 0,
|
|
1012
|
+
"conflicts": conflicts,
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
@app.post("/api/projects", dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)])
|
|
1016
|
+
def add_project(body: AddProject) -> dict:
|
|
1017
|
+
try:
|
|
1018
|
+
path = registry.add(body.path)
|
|
1019
|
+
except registry.RegistryError as exc:
|
|
1020
|
+
log.info("alta de proyecto rechazada: %s", exc)
|
|
1021
|
+
raise HTTPException(400, str(exc))
|
|
1022
|
+
return _project_view(path)
|
|
1023
|
+
|
|
1024
|
+
@app.get("/api/projects/export", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
|
|
1025
|
+
def export_projects() -> list[str]:
|
|
1026
|
+
return registry.export_data()
|
|
1027
|
+
|
|
1028
|
+
@app.post("/api/projects/import", dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)])
|
|
1029
|
+
def import_projects(paths_list: list[str]) -> dict:
|
|
1030
|
+
imported = registry.import_data(paths_list)
|
|
1031
|
+
return {"imported": imported, "count": len(imported)}
|
|
1032
|
+
|
|
1033
|
+
@app.delete(
|
|
1034
|
+
"/api/projects/{pid}",
|
|
1035
|
+
dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
|
|
1036
|
+
)
|
|
1037
|
+
def drop_project(pid: str) -> dict:
|
|
1038
|
+
path = _lookup(pid)
|
|
1039
|
+
with sessions_lock:
|
|
1040
|
+
session = sessions.pop(pid, None)
|
|
1041
|
+
if session is not None:
|
|
1042
|
+
session.stop()
|
|
1043
|
+
if not registry.remove(pid):
|
|
1044
|
+
raise HTTPException(404, "proyecto desconocido")
|
|
1045
|
+
_olvidar_stack(path)
|
|
1046
|
+
return {"ok": True}
|
|
1047
|
+
|
|
1048
|
+
@app.post(
|
|
1049
|
+
"/api/projects/{pid}/freeze",
|
|
1050
|
+
dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
|
|
1051
|
+
)
|
|
1052
|
+
def freeze(pid: str) -> dict:
|
|
1053
|
+
"""Congela lo detectado en un stack.yaml editable.
|
|
1054
|
+
|
|
1055
|
+
La ruta sale del registro, nunca del cuerpo del request, y el contenido
|
|
1056
|
+
lo genera `detect.to_yaml`. Un stack.yaml es codigo ejecutable por
|
|
1057
|
+
diseno (ver `shell=True` en CLAUDE.md): un endpoint que aceptara
|
|
1058
|
+
cualquiera de las dos cosas del cliente seria ejecucion remota local
|
|
1059
|
+
detras del token, en vez de un boton que hace lo que dice.
|
|
1060
|
+
"""
|
|
1061
|
+
path = _lookup(pid)
|
|
1062
|
+
try:
|
|
1063
|
+
target = detect.freeze(path)
|
|
1064
|
+
except config.ConfigError as exc:
|
|
1065
|
+
log.info("congelado rechazado para %s: %s", pid, exc)
|
|
1066
|
+
# Ya existe, o no hay nada que congelar: en los dos casos el estado
|
|
1067
|
+
# del disco es el que manda, no el pedido.
|
|
1068
|
+
raise HTTPException(409, str(exc))
|
|
1069
|
+
|
|
1070
|
+
# El stack.yaml recien escrito es lo que la vista tiene que leer ahora,
|
|
1071
|
+
# no dentro de diez segundos: sin esto se apretaba "Congelar" y la fila
|
|
1072
|
+
# seguia diciendo "detectado" hasta que venciera el cache.
|
|
1073
|
+
_olvidar_stack(path)
|
|
1074
|
+
log.info("stack congelado en %s", target)
|
|
1075
|
+
return {"ok": True, "path": str(target)}
|
|
1076
|
+
|
|
1077
|
+
@app.post(
|
|
1078
|
+
"/api/projects/{pid}/up",
|
|
1079
|
+
dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
|
|
1080
|
+
)
|
|
1081
|
+
def up(pid: str, body: UpRequest) -> dict:
|
|
1082
|
+
path = _lookup(pid)
|
|
1083
|
+
try:
|
|
1084
|
+
stack = detect.stack_for(path)
|
|
1085
|
+
stack.resolve(body.profile)
|
|
1086
|
+
except config.ConfigError as exc:
|
|
1087
|
+
log.info("arranque rechazado para %s: %s", pid, exc)
|
|
1088
|
+
raise HTTPException(400, str(exc))
|
|
1089
|
+
|
|
1090
|
+
with sessions_lock:
|
|
1091
|
+
live = sessions.get(pid)
|
|
1092
|
+
if live is not None and live.state in ("starting", "running"):
|
|
1093
|
+
raise HTTPException(409, "el stack ya esta corriendo")
|
|
1094
|
+
if live is not None and live.state == "stopping":
|
|
1095
|
+
raise HTTPException(409, "el stack se esta apagando")
|
|
1096
|
+
selected_profiles[pid] = body.profile
|
|
1097
|
+
session = Session(stack, body.profile)
|
|
1098
|
+
sessions[pid] = session
|
|
1099
|
+
session.start()
|
|
1100
|
+
_save_sessions_state()
|
|
1101
|
+
return {"ok": True}
|
|
1102
|
+
|
|
1103
|
+
@app.post(
|
|
1104
|
+
"/api/projects/{pid}/switch-profile",
|
|
1105
|
+
dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
|
|
1106
|
+
)
|
|
1107
|
+
def switch_profile(pid: str, body: SwitchProfileRequest) -> dict:
|
|
1108
|
+
path = _lookup(pid)
|
|
1109
|
+
try:
|
|
1110
|
+
stack = detect.stack_for(path)
|
|
1111
|
+
stack.resolve(body.profile)
|
|
1112
|
+
except config.ConfigError as exc:
|
|
1113
|
+
log.info("cambio de perfil rechazado para %s: %s", pid, exc)
|
|
1114
|
+
raise HTTPException(400, str(exc))
|
|
1115
|
+
|
|
1116
|
+
with sessions_lock:
|
|
1117
|
+
selected_profiles[pid] = body.profile
|
|
1118
|
+
session = sessions.get(pid)
|
|
1119
|
+
is_running = session is not None and session.state in ("starting", "running")
|
|
1120
|
+
if is_running:
|
|
1121
|
+
session.switch_profile_async(body.profile)
|
|
1122
|
+
|
|
1123
|
+
_save_sessions_state()
|
|
1124
|
+
return {"ok": True, "profile": body.profile}
|
|
1125
|
+
|
|
1126
|
+
@app.post(
|
|
1127
|
+
"/api/projects/{pid}/down",
|
|
1128
|
+
dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
|
|
1129
|
+
)
|
|
1130
|
+
def down(pid: str) -> dict:
|
|
1131
|
+
path = _lookup(pid)
|
|
1132
|
+
with sessions_lock:
|
|
1133
|
+
session = sessions.get(pid)
|
|
1134
|
+
if session is None:
|
|
1135
|
+
# Los contenedores de un `docker compose up -d` desde la
|
|
1136
|
+
# terminal se ven "listos" en la tarjeta, y Apagar contestaba
|
|
1137
|
+
# 404: la interfaz mostraba algo vivo que no podia bajar.
|
|
1138
|
+
try:
|
|
1139
|
+
stack = detect.stack_for(path)
|
|
1140
|
+
except config.ConfigError as exc:
|
|
1141
|
+
raise HTTPException(400, str(exc))
|
|
1142
|
+
session = Session(stack, None)
|
|
1143
|
+
sessions[pid] = session
|
|
1144
|
+
if session.state != "stopping":
|
|
1145
|
+
session.stop_async()
|
|
1146
|
+
return {"ok": True}
|
|
1147
|
+
|
|
1148
|
+
@app.post(
|
|
1149
|
+
"/api/projects/{pid}/services/{name}/restart",
|
|
1150
|
+
dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
|
|
1151
|
+
)
|
|
1152
|
+
def restart(pid: str, name: str) -> dict:
|
|
1153
|
+
with sessions_lock:
|
|
1154
|
+
session = sessions.get(pid)
|
|
1155
|
+
if session is None:
|
|
1156
|
+
raise HTTPException(404, "ese stack no fue arrancado desde aca")
|
|
1157
|
+
if session.state != "running":
|
|
1158
|
+
raise HTTPException(409, "el stack no esta corriendo")
|
|
1159
|
+
if session.engine is None:
|
|
1160
|
+
# Sesion recuperada tras reiniciar el servidor: sabemos que los
|
|
1161
|
+
# puertos estan ocupados, no tenemos los procesos. Reiniciar reventaba
|
|
1162
|
+
# con un AssertionError adentro de un hilo daemon, o sea en silencio.
|
|
1163
|
+
raise HTTPException(
|
|
1164
|
+
409, "este stack sobrevivio a un reinicio del servidor: apagalo y volve a arrancarlo"
|
|
1165
|
+
)
|
|
1166
|
+
if name not in session.stack.services:
|
|
1167
|
+
log.info("reinicio rechazado en %s: servicio %r desconocido", pid, name)
|
|
1168
|
+
raise HTTPException(404, "ese servicio no esta en el stack")
|
|
1169
|
+
session.restart_async(name)
|
|
1170
|
+
return {"ok": True}
|
|
1171
|
+
|
|
1172
|
+
@app.get(
|
|
1173
|
+
"/api/projects/{pid}/logs",
|
|
1174
|
+
dependencies=[quota("state", QUOTA_READ), Depends(require_token)],
|
|
1175
|
+
)
|
|
1176
|
+
def logs(pid: str, since: int = 0) -> dict:
|
|
1177
|
+
with sessions_lock:
|
|
1178
|
+
session = sessions.get(pid)
|
|
1179
|
+
if session is None:
|
|
1180
|
+
return {"lines": [], "seq": 0}
|
|
1181
|
+
pending = [
|
|
1182
|
+
{"seq": seq, "text": text}
|
|
1183
|
+
for seq, text in list(session.sink.lines)
|
|
1184
|
+
if seq > since
|
|
1185
|
+
]
|
|
1186
|
+
return {"lines": pending, "seq": session.sink.seq}
|
|
1187
|
+
|
|
1188
|
+
@app.get(
|
|
1189
|
+
"/api/projects/{pid}/logs/stream",
|
|
1190
|
+
dependencies=[quota("state", QUOTA_READ), Depends(require_token)],
|
|
1191
|
+
)
|
|
1192
|
+
async def stream_logs(
|
|
1193
|
+
pid: str,
|
|
1194
|
+
since: int = 0,
|
|
1195
|
+
follow: bool = True,
|
|
1196
|
+
max_duration: float | None = None,
|
|
1197
|
+
) -> StreamingResponse:
|
|
1198
|
+
with sessions_lock:
|
|
1199
|
+
session = sessions.get(pid)
|
|
1200
|
+
if session is None:
|
|
1201
|
+
raise HTTPException(404, "el proyecto no esta corriendo")
|
|
1202
|
+
|
|
1203
|
+
sub_q = session.sink.subscribe()
|
|
1204
|
+
|
|
1205
|
+
async def event_generator():
|
|
1206
|
+
try:
|
|
1207
|
+
# 1. Emitir líneas pendientes del buffer
|
|
1208
|
+
pending = [
|
|
1209
|
+
(seq, text)
|
|
1210
|
+
for seq, text in list(session.sink.lines)
|
|
1211
|
+
if seq > since
|
|
1212
|
+
]
|
|
1213
|
+
for seq, text in pending:
|
|
1214
|
+
payload = json.dumps({"seq": seq, "text": text})
|
|
1215
|
+
yield f"data: {payload}\n\n"
|
|
1216
|
+
|
|
1217
|
+
if not follow:
|
|
1218
|
+
return
|
|
1219
|
+
|
|
1220
|
+
start_time = time.monotonic()
|
|
1221
|
+
last_event_time = start_time
|
|
1222
|
+
effective_max = max_duration or 3600.0
|
|
1223
|
+
while True:
|
|
1224
|
+
now = time.monotonic()
|
|
1225
|
+
if (now - start_time) > effective_max:
|
|
1226
|
+
break
|
|
1227
|
+
|
|
1228
|
+
had_event = False
|
|
1229
|
+
while True:
|
|
1230
|
+
try:
|
|
1231
|
+
seq, text = sub_q.get_nowait()
|
|
1232
|
+
payload = json.dumps({"seq": seq, "text": text})
|
|
1233
|
+
yield f"data: {payload}\n\n"
|
|
1234
|
+
had_event = True
|
|
1235
|
+
last_event_time = now
|
|
1236
|
+
except queue.Empty:
|
|
1237
|
+
break
|
|
1238
|
+
|
|
1239
|
+
if not had_event and (now - last_event_time) >= 15.0:
|
|
1240
|
+
yield ": keepalive\n\n"
|
|
1241
|
+
last_event_time = now
|
|
1242
|
+
|
|
1243
|
+
await asyncio.sleep(0.05)
|
|
1244
|
+
except (asyncio.CancelledError, GeneratorExit):
|
|
1245
|
+
pass
|
|
1246
|
+
finally:
|
|
1247
|
+
session.sink.unsubscribe(sub_q)
|
|
1248
|
+
|
|
1249
|
+
return StreamingResponse(
|
|
1250
|
+
event_generator(),
|
|
1251
|
+
media_type="text/event-stream",
|
|
1252
|
+
headers={
|
|
1253
|
+
"Cache-Control": "no-cache",
|
|
1254
|
+
"Connection": "keep-alive",
|
|
1255
|
+
"X-Accel-Buffering": "no",
|
|
1256
|
+
},
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
@app.post(
|
|
1260
|
+
"/api/ports/{port}/kill",
|
|
1261
|
+
dependencies=[quota("kill", QUOTA_KILL), Depends(require_token)],
|
|
1262
|
+
)
|
|
1263
|
+
def kill_port(port: int) -> dict:
|
|
1264
|
+
try:
|
|
1265
|
+
status = ports.scan(port)
|
|
1266
|
+
except ValueError as exc:
|
|
1267
|
+
log.info("puerto rechazado: %s", exc)
|
|
1268
|
+
raise HTTPException(400, str(exc))
|
|
1269
|
+
|
|
1270
|
+
if status.free:
|
|
1271
|
+
return {"ok": True, "detail": "ya estaba libre"}
|
|
1272
|
+
if status.pid is None:
|
|
1273
|
+
raise HTTPException(403, "el proceso no es visible con estos permisos")
|
|
1274
|
+
|
|
1275
|
+
try:
|
|
1276
|
+
ports.kill(status.pid, status.create_time, port=port)
|
|
1277
|
+
except ports.KillRefused as exc:
|
|
1278
|
+
log.warning("kill rechazado en %d: %s", port, exc)
|
|
1279
|
+
raise HTTPException(409, str(exc))
|
|
1280
|
+
except psutil.NoSuchProcess:
|
|
1281
|
+
pass
|
|
1282
|
+
except psutil.AccessDenied:
|
|
1283
|
+
log.warning("kill sin permisos en %d (pid %d)", port, status.pid)
|
|
1284
|
+
raise HTTPException(403, "sin permisos para cerrar ese proceso")
|
|
1285
|
+
|
|
1286
|
+
log.info("proceso cerrado: puerto=%d pid=%d nombre=%s", port, status.pid, status.name)
|
|
1287
|
+
return {"ok": True}
|
|
1288
|
+
|
|
1289
|
+
@app.get(
|
|
1290
|
+
"/api/ports/{port}/suggest",
|
|
1291
|
+
dependencies=[quota("state", QUOTA_READ), Depends(require_token)],
|
|
1292
|
+
)
|
|
1293
|
+
def suggest_port(port: int) -> dict:
|
|
1294
|
+
try:
|
|
1295
|
+
ports.check_port(port)
|
|
1296
|
+
except ValueError as exc:
|
|
1297
|
+
log.info("puerto rechazado para sugerencia: %s", exc)
|
|
1298
|
+
raise HTTPException(400, str(exc))
|
|
1299
|
+
|
|
1300
|
+
status = ports.scan(port)
|
|
1301
|
+
declared = registry.declared_ports()
|
|
1302
|
+
all_declared = set(declared.keys())
|
|
1303
|
+
suggested = ports.suggest_alternative(port, exclude=all_declared)
|
|
1304
|
+
return {
|
|
1305
|
+
"port": port,
|
|
1306
|
+
"free": status.free,
|
|
1307
|
+
"suggested": suggested,
|
|
1308
|
+
"occupant": _occupant(status, False),
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
@app.get(
|
|
1312
|
+
"/api/mcp/activity",
|
|
1313
|
+
dependencies=[quota("state", QUOTA_READ), Depends(require_token)],
|
|
1314
|
+
)
|
|
1315
|
+
def mcp_activity() -> dict:
|
|
1316
|
+
"""Telemetría y registro de invocaciones de herramientas MCP para agentes IA."""
|
|
1317
|
+
return mcp.get_telemetry()
|
|
1318
|
+
|
|
1319
|
+
@app.get("/api/version", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
|
|
1320
|
+
def version() -> dict:
|
|
1321
|
+
"""Version y fecha de los estaticos que este servidor sirve.
|
|
1322
|
+
|
|
1323
|
+
Existe para contestar "estoy viendo la pagina nueva o una vieja de la
|
|
1324
|
+
cache del navegador", que sin esto se responde adivinando.
|
|
1325
|
+
"""
|
|
1326
|
+
# Todo lo que sirve el mount, y no una lista escrita a mano. La lista se
|
|
1327
|
+
# olvidaba tokens.css, que app.css importa y es donde viven los colores:
|
|
1328
|
+
# un cambio de solo estilos no movia la fecha y el sello quedaba
|
|
1329
|
+
# mintiendo justo en el caso para el que se puso.
|
|
1330
|
+
#
|
|
1331
|
+
# El arbol entero y no solo el primer nivel: StaticFiles sirve los
|
|
1332
|
+
# subdirectorios igual. Hoy `web/` es plano, asi que esto no arregla un
|
|
1333
|
+
# sintoma, desarma la trampa antes de que exista el primer `web/img/`.
|
|
1334
|
+
assets = max(f.stat().st_mtime for f in WEB.rglob("*") if f.is_file())
|
|
1335
|
+
return {
|
|
1336
|
+
"version": __version__,
|
|
1337
|
+
"assets": time.strftime("%Y-%m-%d %H:%M", time.localtime(assets)),
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
@app.get("/api/health", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
|
|
1341
|
+
def health() -> dict:
|
|
1342
|
+
"""Salud de todo lo que arrancamos, sin paginar.
|
|
1343
|
+
|
|
1344
|
+
`/api/state` devuelve una pagina de cuatro proyectos: alimentar de ahi un
|
|
1345
|
+
aviso de "algo se cayo" seria una mentira silenciosa apenas hay una
|
|
1346
|
+
segunda pagina. Aca no hay escaneo de puertos ni lectura de configs, solo
|
|
1347
|
+
un `poll()` por proceso vivo, que es lo que lo hace barato como para
|
|
1348
|
+
sondearlo al mismo ritmo que el estado.
|
|
1349
|
+
|
|
1350
|
+
Solo mira las sesiones: un servicio que no arrancamos nosotros no es algo
|
|
1351
|
+
que se nos "haya caido".
|
|
1352
|
+
"""
|
|
1353
|
+
with sessions_lock:
|
|
1354
|
+
live = list(sessions.items())
|
|
1355
|
+
|
|
1356
|
+
corriendo, caidos = 0, []
|
|
1357
|
+
for pid, session in live:
|
|
1358
|
+
entrada = {"project": pid, "stack": session.stack.name}
|
|
1359
|
+
if session.state in ("stopped", "error") and not session.stopped_by_user:
|
|
1360
|
+
# El stack entero se murio. La tarjeta lo dice, pero puede estar
|
|
1361
|
+
# en otra pagina: es justo lo que este endpoint viene a resolver.
|
|
1362
|
+
caidos.append({**entrada, "service": None})
|
|
1363
|
+
continue
|
|
1364
|
+
if session.state not in ("starting", "running"):
|
|
1365
|
+
continue
|
|
1366
|
+
corriendo += 1
|
|
1367
|
+
for name, state in session.service_states().items():
|
|
1368
|
+
if state == "stopped":
|
|
1369
|
+
caidos.append({**entrada, "service": name})
|
|
1370
|
+
return {"running": corriendo, "fallen": caidos}
|
|
1371
|
+
|
|
1372
|
+
def _active_service_ports() -> set[int]:
|
|
1373
|
+
"""Puertos que ocupa alguna sesion nuestra, de cualquier proyecto.
|
|
1374
|
+
|
|
1375
|
+
Antes el descarte era por servicio y por proyecto. Con el conjunto de
|
|
1376
|
+
puertos, un puerto que corre en el proyecto A deja de figurar como
|
|
1377
|
+
intruso en el proyecto B que lo declara: quien lo tiene es nuestro, y
|
|
1378
|
+
el aviso de puerto compartido ya cubre ese caso en la tarjeta.
|
|
1379
|
+
"""
|
|
1380
|
+
with sessions_lock:
|
|
1381
|
+
active_sessions = list(sessions.values())
|
|
1382
|
+
running_ports = set()
|
|
1383
|
+
for s in active_sessions:
|
|
1384
|
+
# Los que el servicio eligio al arrancar, ademas de los declarados.
|
|
1385
|
+
# Un proyecto con `ready: listen` no declara puerto (un Next, un
|
|
1386
|
+
# vite), asi que el suyo no entraba aca: otro proyecto que si
|
|
1387
|
+
# declarara ese numero lo veia ocupado y acusaba de intruso al
|
|
1388
|
+
# proceso que acabamos de levantar nosotros. Cerrarlo desde la
|
|
1389
|
+
# interfaz mataba el servicio propio, y "Liberar todos" lo hacia de
|
|
1390
|
+
# un click.
|
|
1391
|
+
descubiertos = s.service_ports()
|
|
1392
|
+
for name, state in s.service_states().items():
|
|
1393
|
+
if state == "stopped":
|
|
1394
|
+
continue
|
|
1395
|
+
svc = s.stack.services.get(name)
|
|
1396
|
+
if svc and svc.port:
|
|
1397
|
+
running_ports.add(svc.port)
|
|
1398
|
+
if name in descubiertos:
|
|
1399
|
+
running_ports.add(descubiertos[name])
|
|
1400
|
+
return running_ports
|
|
1401
|
+
|
|
1402
|
+
@app.get("/api/ports/orphans", dependencies=[quota("state", QUOTA_READ), Depends(require_token)])
|
|
1403
|
+
def orphans() -> dict:
|
|
1404
|
+
"""Puertos de proyectos registrados ocupados por procesos que no lanzamos."""
|
|
1405
|
+
return {"orphans": registry.find_orphans(_active_service_ports())}
|
|
1406
|
+
|
|
1407
|
+
@app.post(
|
|
1408
|
+
"/api/ports/kill-all",
|
|
1409
|
+
dependencies=[quota("kill", QUOTA_KILL), Depends(require_token)],
|
|
1410
|
+
)
|
|
1411
|
+
def kill_all_ports(body: KillAllRequest) -> dict:
|
|
1412
|
+
"""Cierra los procesos intrusos que ocupan los puertos que pide el cliente.
|
|
1413
|
+
|
|
1414
|
+
Del cliente viene una lista de puertos, y nada mas. El servidor vuelve a
|
|
1415
|
+
calcular quienes son intrusos ahora, y de ahi saca el pid y el
|
|
1416
|
+
create_time: un PID que llegara por la red se saltearia el escaneo, la
|
|
1417
|
+
exclusion del proxy de Docker y el recorte a los proyectos registrados.
|
|
1418
|
+
|
|
1419
|
+
Contesta 200 aunque falle alguno. Cerrar seis procesos donde dos dan
|
|
1420
|
+
AccessDenied no es exito ni error, y un solo codigo HTTP no lo puede
|
|
1421
|
+
decir: el detalle va por item.
|
|
1422
|
+
"""
|
|
1423
|
+
pedidos = set(body.ports)
|
|
1424
|
+
candidatos = [
|
|
1425
|
+
c for c in registry.find_orphans(_active_service_ports()) if c["port"] in pedidos
|
|
1426
|
+
]
|
|
1427
|
+
|
|
1428
|
+
killed, failed = [], []
|
|
1429
|
+
for candidato in candidatos:
|
|
1430
|
+
port, pid = candidato["port"], candidato["pid"]
|
|
1431
|
+
try:
|
|
1432
|
+
ports.kill(pid, candidato["create_time"], port=port)
|
|
1433
|
+
except ports.KillRefused as exc:
|
|
1434
|
+
failed.append({"port": port, "reason": str(exc)})
|
|
1435
|
+
log.warning("kill en lote rechazado en %d: %s", port, exc)
|
|
1436
|
+
continue
|
|
1437
|
+
except psutil.NoSuchProcess:
|
|
1438
|
+
pass # se murio entre el escaneo y el kill: el puerto quedo libre igual
|
|
1439
|
+
except psutil.AccessDenied:
|
|
1440
|
+
failed.append({"port": port, "reason": "sin permisos para cerrar ese proceso"})
|
|
1441
|
+
log.warning("kill en lote sin permisos en %d (pid %d)", port, pid)
|
|
1442
|
+
continue
|
|
1443
|
+
except Exception as exc: # un item roto no puede tirar el lote entero
|
|
1444
|
+
failed.append({"port": port, "reason": "no se pudo cerrar"})
|
|
1445
|
+
log.warning("kill en lote fallo en %d (pid %d): %s", port, pid, exc)
|
|
1446
|
+
continue
|
|
1447
|
+
killed.append({"port": port, "pid": pid, "name": candidato["name"]})
|
|
1448
|
+
log.info("intruso cerrado en lote: puerto %d, pid %d", port, pid)
|
|
1449
|
+
|
|
1450
|
+
return {"ok": True, "killed": killed, "failed": failed}
|
|
1451
|
+
|
|
1452
|
+
@app.post(
|
|
1453
|
+
"/api/docker/clean",
|
|
1454
|
+
dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
|
|
1455
|
+
)
|
|
1456
|
+
def docker_clean(body: CleanRequest) -> dict:
|
|
1457
|
+
"""Limpia las categorias de Docker que pidio el usuario."""
|
|
1458
|
+
ok, detail = docker.prune(body.targets)
|
|
1459
|
+
log.info("docker clean pedido %s: ok=%s (%s)", body.targets, ok, detail)
|
|
1460
|
+
return {"ok": ok, "detail": detail}
|
|
1461
|
+
|
|
1462
|
+
@app.get(
|
|
1463
|
+
"/api/docker/containers",
|
|
1464
|
+
dependencies=[quota("state", QUOTA_READ), Depends(require_token)],
|
|
1465
|
+
)
|
|
1466
|
+
def docker_containers() -> dict:
|
|
1467
|
+
"""Los contenedores corriendo, para que Reiniciar diga que se va a llevar.
|
|
1468
|
+
|
|
1469
|
+
Reiniciar el motor los baja a todos, incluidos los de proyectos que no
|
|
1470
|
+
estas mirando, y eso no se puede hacer a medias. Nombrarlos antes es lo
|
|
1471
|
+
unico que se puede hacer por quien aprieta el boton.
|
|
1472
|
+
"""
|
|
1473
|
+
return {"running": docker.running()}
|
|
1474
|
+
|
|
1475
|
+
@app.get(
|
|
1476
|
+
"/api/docker/usage",
|
|
1477
|
+
dependencies=[quota("state", QUOTA_READ), Depends(require_token)],
|
|
1478
|
+
)
|
|
1479
|
+
def docker_usage() -> dict:
|
|
1480
|
+
"""La tabla de `docker system df`, para elegir con un numero delante.
|
|
1481
|
+
|
|
1482
|
+
"Limpiar Docker?" sin decir cuanto hay no es una pregunta que se pueda
|
|
1483
|
+
contestar. Sale tal cual la formatea Docker: parsearla seria atarnos a
|
|
1484
|
+
su salida a cambio de nada.
|
|
1485
|
+
"""
|
|
1486
|
+
return {"table": docker.usage() or ""}
|
|
1487
|
+
|
|
1488
|
+
@app.post(
|
|
1489
|
+
"/api/docker/{action}",
|
|
1490
|
+
dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
|
|
1491
|
+
)
|
|
1492
|
+
def docker_action(action: Literal["start", "restart"]) -> dict:
|
|
1493
|
+
"""Arranca o reinicia Docker Desktop. Si esta arriba lo dice `doctor`.
|
|
1494
|
+
|
|
1495
|
+
El `Literal` es la validacion: cualquier otra cosa en la ruta la rechaza
|
|
1496
|
+
FastAPI con un 422 antes de llegar aca, y el comando sale igual de
|
|
1497
|
+
`docker.ACTIONS` y nunca de lo que vino por la red.
|
|
1498
|
+
|
|
1499
|
+
200 tambien cuando no se pudo: que Docker no este instalado no es un
|
|
1500
|
+
error del request. El motivo va en el cuerpo, que es donde la interfaz
|
|
1501
|
+
lo puede mostrar.
|
|
1502
|
+
"""
|
|
1503
|
+
ok, detail = docker.run(action)
|
|
1504
|
+
log.info("docker %s pedido: ok=%s (%s)", action, ok, detail)
|
|
1505
|
+
return {"ok": ok, "detail": detail}
|
|
1506
|
+
|
|
1507
|
+
@app.post(
|
|
1508
|
+
"/api/share",
|
|
1509
|
+
dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
|
|
1510
|
+
)
|
|
1511
|
+
def share_port(request: Request, port: int, provider: str | None = None) -> dict:
|
|
1512
|
+
"""Inicia un tunel efimero para compartir un puerto."""
|
|
1513
|
+
# Antes del candado y de la reserva: un puerto que no existe no puede
|
|
1514
|
+
# dejar una entrada a medias en `_active_tunnels`. `kill` valida gratis
|
|
1515
|
+
# porque llama a `ports.scan`; aca no hay ningun scan que lo traiga, y
|
|
1516
|
+
# sin esto el 0, el -5 y el 99999 contestaban 200.
|
|
1517
|
+
try:
|
|
1518
|
+
ports.check_port(port)
|
|
1519
|
+
except ValueError as exc:
|
|
1520
|
+
log.info("puerto rechazado: %s", exc)
|
|
1521
|
+
raise HTTPException(400, str(exc))
|
|
1522
|
+
|
|
1523
|
+
# PortMaster no se publica a si mismo. Detras de este puerto esta la API
|
|
1524
|
+
# que arranca stack.yaml, o sea ejecucion de comandos: exponerla deja al
|
|
1525
|
+
# token como unica puerta contra internet entero. La validacion de Host
|
|
1526
|
+
# del middleware ya rechaza al cliente de tuneles, asi que hoy el efecto
|
|
1527
|
+
# es una URL publica que solo sabe contestar 400; el usuario cree que
|
|
1528
|
+
# compartio algo y comparte la consola. Se corta aca y se dice por que.
|
|
1529
|
+
#
|
|
1530
|
+
# El puerto sale del scope ASGI, que uvicorn llena con el socket que
|
|
1531
|
+
# bindeo de verdad. `request.url.port` sale del header Host, y un header
|
|
1532
|
+
# lo escribe quien llama: serviria para esquivar justo esta guarda.
|
|
1533
|
+
propio = (request.scope.get("server") or (None, None))[1]
|
|
1534
|
+
if propio is not None and port == propio:
|
|
1535
|
+
raise HTTPException(
|
|
1536
|
+
400,
|
|
1537
|
+
f"el puerto {port} es el de PortMaster. Publicarlo expone la API "
|
|
1538
|
+
"que ejecuta los comandos de tu stack.yaml, no tu proyecto.",
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1541
|
+
with _tunnels_lock:
|
|
1542
|
+
# Un tunel que se murio solo no puede bloquear el puerto hasta que
|
|
1543
|
+
# pase el sondeo de estado: la limpieza vivia en `tunnels_view`, o
|
|
1544
|
+
# sea que reabrir dependia de que la interfaz hubiera refrescado.
|
|
1545
|
+
previo = _active_tunnels.get(port)
|
|
1546
|
+
if previo is not None and previo.proc.poll() is not None:
|
|
1547
|
+
del _active_tunnels[port]
|
|
1548
|
+
log.info("el tunel del puerto %d se cerro solo", port)
|
|
1549
|
+
if port in _active_tunnels:
|
|
1550
|
+
return {"ok": False, "detail": f"ya hay un tunel para el puerto {port}"}
|
|
1551
|
+
_active_tunnels[port] = None # reserva, ver _active_tunnels
|
|
1552
|
+
|
|
1553
|
+
try:
|
|
1554
|
+
tun = tunnel.start_tunnel(port, provider=provider)
|
|
1555
|
+
except Exception as exc:
|
|
1556
|
+
with _tunnels_lock:
|
|
1557
|
+
_active_tunnels.pop(port, None)
|
|
1558
|
+
if not isinstance(exc, tunnel.TunnelError):
|
|
1559
|
+
raise
|
|
1560
|
+
return {"ok": False, "detail": str(exc)}
|
|
1561
|
+
|
|
1562
|
+
with _tunnels_lock:
|
|
1563
|
+
_active_tunnels[port] = tun
|
|
1564
|
+
log.info("tunel abierto en el puerto %d via %s", port, tun.provider)
|
|
1565
|
+
return {"ok": True, "url": tun.url, "provider": tun.provider, "port": port}
|
|
1566
|
+
|
|
1567
|
+
@app.delete(
|
|
1568
|
+
"/api/share/{port}",
|
|
1569
|
+
dependencies=[quota("write", QUOTA_WRITE), Depends(require_token)],
|
|
1570
|
+
)
|
|
1571
|
+
def stop_share_port(port: int) -> dict:
|
|
1572
|
+
"""Detiene un tunel activo."""
|
|
1573
|
+
with _tunnels_lock:
|
|
1574
|
+
# Solo un tunel ya arrancado. Sacar una reserva liberaria el puerto
|
|
1575
|
+
# mientras su proceso todavia esta naciendo, y ese quedaria afuera.
|
|
1576
|
+
tun = _active_tunnels.get(port)
|
|
1577
|
+
if tun is not None:
|
|
1578
|
+
del _active_tunnels[port]
|
|
1579
|
+
if tun is None:
|
|
1580
|
+
return {"ok": False, "detail": f"No hay tunel activo para el puerto {port}."}
|
|
1581
|
+
tun.stop()
|
|
1582
|
+
log.info("tunel del puerto %d cerrado", port)
|
|
1583
|
+
return {"ok": True, "detail": f"Tunel para puerto {port} cerrado."}
|
|
1584
|
+
|
|
1585
|
+
return app
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
# vistas -------------------------------------------------------------------
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
def _lookup(pid: str) -> Path:
|
|
1592
|
+
for path in registry.paths():
|
|
1593
|
+
if registry.project_id(path) == pid:
|
|
1594
|
+
return path
|
|
1595
|
+
raise HTTPException(404, "proyecto desconocido")
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
def _matching(paths: list[Path], needle: str) -> list[Path]:
|
|
1599
|
+
"""Filtra por nombre de carpeta o ruta.
|
|
1600
|
+
|
|
1601
|
+
El nombre del stack no entra: sale de leer la config de cada proyecto, que es
|
|
1602
|
+
justo el trabajo que el filtro existe para no hacer.
|
|
1603
|
+
"""
|
|
1604
|
+
needle = needle.strip().lower()
|
|
1605
|
+
if not needle:
|
|
1606
|
+
return paths
|
|
1607
|
+
return [p for p in paths if needle in p.name.lower() or needle in str(p).lower()]
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
def _status_match(path: Path, wanted: str) -> bool:
|
|
1611
|
+
"""Filtra por estado general del proyecto."""
|
|
1612
|
+
pid = registry.project_id(path)
|
|
1613
|
+
with sessions_lock:
|
|
1614
|
+
session = sessions.get(pid)
|
|
1615
|
+
state = session.state if session else "stopped"
|
|
1616
|
+
if wanted == "running":
|
|
1617
|
+
return state in ("starting", "running")
|
|
1618
|
+
if wanted == "stopped":
|
|
1619
|
+
return state == "stopped"
|
|
1620
|
+
if wanted == "error":
|
|
1621
|
+
return state in ("error", "invalid")
|
|
1622
|
+
return True
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _project_view(path: Path) -> dict:
|
|
1626
|
+
pid = registry.project_id(path)
|
|
1627
|
+
base = {"id": pid, "path": str(path), "name": path.name}
|
|
1628
|
+
|
|
1629
|
+
try:
|
|
1630
|
+
stack = _stack_para_la_vista(path)
|
|
1631
|
+
except config.ConfigError as exc:
|
|
1632
|
+
# Con el contrato completo: este `return` se salteaba `detected`,
|
|
1633
|
+
# `needs_docker` y `docker_down`, y un proyecto con la config rota
|
|
1634
|
+
# llegaba a la interfaz con la mitad de las claves. En JS eso es
|
|
1635
|
+
# `undefined`, o sea falso silencioso, y el proximo que lea
|
|
1636
|
+
# `p.needs_docker` esperando un booleano se come la trampa.
|
|
1637
|
+
return {
|
|
1638
|
+
**base,
|
|
1639
|
+
"state": "invalid",
|
|
1640
|
+
"error": str(exc),
|
|
1641
|
+
"services": [],
|
|
1642
|
+
"profiles": [],
|
|
1643
|
+
"profile": None,
|
|
1644
|
+
"default": [],
|
|
1645
|
+
"detected": False,
|
|
1646
|
+
"metrics": {},
|
|
1647
|
+
"needs_docker": False,
|
|
1648
|
+
"docker_down": False,
|
|
1649
|
+
"graph": {"levels": [], "nodes": [], "edges": []},
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
with sessions_lock:
|
|
1653
|
+
session = sessions.get(pid)
|
|
1654
|
+
# El estado del proyecto se lee antes que el de sus servicios, y no despues.
|
|
1655
|
+
# `_run` pasa a "running" recien cuando todos quedaron listos: leyendolo al
|
|
1656
|
+
# final, un arranque que termina entre las dos lecturas devuelve el proyecto
|
|
1657
|
+
# "corriendo" con los servicios todavia "arrancando".
|
|
1658
|
+
estado = session.state if session else "stopped"
|
|
1659
|
+
running = session.service_states() if session else {}
|
|
1660
|
+
# Los que ademas tienen un `Proc` vivo detras. None cuando no hay de donde.
|
|
1661
|
+
manejados = running if session and session.engine else None
|
|
1662
|
+
discovered = session.service_ports() if session else {}
|
|
1663
|
+
openable = session.service_http() if session else set()
|
|
1664
|
+
tomados = session.service_taken() if session else set()
|
|
1665
|
+
|
|
1666
|
+
scanned = ports.scan_many([s.port for s in stack.services.values() if s.port])
|
|
1667
|
+
# Cacheado: resolver el stack de cada proyecto registrado cuesta de 1 a 92ms
|
|
1668
|
+
# segun tenga stack.yaml o haya que detectarlo, y esto corre por proyecto y
|
|
1669
|
+
# por request cada 2.5s. Ver registry.declared_ports.
|
|
1670
|
+
compartidos = registry.declared_ports(max_age=registry.PORTS_TTL)
|
|
1671
|
+
services = []
|
|
1672
|
+
for service in stack.services.values():
|
|
1673
|
+
status = scanned.get(service.port) if service.port else None
|
|
1674
|
+
state = running.get(service.name, "stopped")
|
|
1675
|
+
# Un detached vive fuera de nuestro arbol: si alguien lo bajo por afuera
|
|
1676
|
+
# (docker stop), el puerto libre es la verdad y el proceso no dice nada.
|
|
1677
|
+
if state == "ready" and service.detached and status is not None and status.free:
|
|
1678
|
+
state = "stopped"
|
|
1679
|
+
# Y al reves: el puerto lo publica el proxy de Docker, asi que el
|
|
1680
|
+
# contenedor esta arriba lo haya arrancado esta interfaz o no. Sin esto
|
|
1681
|
+
# un stack levantado desde la terminal se ve "detenido" con los tres
|
|
1682
|
+
# puertos en rojo.
|
|
1683
|
+
#
|
|
1684
|
+
# ponytail: asume que el contenedor de ese puerto es el de este
|
|
1685
|
+
# proyecto, la misma apuesta que hace `ready: port` en el runner.
|
|
1686
|
+
# Preguntarle a `docker ps` por cada puerto costaria un subproceso, y
|
|
1687
|
+
# esto se sondea cada 2.5s.
|
|
1688
|
+
if state == "stopped" and service.detached and _published(status):
|
|
1689
|
+
state = "ready"
|
|
1690
|
+
# Solo lo que contesta HTTP se ofrece para abrir: un postgres listo tiene
|
|
1691
|
+
# puerto y no es algo que mandar al navegador.
|
|
1692
|
+
abrible = state != "stopped" and service.name in openable
|
|
1693
|
+
occ = _occupant(status, state != "stopped")
|
|
1694
|
+
suggested_alt = (
|
|
1695
|
+
ports.suggest_alternative(service.port, set(compartidos.keys()))
|
|
1696
|
+
if (status is not None and not status.free and (occ is not None or state == "stopped"))
|
|
1697
|
+
else None
|
|
1698
|
+
)
|
|
1699
|
+
services.append(
|
|
1700
|
+
{
|
|
1701
|
+
"name": service.name,
|
|
1702
|
+
# Contenedor o proceso local: es lo unico que se sabe de verdad
|
|
1703
|
+
# del rol de un servicio. Adivinar "backend" por el nombre no.
|
|
1704
|
+
"kind": "container" if service.detached else "local",
|
|
1705
|
+
"port": service.port or discovered.get(service.name),
|
|
1706
|
+
"openable": abrible,
|
|
1707
|
+
# Adonde lleva "Abrir", si el stack.yaml lo declara. None deja
|
|
1708
|
+
# que la interfaz arme el default con el puerto.
|
|
1709
|
+
#
|
|
1710
|
+
# Se pregunta SOLO si ya es abrible: `runner.service_url` lee
|
|
1711
|
+
# `env.global` y cada `env_file` de disco, y esto corre cada 2.5s
|
|
1712
|
+
# para cada servicio de cada proyecto. Con los stacks apagados,
|
|
1713
|
+
# que es el caso normal, el boton ni se dibuja y no se toca disco.
|
|
1714
|
+
"url": runner.service_url(service) if abrible else None,
|
|
1715
|
+
"needs": list(service.needs),
|
|
1716
|
+
"state": state,
|
|
1717
|
+
"occupant": occ,
|
|
1718
|
+
"suggested_port": suggested_alt,
|
|
1719
|
+
# Otros proyectos registrados que declaran este mismo puerto.
|
|
1720
|
+
# Conviven mientras no corran a la vez, y saberlo antes evita
|
|
1721
|
+
# el "puerto ocupado" que no dice por quien.
|
|
1722
|
+
"shared_with": _shared_with(compartidos, service.port, path),
|
|
1723
|
+
# El puerto ya estaba ocupado cuando arrancamos: el verde puede
|
|
1724
|
+
# ser de otro proceso. Solo mientras corra la sesion que lo vio.
|
|
1725
|
+
"port_taken": state != "stopped" and service.name in tomados,
|
|
1726
|
+
# Lo arranco esta interfaz y hay un proceso del que colgarse.
|
|
1727
|
+
# Sin esto la tarjeta ofrecia "Reiniciar" sobre un contenedor
|
|
1728
|
+
# ajeno, y el boton contestaba 404. El engine hace falta aparte
|
|
1729
|
+
# de la sesion: una que sobrevivio a un reinicio del servidor
|
|
1730
|
+
# deriva sus estados del puerto y no tiene procesos, y ahi
|
|
1731
|
+
# `restart` contesta 409.
|
|
1732
|
+
"managed": manejados is not None and service.name in manejados,
|
|
1733
|
+
}
|
|
1734
|
+
)
|
|
1735
|
+
|
|
1736
|
+
has_docker = any(doctor._program(s.command) == "docker" for s in stack.services.values())
|
|
1737
|
+
docker_down = _docker_is_down() if has_docker else False
|
|
1738
|
+
metrics = session.engine.resource_stats() if session and session.engine else {}
|
|
1739
|
+
|
|
1740
|
+
return {
|
|
1741
|
+
**base,
|
|
1742
|
+
"name": stack.name,
|
|
1743
|
+
"state": estado,
|
|
1744
|
+
"error": session.error if session else None,
|
|
1745
|
+
"detected": stack.detected,
|
|
1746
|
+
"profiles": sorted(stack.profiles),
|
|
1747
|
+
"profile": _current_profile(pid, session),
|
|
1748
|
+
# Que levanta Arrancar sin perfil. Vacio = todos.
|
|
1749
|
+
"default": list(stack.default or ()),
|
|
1750
|
+
"services": services,
|
|
1751
|
+
"metrics": metrics,
|
|
1752
|
+
# Los dos: `docker_down` en False quiere decir "el daemon contesta" y
|
|
1753
|
+
# tambien "este proyecto no usa Docker", y la interfaz necesita
|
|
1754
|
+
# distinguirlos para poder decir "corriendo" en vez de callarse.
|
|
1755
|
+
"needs_docker": has_docker,
|
|
1756
|
+
"docker_down": docker_down,
|
|
1757
|
+
"graph": runner.dependency_graph(stack),
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
def _shared_with(mapa: dict[int, list[Path]], port: int | None, mio: Path) -> list[str]:
|
|
1762
|
+
"""Nombres de los otros proyectos que declaran `port`. Vacio si no hay."""
|
|
1763
|
+
if not port:
|
|
1764
|
+
return []
|
|
1765
|
+
return [registry.name_of(otro) for otro in mapa.get(port, []) if otro != mio]
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
def _published(status: ports.PortStatus | None) -> bool:
|
|
1769
|
+
"""El puerto lo tiene el proxy de Docker, o sea que hay un contenedor arriba."""
|
|
1770
|
+
return status is not None and not status.free and ports.proxy_owner(status) is not None
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
def _occupant(status: ports.PortStatus | None, ours: bool) -> dict | None:
|
|
1774
|
+
"""Quien ocupa el puerto, solo cuando no somos nosotros."""
|
|
1775
|
+
if status is None or status.free or ours:
|
|
1776
|
+
return None
|
|
1777
|
+
# El dueno del puerto puede ser el proxy de Docker, y entonces lo que escucha
|
|
1778
|
+
# ahi es un contenedor: matar ese pid apaga el backend de Docker Desktop
|
|
1779
|
+
# entero y deja el contenedor corriendo sin publicar. `find_orphans` ya lo
|
|
1780
|
+
# sabe y lo saltea, por eso la tarjeta decia "ocupado" mientras el panel de
|
|
1781
|
+
# intrusos decia "ninguno".
|
|
1782
|
+
return {
|
|
1783
|
+
"pid": status.pid,
|
|
1784
|
+
"name": status.name or "desconocido",
|
|
1785
|
+
"proxy": ports.proxy_owner(status),
|
|
1786
|
+
}
|