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/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
+