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/registry.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Proyectos que la interfaz conoce.
|
|
2
|
+
|
|
3
|
+
El CLI trabaja sobre el directorio actual y no necesita registro. La interfaz
|
|
4
|
+
si: su razon de existir es ver todos los proyectos a la vez sin importar en que
|
|
5
|
+
carpeta estas parado.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import config, detect, ports
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _default_home() -> Path:
|
|
22
|
+
env = os.environ.get("STACKHELX_HOME") or os.environ.get("PORTMASTER_HOME")
|
|
23
|
+
if env:
|
|
24
|
+
return Path(env)
|
|
25
|
+
new_home = Path.home() / ".stackhelx"
|
|
26
|
+
old_home = Path.home() / ".portmaster"
|
|
27
|
+
if not new_home.exists() and old_home.exists():
|
|
28
|
+
return old_home
|
|
29
|
+
return new_home
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
HOME = _default_home()
|
|
33
|
+
PROJECTS = HOME / "projects.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RegistryError(Exception):
|
|
37
|
+
"""La ruta no sirve como proyecto."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def project_id(path: Path) -> str:
|
|
41
|
+
"""Identificador estable y seguro para URLs, derivado de la ruta."""
|
|
42
|
+
return hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:12]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def paths() -> list[Path]:
|
|
46
|
+
try:
|
|
47
|
+
data = json.loads(PROJECTS.read_text(encoding="utf-8"))
|
|
48
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
49
|
+
return []
|
|
50
|
+
if not isinstance(data, list):
|
|
51
|
+
return []
|
|
52
|
+
return [Path(item) for item in data if isinstance(item, str)]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def add(raw: str | Path) -> Path:
|
|
56
|
+
"""Registra un proyecto: con stack.yaml propio, o detectable."""
|
|
57
|
+
path = Path(raw).expanduser()
|
|
58
|
+
if not path.is_dir():
|
|
59
|
+
raise RegistryError(f"no es un directorio: {path}")
|
|
60
|
+
path = path.resolve()
|
|
61
|
+
|
|
62
|
+
known = any((path / name).is_file() for name in config.CONFIG_NAMES)
|
|
63
|
+
if not known and detect.detect(path) is None:
|
|
64
|
+
raise RegistryError(
|
|
65
|
+
f"{path} no tiene {config.CONFIG_NAMES[0]} y no se detecto nada conocido"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
current = paths()
|
|
69
|
+
if path not in current:
|
|
70
|
+
_save(current + [path])
|
|
71
|
+
return path
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def remove(pid: str) -> bool:
|
|
75
|
+
current = paths()
|
|
76
|
+
kept = [p for p in current if project_id(p) != pid]
|
|
77
|
+
if len(kept) == len(current):
|
|
78
|
+
return False
|
|
79
|
+
_save(kept)
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def export_data() -> list[str]:
|
|
84
|
+
"""Exporta las rutas de los proyectos registrados como lista JSON."""
|
|
85
|
+
return [str(p) for p in paths()]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def import_data(data: list[str]) -> list[str]:
|
|
89
|
+
"""Importa masivamente rutas de proyectos desde una lista."""
|
|
90
|
+
if not isinstance(data, list):
|
|
91
|
+
raise RegistryError("el formato debe ser una lista de rutas de proyectos")
|
|
92
|
+
imported = []
|
|
93
|
+
for item in data:
|
|
94
|
+
if isinstance(item, str):
|
|
95
|
+
try:
|
|
96
|
+
path = add(item)
|
|
97
|
+
imported.append(str(path))
|
|
98
|
+
except RegistryError:
|
|
99
|
+
pass
|
|
100
|
+
return imported
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Cuanto vale reusar el mapa antes de recalcularlo, para quien lo pida cacheado.
|
|
104
|
+
PORTS_TTL = 30.0
|
|
105
|
+
_cache_lock = threading.Lock()
|
|
106
|
+
# Sube con cada cambio del registro. Un escaneo que empezo antes de un `add`
|
|
107
|
+
# no puede escribir su resultado despues: seria el estado viejo pisando al
|
|
108
|
+
# nuevo, con 30s de vida por delante.
|
|
109
|
+
_revision = 0
|
|
110
|
+
# Los puertos y los nombres salen del mismo recorrido: un solo cache, que es
|
|
111
|
+
# una cosa menos que olvidarse de invalidar en `_save`.
|
|
112
|
+
_ports_cache: tuple[float, dict[int, list[Path]], dict[Path, str]] | None = None
|
|
113
|
+
_docker_cache: tuple[float, bool] | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def declared_ports(max_age: float = 0.0) -> dict[int, list[Path]]:
|
|
117
|
+
"""Puerto declarado -> proyectos registrados que lo piden.
|
|
118
|
+
|
|
119
|
+
Es el unico dato que PortMaster tiene y las herramientas de un proyecto solo
|
|
120
|
+
no pueden tener: cada compose se conoce a si mismo y ninguno sabe del de al
|
|
121
|
+
lado. Sin esto, que dos proyectos peleen por el 3000 se descubre cuando el
|
|
122
|
+
segundo no arranca.
|
|
123
|
+
|
|
124
|
+
Resolver el stack de cada proyecto medido en esta maquina: 1.3ms con
|
|
125
|
+
stack.yaml, 10 a 92ms detectado. Con tres proyectos son 114ms, y escala
|
|
126
|
+
lineal. Para un comando que corre una vez no es nada, y por eso el default
|
|
127
|
+
es recalcular siempre: un doctor que miente no sirve. La vista de estado,
|
|
128
|
+
que sondea cada 2.5s en el threadpool que comparte con /down y /kill, pide
|
|
129
|
+
`max_age=PORTS_TTL`.
|
|
130
|
+
|
|
131
|
+
ponytail: el cache vence por tiempo y no por mtime. Editar un stack.yaml
|
|
132
|
+
tarda hasta PORTS_TTL en verse en la interfaz, que para un aviso esta bien.
|
|
133
|
+
Si alguna vez molesta, la salida es sumarle el mtime del archivo de config,
|
|
134
|
+
aunque para un proyecto detectado eso tampoco alcanza: la deteccion lee
|
|
135
|
+
archivos de subcarpetas que el mtime de la raiz no delata.
|
|
136
|
+
"""
|
|
137
|
+
global _ports_cache
|
|
138
|
+
now = time.monotonic()
|
|
139
|
+
with _cache_lock:
|
|
140
|
+
if max_age > 0 and _ports_cache is not None and now - _ports_cache[0] < max_age:
|
|
141
|
+
return _ports_cache[1]
|
|
142
|
+
revision = _revision
|
|
143
|
+
|
|
144
|
+
found: dict[int, list[Path]] = {}
|
|
145
|
+
names: dict[Path, str] = {}
|
|
146
|
+
for path in paths():
|
|
147
|
+
names[path], puertos = _stack_of(path)
|
|
148
|
+
for port in sorted(puertos):
|
|
149
|
+
found.setdefault(port, []).append(path)
|
|
150
|
+
with _cache_lock:
|
|
151
|
+
if revision == _revision:
|
|
152
|
+
_ports_cache = (now, found, names)
|
|
153
|
+
return found
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def name_of(path: Path, max_age: float = PORTS_TTL) -> str:
|
|
157
|
+
"""Como se llama un proyecto registrado, igual que en su ficha.
|
|
158
|
+
|
|
159
|
+
El nombre lo declara `stack.yaml` y puede no coincidir con el de la carpeta:
|
|
160
|
+
`apps/Fitness` se llama `fittrack`. Nombrar la carpeta en un aviso manda al
|
|
161
|
+
usuario a buscar un proyecto que en la interfaz no existe.
|
|
162
|
+
"""
|
|
163
|
+
declared_ports(max_age=max_age)
|
|
164
|
+
with _cache_lock:
|
|
165
|
+
cache = _ports_cache
|
|
166
|
+
return cache[2].get(path, path.name) if cache else path.name
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def any_uses_docker(max_age: float = 0.0) -> bool:
|
|
170
|
+
"""Si algun proyecto registrado levanta contenedores.
|
|
171
|
+
|
|
172
|
+
La interfaz decide con esto si muestra la fila de Docker, y tiene que salir
|
|
173
|
+
de todos los proyectos y no de la pagina que estas mirando: colgado de la
|
|
174
|
+
pagina, pasar a la segunda apagaba la fila entera cuando ahi no habia
|
|
175
|
+
ninguno con contenedores. Es el mismo motivo por el que `/api/health` no
|
|
176
|
+
pagina y por el que los tuneles van fuera del paginado.
|
|
177
|
+
|
|
178
|
+
ponytail: cache propio, o sea un segundo recorrido de todos los proyectos
|
|
179
|
+
ademas del de `declared_ports`. Los dos vencen a los 30s, asi que la vista
|
|
180
|
+
de estado paga uno de cada doce sondeos. Si alguna vez pesa, los dos salen
|
|
181
|
+
de un unico recorrido que resuelva cada stack una sola vez.
|
|
182
|
+
"""
|
|
183
|
+
global _docker_cache
|
|
184
|
+
now = time.monotonic()
|
|
185
|
+
with _cache_lock:
|
|
186
|
+
if max_age > 0 and _docker_cache is not None and now - _docker_cache[0] < max_age:
|
|
187
|
+
return _docker_cache[1]
|
|
188
|
+
revision = _revision
|
|
189
|
+
|
|
190
|
+
usa = any(_uses_docker(path) for path in paths())
|
|
191
|
+
|
|
192
|
+
with _cache_lock:
|
|
193
|
+
# Solo si el registro no cambio mientras se recorria. El escaneo tarda
|
|
194
|
+
# decenas de milisegundos y `/api/state` corre en un threadpool: sin
|
|
195
|
+
# esto, un `add` que caia justo en el medio limpiaba el cache y despues
|
|
196
|
+
# este escaneo, que arranco antes, lo repoblaba con el valor viejo. La
|
|
197
|
+
# fila de Docker volvia a tardar 30s en aparecer, que es el bug que ya
|
|
198
|
+
# arreglamos una vez.
|
|
199
|
+
if revision == _revision:
|
|
200
|
+
_docker_cache = (now, usa)
|
|
201
|
+
return usa
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _uses_docker(path: Path) -> bool:
|
|
205
|
+
"""Un proyecto roto no cuenta, y no es motivo para no revisar el resto."""
|
|
206
|
+
# Adentro de la funcion: `doctor` importa este modulo, y arriba seria un
|
|
207
|
+
# ciclo. Es el mismo `_program` que usa la vista de estado, y compartirlo
|
|
208
|
+
# importa: si algun dia cambia como se saca el nombre del programa, los dos
|
|
209
|
+
# lados tienen que cambiar juntos o la fila aparece cuando no debe.
|
|
210
|
+
from . import doctor
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
stack = detect.stack_for(path)
|
|
214
|
+
except (config.ConfigError, OSError):
|
|
215
|
+
return False
|
|
216
|
+
return any(doctor._program(s.command) == "docker" for s in stack.services.values())
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _stack_of(path: Path) -> tuple[str, set[int]]:
|
|
220
|
+
"""Nombre y puertos declarados por un proyecto. Vacio si no se puede leer.
|
|
221
|
+
|
|
222
|
+
Un proyecto roto o borrado no es motivo para que el resto no se revise:
|
|
223
|
+
tiene su propio chequeo en `doctor`. Ahi el nombre cae en el de la carpeta,
|
|
224
|
+
que es lo unico que se sabe de un proyecto que no se puede leer.
|
|
225
|
+
"""
|
|
226
|
+
try:
|
|
227
|
+
stack = detect.stack_for(path)
|
|
228
|
+
return stack.name, {s.port for s in stack.resolve() if s.port}
|
|
229
|
+
except (config.ConfigError, OSError):
|
|
230
|
+
return path.name, set()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _save(items: list[Path]) -> None:
|
|
234
|
+
global _ports_cache, _docker_cache, _revision
|
|
235
|
+
# Agregar o quitar un proyecto cambia el mapa ya mismo: esperar el TTL
|
|
236
|
+
# dejaria la interfaz media hora sin ver el proyecto recien registrado.
|
|
237
|
+
# Los dos caches salen del mismo recorrido del registro y vencen juntos: el
|
|
238
|
+
# de Docker se sumo despues y quedo afuera de esta linea, y el sintoma fue
|
|
239
|
+
# registrar un proyecto con contenedores y que la fila tardara 30s en salir.
|
|
240
|
+
with _cache_lock:
|
|
241
|
+
_ports_cache = None
|
|
242
|
+
_docker_cache = None
|
|
243
|
+
_revision += 1
|
|
244
|
+
HOME.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
ordered = sorted({str(p) for p in items})
|
|
246
|
+
tmp = PROJECTS.with_suffix(".tmp")
|
|
247
|
+
tmp.write_text(json.dumps(ordered, indent=2), encoding="utf-8")
|
|
248
|
+
tmp.replace(PROJECTS)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def token() -> str:
|
|
252
|
+
"""Token de la API local.
|
|
253
|
+
|
|
254
|
+
Prioriza PORTMASTER_TOKEN. Si no esta, usa uno generado en el directorio del
|
|
255
|
+
usuario. Ver la desviacion documentada en CLAUDE.md: una herramienta que se
|
|
256
|
+
instala con pipx no puede traer un .env, y un token generado con permisos
|
|
257
|
+
0600 fuera del repo es mas seguro que uno que el usuario copia a mano.
|
|
258
|
+
"""
|
|
259
|
+
from secrets import token_urlsafe
|
|
260
|
+
|
|
261
|
+
from_env = os.environ.get("STACKHELX_TOKEN") or os.environ.get("PORTMASTER_TOKEN")
|
|
262
|
+
if from_env:
|
|
263
|
+
if len(from_env) < 16:
|
|
264
|
+
raise RegistryError("STACKHELX_TOKEN es demasiado corto (minimo 16)")
|
|
265
|
+
return from_env
|
|
266
|
+
|
|
267
|
+
path = HOME / "token"
|
|
268
|
+
try:
|
|
269
|
+
existing = path.read_text(encoding="utf-8").strip()
|
|
270
|
+
if len(existing) >= 16:
|
|
271
|
+
return existing
|
|
272
|
+
except OSError:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
HOME.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
fresh = token_urlsafe(32)
|
|
277
|
+
# Permisos restrictivos (0600) en el open, y fchmod previo si ya existia.
|
|
278
|
+
def _opener(p, flags):
|
|
279
|
+
fd = os.open(p, flags, 0o600)
|
|
280
|
+
if hasattr(os, "fchmod"):
|
|
281
|
+
try:
|
|
282
|
+
os.fchmod(fd, 0o600)
|
|
283
|
+
except OSError:
|
|
284
|
+
pass
|
|
285
|
+
return fd
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
with open(path, "w", encoding="utf-8", opener=_opener) as f:
|
|
289
|
+
f.write(fresh)
|
|
290
|
+
try:
|
|
291
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
292
|
+
except OSError:
|
|
293
|
+
pass
|
|
294
|
+
except OSError:
|
|
295
|
+
path.write_text(fresh, encoding="utf-8") # sistemas sin permisos POSIX
|
|
296
|
+
return fresh
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def find_orphans(running_ports: frozenset[int] | set[int] = frozenset()) -> list[dict]:
|
|
300
|
+
"""Puertos de proyectos registrados ocupados por procesos que no lanzamos.
|
|
301
|
+
|
|
302
|
+
Un proceso es intruso si su puerto aparece en el stack de algun proyecto
|
|
303
|
+
registrado, no esta en running_ports (lo que corre en nuestras sesiones), y
|
|
304
|
+
hay un proceso externo escuchando ahi que no es un proxy de Docker.
|
|
305
|
+
|
|
306
|
+
El default vacio es lo correcto para el CLI, que no tiene sesiones y por eso
|
|
307
|
+
no puede distinguir un intruso de un stack que vos mismo levantaste en otra
|
|
308
|
+
terminal. Quien llame con esa suposicion tiene que mostrar la lista antes de
|
|
309
|
+
cerrar nada.
|
|
310
|
+
"""
|
|
311
|
+
# Una fila por puerto, con todos los proyectos que lo reclaman. Antes salia
|
|
312
|
+
# una por proyecto, asi que un puerto que dos declaran aparecia dos veces
|
|
313
|
+
# con el mismo pid y la misma linea de comando: informacion repetida que
|
|
314
|
+
# ademas sugeria que habia dos procesos.
|
|
315
|
+
por_puerto: dict[int, dict] = {}
|
|
316
|
+
for path in paths():
|
|
317
|
+
try:
|
|
318
|
+
stack = detect.stack_for(path)
|
|
319
|
+
except (config.ConfigError, OSError):
|
|
320
|
+
continue
|
|
321
|
+
|
|
322
|
+
scanned = ports.scan_many([s.port for s in stack.services.values() if s.port])
|
|
323
|
+
|
|
324
|
+
for svc in stack.services.values():
|
|
325
|
+
if not svc.port or svc.port in running_ports:
|
|
326
|
+
continue
|
|
327
|
+
status = scanned.get(svc.port)
|
|
328
|
+
if status is None or status.free or status.pid is None:
|
|
329
|
+
continue
|
|
330
|
+
if ports.proxy_owner(status):
|
|
331
|
+
continue
|
|
332
|
+
nombre = stack.name or path.name
|
|
333
|
+
fila = por_puerto.get(svc.port)
|
|
334
|
+
if fila is None:
|
|
335
|
+
por_puerto[svc.port] = {
|
|
336
|
+
"port": svc.port,
|
|
337
|
+
"projects": [nombre],
|
|
338
|
+
"pid": status.pid,
|
|
339
|
+
"name": status.name or "desconocido",
|
|
340
|
+
"cmd": (status.cmdline or "")[:120] or None,
|
|
341
|
+
"create_time": status.create_time,
|
|
342
|
+
}
|
|
343
|
+
elif nombre not in fila["projects"]:
|
|
344
|
+
fila["projects"].append(nombre)
|
|
345
|
+
|
|
346
|
+
for fila in por_puerto.values():
|
|
347
|
+
fila["projects"].sort()
|
|
348
|
+
return [por_puerto[port] for port in sorted(por_puerto)]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def find_collisions(max_age: float = 0.0) -> dict[int, list[Path]]:
|
|
352
|
+
"""Devuelve los puertos disputados por dos o mas proyectos registrados."""
|
|
353
|
+
ports_map = declared_ports(max_age=max_age)
|
|
354
|
+
return {port: projs for port, projs in ports_map.items() if len(projs) > 1}
|
|
355
|
+
|
|
356
|
+
|