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/docker.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Arranque y reinicio de Docker Desktop, a pedido del usuario.
|
|
2
|
+
|
|
3
|
+
Vive fuera de `doctor` a proposito: ese modulo diagnostica sin arrancar nada, y
|
|
4
|
+
lo dice en su primera linea. Aca no hay chequeos; el "esta apagado" sigue
|
|
5
|
+
saliendo de `doctor._docker`, que es quien ya lo sabe.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
# El plugin del CLI y no el ejecutable. `docker` ya es una dependencia del
|
|
14
|
+
# proyecto y ya tiene que estar en el PATH para que un stack con compose sirva
|
|
15
|
+
# de algo; el ejecutable de Docker Desktop no esta en el PATH en ninguna
|
|
16
|
+
# plataforma, vive en una ruta distinta por sistema operativo, y en Windows
|
|
17
|
+
# lanzarlo con `start` lo hace buscar tambien en el directorio actual, que es
|
|
18
|
+
# el del proyecto que estes mirando.
|
|
19
|
+
#
|
|
20
|
+
# `--detach` porque sin el, el comando espera a que el motor termine: entre 30 y
|
|
21
|
+
# 60 segundos con un request colgado en el threadpool que FastAPI comparte con
|
|
22
|
+
# apagar y con matar procesos. Ver server._probe_late_http, que existe por haber
|
|
23
|
+
# aprendido eso mismo.
|
|
24
|
+
#
|
|
25
|
+
# Diccionario y no un string armado con lo que llegue: lo unico que puede pedir
|
|
26
|
+
# la red es una de estas dos claves, y el comando sale de aca.
|
|
27
|
+
ACTIONS = {
|
|
28
|
+
"start": ("docker", "desktop", "start", "--detach"),
|
|
29
|
+
"restart": ("docker", "desktop", "restart", "--detach"),
|
|
30
|
+
}
|
|
31
|
+
TIMEOUT = 10.0
|
|
32
|
+
|
|
33
|
+
HECHO = {
|
|
34
|
+
"start": "Docker Desktop esta arrancando",
|
|
35
|
+
"restart": "Docker Desktop se esta reiniciando",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run(action: str) -> tuple[bool, str]:
|
|
40
|
+
"""Pide `start` o `restart` de Docker Desktop. Devuelve si se pudo, y que decir.
|
|
41
|
+
|
|
42
|
+
Mira el resultado en vez de disparar y olvidarse. Un lanzamiento que nadie
|
|
43
|
+
espera contesta "listo" siempre, tambien cuando Docker no esta instalado, y
|
|
44
|
+
el usuario se queda mirando una interfaz que le mintio.
|
|
45
|
+
|
|
46
|
+
Que el comando haya salido bien no es que el motor este arriba: eso tarda y
|
|
47
|
+
lo reporta la vista de estado, que ya sondea `docker info`.
|
|
48
|
+
"""
|
|
49
|
+
command = ACTIONS[action]
|
|
50
|
+
try:
|
|
51
|
+
done = subprocess.run(
|
|
52
|
+
list(command),
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
errors="replace",
|
|
56
|
+
timeout=TIMEOUT,
|
|
57
|
+
)
|
|
58
|
+
except FileNotFoundError:
|
|
59
|
+
return False, "docker no esta en el PATH"
|
|
60
|
+
except subprocess.TimeoutExpired:
|
|
61
|
+
return False, f"docker no contesto en {TIMEOUT:.0f}s"
|
|
62
|
+
except OSError as exc:
|
|
63
|
+
return False, f"no se pudo ejecutar docker: {exc}"
|
|
64
|
+
|
|
65
|
+
if done.returncode != 0:
|
|
66
|
+
return False, _motivo(done, action)
|
|
67
|
+
return True, HECHO[action]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _motivo(done: subprocess.CompletedProcess, action: str) -> str:
|
|
71
|
+
"""La primera linea util de la salida, o el codigo si no dijo nada.
|
|
72
|
+
|
|
73
|
+
Docker Desktop para Linux no trae el plugin `desktop`, y ahi el error es
|
|
74
|
+
'docker: desktop is not a docker command'. Decirlo tal cual es mas util que
|
|
75
|
+
un "no se pudo" que obliga a ir a buscar por que.
|
|
76
|
+
"""
|
|
77
|
+
for stream in (done.stderr, done.stdout):
|
|
78
|
+
primera = next((line.strip() for line in (stream or "").splitlines() if line.strip()), "")
|
|
79
|
+
if primera:
|
|
80
|
+
return primera[:200]
|
|
81
|
+
return f"docker desktop {action} fallo con codigo {done.returncode}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def running() -> list[str]:
|
|
85
|
+
"""Nombres de los contenedores corriendo.
|
|
86
|
+
|
|
87
|
+
Reiniciar el motor los baja a todos, incluidos los de proyectos que no
|
|
88
|
+
estas mirando. No hay forma de reiniciarlo a medias, asi que lo unico que
|
|
89
|
+
se puede hacer por el usuario es decirle que se va a llevar antes de
|
|
90
|
+
llevarselo. Vacio si docker no contesta: la confirmacion sigue, con la
|
|
91
|
+
frase generica.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
done = subprocess.run(
|
|
95
|
+
["docker", "ps", "--format", "{{.Names}}"],
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
errors="replace",
|
|
99
|
+
timeout=TIMEOUT,
|
|
100
|
+
)
|
|
101
|
+
except (OSError, subprocess.SubprocessError):
|
|
102
|
+
return []
|
|
103
|
+
if done.returncode != 0:
|
|
104
|
+
return []
|
|
105
|
+
return [line.strip() for line in (done.stdout or "").splitlines() if line.strip()]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def usage() -> str | None:
|
|
109
|
+
"""La tabla de `docker system df`, para que la confirmacion diga cuanto hay.
|
|
110
|
+
|
|
111
|
+
Sin parsear: Docker ya la formatea, y sacarle el numero a mano seria atarnos
|
|
112
|
+
a su formato de salida a cambio de nada. None si docker no contesta, y ahi
|
|
113
|
+
la confirmacion sigue sin la tabla en vez de cancelarse.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
done = subprocess.run(
|
|
117
|
+
["docker", "system", "df"],
|
|
118
|
+
capture_output=True,
|
|
119
|
+
text=True,
|
|
120
|
+
errors="replace",
|
|
121
|
+
timeout=TIMEOUT,
|
|
122
|
+
)
|
|
123
|
+
except (OSError, subprocess.SubprocessError):
|
|
124
|
+
return None
|
|
125
|
+
return done.stdout.strip() or None if done.returncode == 0 else None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Que limpia cada categoria, con su comando propio. `docker system prune` los
|
|
129
|
+
# tira todos juntos y no deja elegir: el cache de build puede ser decenas de GB
|
|
130
|
+
# que se regeneran solas, y las imagenes sin tag pueden ser la capa base que vas
|
|
131
|
+
# a volver a bajar. No es lo mismo y el usuario tiene que poder separarlo.
|
|
132
|
+
#
|
|
133
|
+
# Diccionario y no un comando armado con lo que llegue: lo unico que puede pedir
|
|
134
|
+
# la red son estas claves.
|
|
135
|
+
TARGETS = {
|
|
136
|
+
"containers": ("docker", "container", "prune", "-f"),
|
|
137
|
+
"images": ("docker", "image", "prune", "-f"),
|
|
138
|
+
"networks": ("docker", "network", "prune", "-f"),
|
|
139
|
+
"cache": ("docker", "builder", "prune", "-f"),
|
|
140
|
+
# Aparte del resto en todos lados: adentro hay datos y no se regeneran. Ni
|
|
141
|
+
# el default del CLI ni el de la interfaz lo incluyen, y el MCP lo rechaza.
|
|
142
|
+
"volumes": ("docker", "volume", "prune", "-f"),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# Lo que se limpia cuando no se elige nada. `volumes` queda afuera a proposito.
|
|
146
|
+
DEFAULT_TARGETS = ("containers", "images", "networks", "cache")
|
|
147
|
+
|
|
148
|
+
ETIQUETAS = {
|
|
149
|
+
"containers": "contenedores parados",
|
|
150
|
+
"images": "imagenes sin tag",
|
|
151
|
+
"networks": "redes sin usar",
|
|
152
|
+
"cache": "cache de build",
|
|
153
|
+
"volumes": "volumenes anonimos",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
PRUNE_TIMEOUT = 60.0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def prune(targets: Sequence[str] | None = None) -> tuple[bool, str]:
|
|
160
|
+
"""Limpia las categorias pedidas, una por una.
|
|
161
|
+
|
|
162
|
+
Devuelve si todas salieron bien, y un resumen con lo que reclamo cada una.
|
|
163
|
+
Una que falle no cancela a las demas: son independientes, y quedarse a mitad
|
|
164
|
+
sin decir cual fallo es peor que terminar y contarlo.
|
|
165
|
+
"""
|
|
166
|
+
elegidos = list(targets) if targets else list(DEFAULT_TARGETS)
|
|
167
|
+
desconocidos = [t for t in elegidos if t not in TARGETS]
|
|
168
|
+
if desconocidos:
|
|
169
|
+
return False, f"categoria desconocida: {', '.join(desconocidos)}"
|
|
170
|
+
|
|
171
|
+
partes, fallo = [], False
|
|
172
|
+
for target in elegidos:
|
|
173
|
+
ok, detalle = _prune_one(target)
|
|
174
|
+
fallo = fallo or not ok
|
|
175
|
+
partes.append(f"{ETIQUETAS[target]}: {detalle}")
|
|
176
|
+
return not fallo, " · ".join(partes)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _prune_one(target: str) -> tuple[bool, str]:
|
|
180
|
+
try:
|
|
181
|
+
done = subprocess.run(
|
|
182
|
+
list(TARGETS[target]),
|
|
183
|
+
capture_output=True,
|
|
184
|
+
text=True,
|
|
185
|
+
errors="replace",
|
|
186
|
+
timeout=PRUNE_TIMEOUT,
|
|
187
|
+
)
|
|
188
|
+
except FileNotFoundError:
|
|
189
|
+
return False, "docker no esta en el PATH"
|
|
190
|
+
except subprocess.TimeoutExpired:
|
|
191
|
+
return False, f"excedio los {PRUNE_TIMEOUT:.0f}s"
|
|
192
|
+
except OSError as exc:
|
|
193
|
+
return False, f"no se pudo ejecutar docker: {exc}"
|
|
194
|
+
|
|
195
|
+
if done.returncode != 0:
|
|
196
|
+
return False, _motivo(done, f"{target} prune")
|
|
197
|
+
|
|
198
|
+
# La ultima linea de docker es el "Total reclaimed space". Lo demas es la
|
|
199
|
+
# lista de lo borrado, que en un resumen de una linea no entra.
|
|
200
|
+
lineas = [line.strip() for line in (done.stdout or "").splitlines() if line.strip()]
|
|
201
|
+
reclamado = next((line for line in reversed(lineas) if "reclaimed" in line.lower()), "")
|
|
202
|
+
return True, reclamado.split(":")[-1].strip() if reclamado else "listo"
|
stackhelx/doctor.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Diagnostico: por que este proyecto no arranca, sin arrancarlo.
|
|
2
|
+
|
|
3
|
+
Vive en su propio modulo y no en `cli.py` porque cruza cuatro modulos y porque
|
|
4
|
+
la interfaz web va a querer los mismos chequeos. `run` devuelve datos; imprimir
|
|
5
|
+
es problema de quien llame.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import config, detect, ports, registry
|
|
16
|
+
|
|
17
|
+
UI_PORT = 7666
|
|
18
|
+
DOCKER_TIMEOUT = 5.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Check:
|
|
23
|
+
"""Un chequeo y su resultado.
|
|
24
|
+
|
|
25
|
+
`fix` es un comando copiable o una accion de una linea. Un chequeo en rojo
|
|
26
|
+
sin `fix` deja al usuario donde estaba: sabiendo que algo anda mal y no que
|
|
27
|
+
hacer. Por eso todo lo que sale `fail` lo lleva.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
level: str # "ok" | "warn" | "fail"
|
|
32
|
+
detail: str
|
|
33
|
+
fix: str = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run(root: Path) -> list[Check]:
|
|
37
|
+
"""Entorno siempre, proyecto si hay algo que revisar."""
|
|
38
|
+
return environment() + project(root)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def blocking(checks: list[Check]) -> bool:
|
|
42
|
+
"""Si algo impide arrancar. Los avisos no cuentan, o el codigo de salida
|
|
43
|
+
daria 1 todo el dia y dejaria de significar algo."""
|
|
44
|
+
return any(c.level == "fail" for c in checks)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# entorno ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def environment() -> list[Check]:
|
|
51
|
+
return [_token(), _ui_port(), *_registered()]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _token() -> Check:
|
|
55
|
+
try:
|
|
56
|
+
registry.token()
|
|
57
|
+
except Exception as exc: # noqa: BLE001 - cualquier fallo aca es el mismo problema
|
|
58
|
+
return Check(
|
|
59
|
+
"token",
|
|
60
|
+
"fail",
|
|
61
|
+
f"no se pudo leer ni generar {registry.HOME / 'token'}: {exc}",
|
|
62
|
+
"revisa los permisos de la carpeta, o defini STACKHELX_TOKEN",
|
|
63
|
+
)
|
|
64
|
+
return Check("token", "ok", str(registry.HOME / "token"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _ui_port() -> Check:
|
|
68
|
+
status = ports.scan(UI_PORT)
|
|
69
|
+
if status.free:
|
|
70
|
+
return Check("puerto de la interfaz", "ok", f"{UI_PORT} libre")
|
|
71
|
+
quien = status.name or "desconocido"
|
|
72
|
+
return Check(
|
|
73
|
+
"puerto de la interfaz",
|
|
74
|
+
"warn",
|
|
75
|
+
f"{UI_PORT} ocupado por {quien} (pid {status.pid})",
|
|
76
|
+
f"si no es StackHelx: stackhelx free {UI_PORT}",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _registered() -> list[Check]:
|
|
81
|
+
known = registry.paths()
|
|
82
|
+
if not known:
|
|
83
|
+
return [
|
|
84
|
+
Check("proyectos registrados", "ok", "ninguno", "para registrar: stackhelx add .")
|
|
85
|
+
]
|
|
86
|
+
perdidos = [p for p in known if not p.is_dir()]
|
|
87
|
+
checks = [Check("proyectos registrados", "ok", f"{len(known)}")]
|
|
88
|
+
for path in perdidos:
|
|
89
|
+
checks.append(
|
|
90
|
+
Check(
|
|
91
|
+
"proyecto sin carpeta",
|
|
92
|
+
"warn",
|
|
93
|
+
str(path),
|
|
94
|
+
f'stackhelx remove "{path}"',
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return checks
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# proyecto -----------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def project(root: Path) -> list[Check]:
|
|
104
|
+
try:
|
|
105
|
+
stack = detect.stack_for(root)
|
|
106
|
+
services = stack.resolve()
|
|
107
|
+
except config.ConfigError as exc:
|
|
108
|
+
# Un stack.yaml roto es una falla; no tener ninguno, corriendo doctor en
|
|
109
|
+
# una carpeta cualquiera, es lo normal recien instalado.
|
|
110
|
+
if _archivo(root) is None:
|
|
111
|
+
return [Check("proyecto", "ok", f"{root} no es un proyecto conocido")]
|
|
112
|
+
return [
|
|
113
|
+
Check(
|
|
114
|
+
"stack",
|
|
115
|
+
"fail",
|
|
116
|
+
str(exc),
|
|
117
|
+
"corregi el archivo (ver stack.example.yaml) o borralo para que se detecte",
|
|
118
|
+
)
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
origen = "detectado" if stack.detected else str(stack.path)
|
|
122
|
+
checks = [Check("stack", "ok", f"{origen} ({len(services)} servicios)")]
|
|
123
|
+
checks += _dotenv(root)
|
|
124
|
+
checks += _executables(services)
|
|
125
|
+
if any(_program(s.command) == "docker" for s in services):
|
|
126
|
+
checks.append(_docker())
|
|
127
|
+
checks += _ports(services)
|
|
128
|
+
checks += _compartidos(stack.root, services)
|
|
129
|
+
return checks
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _compartidos(root: Path, services: list[config.Service]) -> list[Check]:
|
|
133
|
+
"""Otros proyectos registrados que declaran los mismos puertos.
|
|
134
|
+
|
|
135
|
+
Aviso y no falla: dos proyectos con el mismo puerto conviven mientras no
|
|
136
|
+
corran a la vez. Lo que hoy no existe es enterarse antes, en vez de cuando
|
|
137
|
+
el segundo no arranca y el error habla de un puerto ocupado sin decir por
|
|
138
|
+
quien.
|
|
139
|
+
"""
|
|
140
|
+
wanted = {s.port for s in services if s.port}
|
|
141
|
+
if not wanted:
|
|
142
|
+
return []
|
|
143
|
+
try:
|
|
144
|
+
mio = root.resolve()
|
|
145
|
+
except OSError:
|
|
146
|
+
mio = root
|
|
147
|
+
|
|
148
|
+
duenos = registry.declared_ports()
|
|
149
|
+
checks = []
|
|
150
|
+
for port in sorted(wanted):
|
|
151
|
+
otros = [p for p in duenos.get(port, []) if p != mio]
|
|
152
|
+
if not otros:
|
|
153
|
+
continue
|
|
154
|
+
checks.append(
|
|
155
|
+
Check(
|
|
156
|
+
f"puerto {port} compartido",
|
|
157
|
+
"warn",
|
|
158
|
+
f"tambien lo declara {', '.join(str(p) for p in otros)}",
|
|
159
|
+
"arrancalos de a uno, o cambia el port: en uno de los stack.yaml",
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
return checks
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _parse_env_keys(path: Path) -> dict[str, str]:
|
|
166
|
+
"""Claves y valores de un .env. Lo que no entienda queda afuera.
|
|
167
|
+
|
|
168
|
+
ponytail: parte en el primer `=`, saltea vacias y comentarios, y nada mas.
|
|
169
|
+
Sin comillas, escapes, `export` ni valores multilinea. El techo: un archivo
|
|
170
|
+
con un valor de varias lineas lee la segunda como una clave rara y la da
|
|
171
|
+
por faltante. El dia que aparezca uno, hay librerias para esto.
|
|
172
|
+
"""
|
|
173
|
+
result = {}
|
|
174
|
+
try:
|
|
175
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
176
|
+
except OSError:
|
|
177
|
+
return result
|
|
178
|
+
for line in content.splitlines():
|
|
179
|
+
line = line.strip()
|
|
180
|
+
if not line or line.startswith("#"):
|
|
181
|
+
continue
|
|
182
|
+
if "=" in line:
|
|
183
|
+
k, v = line.split("=", 1)
|
|
184
|
+
result[k.strip()] = v.strip()
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
_PLACEHOLDER_SECRETS = {
|
|
189
|
+
"changeme",
|
|
190
|
+
"change_me",
|
|
191
|
+
"your_secret_here",
|
|
192
|
+
"your_api_key",
|
|
193
|
+
"your-secret-here",
|
|
194
|
+
"your-api-key",
|
|
195
|
+
"secret",
|
|
196
|
+
"password",
|
|
197
|
+
"admin",
|
|
198
|
+
"123456",
|
|
199
|
+
"todo",
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _dotenv(root: Path) -> list[Check]:
|
|
204
|
+
examples = [p for p in (root / ".env.example", root / ".env.template") if p.is_file()]
|
|
205
|
+
if not examples:
|
|
206
|
+
return []
|
|
207
|
+
example_path = examples[0]
|
|
208
|
+
ejemplo = example_path.name
|
|
209
|
+
env_path = root / ".env"
|
|
210
|
+
|
|
211
|
+
if not env_path.is_file():
|
|
212
|
+
return [
|
|
213
|
+
Check(
|
|
214
|
+
"variables de entorno",
|
|
215
|
+
"warn",
|
|
216
|
+
f"{ejemplo} existe pero falta .env local",
|
|
217
|
+
f"cp {ejemplo} .env",
|
|
218
|
+
)
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
# Nombres de claves, nunca valores: esto sale por HTTP en /api/doctor.
|
|
222
|
+
declaradas = _parse_env_keys(example_path)
|
|
223
|
+
propias = _parse_env_keys(env_path)
|
|
224
|
+
faltan = [k for k in declaradas if k not in propias]
|
|
225
|
+
vacias = [k for k in declaradas if k in propias and not propias[k]]
|
|
226
|
+
placeholders = [
|
|
227
|
+
k
|
|
228
|
+
for k, v in propias.items()
|
|
229
|
+
if v.strip().strip("'\"").lower() in _PLACEHOLDER_SECRETS
|
|
230
|
+
or v.strip().strip("'\"").lower().startswith(("your_", "your-"))
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
# Las advertencias en `warn` y no en `fail`. En este comando `fail` esta reservado
|
|
234
|
+
# para lo que no puede arrancar y se sabe con certeza: un binario que no
|
|
235
|
+
# esta en el PATH, docker apagado, un stack.yaml invalido. Una clave que
|
|
236
|
+
# falta en el .env es una inferencia: puede ser opcional, o venir del
|
|
237
|
+
# entorno o del compose. Un rojo equivocado gasta el unico rojo que hay.
|
|
238
|
+
checks = []
|
|
239
|
+
if faltan:
|
|
240
|
+
checks.append(
|
|
241
|
+
Check(
|
|
242
|
+
"variables de entorno",
|
|
243
|
+
"warn",
|
|
244
|
+
f"{ejemplo} declara claves que el .env no tiene: {', '.join(faltan)}",
|
|
245
|
+
"agregalas al .env, o sacalas del ejemplo si ya no hacen falta",
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
if vacias:
|
|
249
|
+
checks.append(
|
|
250
|
+
Check(
|
|
251
|
+
"variables de entorno",
|
|
252
|
+
"warn",
|
|
253
|
+
f"sin valor en el .env: {', '.join(vacias)}",
|
|
254
|
+
"completalas, o dejalas asi si el vacio es a proposito",
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
if placeholders:
|
|
258
|
+
checks.append(
|
|
259
|
+
Check(
|
|
260
|
+
"variables de entorno",
|
|
261
|
+
"warn",
|
|
262
|
+
f"valores de ejemplo/inseguros en .env: {', '.join(placeholders)}",
|
|
263
|
+
"reemplaza los placeholders por credenciales reales y seguras",
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
return checks or [Check("variables de entorno", "ok", ".env completo")]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _executables(services: list[config.Service]) -> list[Check]:
|
|
270
|
+
"""El primer token de cada comando tiene que estar en el PATH.
|
|
271
|
+
|
|
272
|
+
Cubre de una sola vez el gestor de paquetes del lockfile, docker, uvicorn y
|
|
273
|
+
lo que sea que el usuario haya escrito en su stack.yaml. Un chequeo por
|
|
274
|
+
tecnologia seria la misma pregunta escrita cuatro veces.
|
|
275
|
+
"""
|
|
276
|
+
vistos: dict[str, list[str]] = {}
|
|
277
|
+
for service in services:
|
|
278
|
+
vistos.setdefault(_program(service.command), []).append(service.name)
|
|
279
|
+
|
|
280
|
+
checks = []
|
|
281
|
+
for programa, duenos in sorted(vistos.items()):
|
|
282
|
+
if not programa:
|
|
283
|
+
continue
|
|
284
|
+
ruta = shutil.which(programa)
|
|
285
|
+
if ruta:
|
|
286
|
+
checks.append(Check(f"comando {programa}", "ok", ruta))
|
|
287
|
+
else:
|
|
288
|
+
checks.append(
|
|
289
|
+
Check(
|
|
290
|
+
f"comando {programa}",
|
|
291
|
+
"fail",
|
|
292
|
+
f"no esta en el PATH, lo pide {', '.join(duenos)}",
|
|
293
|
+
f"instala {programa}, o corregi el comando en stack.yaml",
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
return checks
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _docker() -> Check:
|
|
300
|
+
"""El daemon, no el binario. `docker` instalado con Docker Desktop cerrado
|
|
301
|
+
es el caso que mas veces rompe un arranque, y el error que tira compose es
|
|
302
|
+
una linea de 200 caracteres con un named pipe adentro."""
|
|
303
|
+
try:
|
|
304
|
+
done = subprocess.run(
|
|
305
|
+
["docker", "info", "--format", "{{.ServerVersion}}"],
|
|
306
|
+
capture_output=True,
|
|
307
|
+
text=True,
|
|
308
|
+
errors="replace",
|
|
309
|
+
timeout=DOCKER_TIMEOUT,
|
|
310
|
+
)
|
|
311
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
312
|
+
return Check("daemon de docker", "fail", "no contesto", "abri Docker Desktop")
|
|
313
|
+
if done.returncode != 0:
|
|
314
|
+
return Check("daemon de docker", "fail", "no esta en ejecucion", "abri Docker Desktop")
|
|
315
|
+
return Check("daemon de docker", "ok", f"servidor {done.stdout.strip()}")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _ports(services: list[config.Service]) -> list[Check]:
|
|
319
|
+
"""Solo los puertos declarados. El de un `npm run dev` no se sabe hasta que
|
|
320
|
+
arranca, y adivinarlo seria peor que no decir nada."""
|
|
321
|
+
wanted = [s.port for s in services if s.port]
|
|
322
|
+
if not wanted:
|
|
323
|
+
return [Check("puertos", "ok", "ninguno declarado, se descubren al arrancar")]
|
|
324
|
+
|
|
325
|
+
scanned = ports.scan_many(wanted)
|
|
326
|
+
checks = []
|
|
327
|
+
for service in services:
|
|
328
|
+
if not service.port:
|
|
329
|
+
continue
|
|
330
|
+
status = scanned.get(service.port)
|
|
331
|
+
if status is None or status.free:
|
|
332
|
+
checks.append(Check(f"puerto {service.port}", "ok", f"libre ({service.name})"))
|
|
333
|
+
continue
|
|
334
|
+
quien = status.name or "desconocido"
|
|
335
|
+
checks.append(
|
|
336
|
+
Check(
|
|
337
|
+
f"puerto {service.port}",
|
|
338
|
+
"warn",
|
|
339
|
+
f"ocupado por {quien} (pid {status.pid}), lo pide {service.name}",
|
|
340
|
+
f"stackhelx free {service.port}",
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
return checks
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _archivo(root: Path) -> Path | None:
|
|
347
|
+
try:
|
|
348
|
+
return config.find(root)
|
|
349
|
+
except config.ConfigError:
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _program(command: str) -> str:
|
|
354
|
+
"""Primer token del comando, sin comillas."""
|
|
355
|
+
head = command.strip().split(maxsplit=1)
|
|
356
|
+
return head[0].strip('"\'') if head else ""
|
stackhelx/guardrails.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Validacion de los identificadores que se convierten en nombre de archivo.
|
|
2
|
+
|
|
3
|
+
Aca no hay lista de comandos peligrosos, y es a proposito. Ver la nota en
|
|
4
|
+
`CLAUDE.md`, "Los comandos de stack.yaml no se filtran por contenido".
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
# Longitud máxima permitida para identificadores seguros
|
|
12
|
+
MAX_IDENT_LEN = 64
|
|
13
|
+
|
|
14
|
+
# Identificadores alfanuméricos seguros para IDs de proyecto y servicios
|
|
15
|
+
_IDENT_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
|
|
16
|
+
|
|
17
|
+
# Nombres de dispositivos reservados en Windows (DOS Device Names)
|
|
18
|
+
_WINDOWS_RESERVED = {
|
|
19
|
+
"CON", "PRN", "AUX", "NUL",
|
|
20
|
+
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
21
|
+
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GuardrailError(ValueError):
|
|
26
|
+
"""Identificador rechazado por no ser seguro como nombre de archivo."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_identifier(ident: str, field_name: str = "identificador") -> str:
|
|
30
|
+
"""Valida que un ID de proyecto o servicio sea alfanumérico seguro (sin path traversal ni DOS devices)."""
|
|
31
|
+
clean = str(ident).strip()
|
|
32
|
+
if not clean:
|
|
33
|
+
raise GuardrailError(f"{field_name} no puede estar vacío")
|
|
34
|
+
if len(clean) > MAX_IDENT_LEN:
|
|
35
|
+
raise GuardrailError(f"{field_name} excede la longitud máxima permitida ({MAX_IDENT_LEN} caracteres)")
|
|
36
|
+
if not _IDENT_PATTERN.match(clean):
|
|
37
|
+
raise GuardrailError(
|
|
38
|
+
f"{field_name} inválido ({clean!r}): solo se permiten letras, números, guiones y guiones bajos"
|
|
39
|
+
)
|
|
40
|
+
if clean.upper() in _WINDOWS_RESERVED:
|
|
41
|
+
raise GuardrailError(f"{field_name} inválido ({clean!r}): nombre de dispositivo reservado en Windows")
|
|
42
|
+
return clean
|
stackhelx/history.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import collections
|
|
2
|
+
import json
|
|
3
|
+
import threading
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from . import guardrails, registry
|
|
9
|
+
|
|
10
|
+
MAX_LIMIT = 50
|
|
11
|
+
MAX_ENTRIES = 500
|
|
12
|
+
RETAIN_ENTRIES = 250
|
|
13
|
+
_history_lock = threading.Lock()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _history_file(pid: str) -> Path:
|
|
17
|
+
clean_pid = guardrails.validate_identifier(pid, "pid")
|
|
18
|
+
return (registry.HOME / "history" / f"{clean_pid}.jsonl").resolve()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read_tail_lines(path: Path, limit: int) -> list[str]:
|
|
22
|
+
"""Lee las últimas `limit` líneas de un archivo sin cortes en líneas largas."""
|
|
23
|
+
if limit <= 0:
|
|
24
|
+
return []
|
|
25
|
+
try:
|
|
26
|
+
with path.open("r", encoding="utf-8", errors="replace") as f:
|
|
27
|
+
return [line.rstrip("\r\n") for line in collections.deque(f, maxlen=limit) if line.strip()]
|
|
28
|
+
except OSError:
|
|
29
|
+
return []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _rotate_if_needed(path: Path) -> None:
|
|
33
|
+
"""Si el archivo supera el umbral de tamaño, lo trunca reteniendo solo los últimos RETAIN_ENTRIES."""
|
|
34
|
+
try:
|
|
35
|
+
if not path.is_file() or path.stat().st_size < 100_000:
|
|
36
|
+
return
|
|
37
|
+
last_lines = _read_tail_lines(path, RETAIN_ENTRIES)
|
|
38
|
+
if last_lines:
|
|
39
|
+
tmp_path = path.with_suffix(".tmp")
|
|
40
|
+
with tmp_path.open("w", encoding="utf-8") as f:
|
|
41
|
+
for line_str in last_lines:
|
|
42
|
+
f.write(line_str.strip() + "\n")
|
|
43
|
+
tmp_path.replace(path)
|
|
44
|
+
except (OSError, ValueError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def append(pid: str, data: dict[str, Any]) -> None:
|
|
49
|
+
if "timestamp" not in data:
|
|
50
|
+
data["timestamp"] = datetime.now(timezone.utc).isoformat()
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
path = _history_file(pid)
|
|
54
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
with _history_lock:
|
|
56
|
+
with path.open("a", encoding="utf-8") as f:
|
|
57
|
+
f.write(json.dumps(data) + "\n")
|
|
58
|
+
_rotate_if_needed(path)
|
|
59
|
+
except (OSError, ValueError):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def read(pid: str, limit: int = 5) -> list[dict[str, Any]]:
|
|
64
|
+
limit = max(1, min(limit, MAX_LIMIT))
|
|
65
|
+
try:
|
|
66
|
+
path = _history_file(pid)
|
|
67
|
+
except ValueError:
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
if not path.is_file():
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
records = []
|
|
74
|
+
with _history_lock:
|
|
75
|
+
raw_lines = _read_tail_lines(path, limit)
|
|
76
|
+
|
|
77
|
+
for line in raw_lines:
|
|
78
|
+
if line.strip():
|
|
79
|
+
try:
|
|
80
|
+
record = json.loads(line)
|
|
81
|
+
if isinstance(record, dict):
|
|
82
|
+
records.append(record)
|
|
83
|
+
except json.JSONDecodeError:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
return records
|