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/config.py ADDED
@@ -0,0 +1,448 @@
1
+ """Carga y validacion de stack.yaml.
2
+
3
+ La validacion es manual y explicita a proposito: el esquema tiene ocho campos
4
+ y no justifica una dependencia de schemas. Cuando crezca, se revisa.
5
+
6
+ Todo lo que entra por el archivo se trata como input no confiable: safe_load,
7
+ tipos verificados uno por uno, y `cwd` obligado a quedar dentro de la raiz del
8
+ proyecto.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ import yaml
17
+
18
+ CONFIG_NAMES = ("stack.yaml", "stack.yml")
19
+ READY_KINDS = ("port", "none", "listen")
20
+
21
+
22
+ class ConfigError(Exception):
23
+ """stack.yaml ausente, ilegible o invalido."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Service:
28
+ name: str
29
+ command: str
30
+ cwd: Path
31
+ port: int | None
32
+ ready: str
33
+ needs: tuple[str, ...]
34
+ env: dict[str, str]
35
+ detached: bool
36
+ # Comando de apagado propio, para lo que no muere matando al proceso que lo
37
+ # arranco: un contenedor vive fuera de nuestro arbol.
38
+ stop: str | None = None
39
+ env_file: tuple[Path, ...] = ()
40
+ pre_start: str | None = None
41
+ post_start: str | None = None
42
+ # Adonde lleva "Abrir". Sin esto es la raiz del puerto, que no alcanza para
43
+ # una app que vive en un path o que necesita un token en la query. Puede
44
+ # traer ${VAR}: lo expande runner.service_url, no la carga.
45
+ url: str | None = None
46
+ restart: str = "no"
47
+ max_retries: int = 3
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Stack:
52
+ name: str
53
+ root: Path
54
+ path: Path
55
+ services: dict[str, Service]
56
+ profiles: dict[str, tuple[str, ...]]
57
+ scripts: dict[str, tuple[str, ...]] = field(default_factory=dict)
58
+ detected: bool = False
59
+ # Que arranca sin pedir perfil. None significa "todo", que es lo que un
60
+ # stack.yaml siempre quiso decir. Existe por los `profiles:` de compose, que
61
+ # marcan servicios que quedan afuera salvo que los pidas: sin esto, un stack
62
+ # detectado arrancaria lo que el compose deja apagado a proposito.
63
+ default: tuple[str, ...] | None = None
64
+
65
+ def ports(self) -> list[int]:
66
+ """Puertos declarados, en orden de aparicion y sin repetir."""
67
+ seen = dict.fromkeys(s.port for s in self.services.values() if s.port)
68
+ return list(seen)
69
+
70
+ def resolve(self, profile: str | None = None) -> list[Service]:
71
+ """Servicios del perfil en orden de arranque.
72
+
73
+ Las dependencias transitivas entran aunque el perfil no las liste: pedir
74
+ `api` sin su base de datos nunca es lo que alguien quiso decir.
75
+ """
76
+ if profile is None:
77
+ wanted = list(self.default if self.default is not None else self.services)
78
+ elif profile in self.profiles:
79
+ wanted = list(self.profiles[profile])
80
+ else:
81
+ known = ", ".join(self.profiles) or "ninguno"
82
+ raise ConfigError(f"perfil desconocido: {profile!r}. Definidos: {known}")
83
+ return [self.services[name] for name in _topological(self.services, wanted)]
84
+
85
+
86
+ def _topological(services: dict[str, Service], wanted: list[str]) -> list[str]:
87
+ order: list[str] = []
88
+ done: set[str] = set()
89
+
90
+ def visit(name: str, path: list[str]) -> None:
91
+ if name in done:
92
+ return
93
+ if name in path:
94
+ chain = " -> ".join(path[path.index(name):] + [name])
95
+ raise ConfigError(f"dependencia circular: {chain}")
96
+ for dep in services[name].needs:
97
+ visit(dep, path + [name])
98
+ done.add(name)
99
+ order.append(name)
100
+
101
+ for name in wanted:
102
+ visit(name, [])
103
+ return order
104
+
105
+
106
+ def find(start: Path | None = None) -> Path:
107
+ """Busca stack.yaml desde start hacia arriba. Permite correr desde subdirs."""
108
+ current = (start or Path.cwd()).resolve()
109
+ for directory in (current, *current.parents):
110
+ for filename in CONFIG_NAMES:
111
+ candidate = directory / filename
112
+ if candidate.is_file():
113
+ return candidate
114
+ raise ConfigError(f"no se encontro {CONFIG_NAMES[0]} desde {current}")
115
+
116
+
117
+ def load(path: Path | None = None, _visited: set[Path] | None = None) -> Stack:
118
+ path = (path or find()).resolve()
119
+ root = path.parent
120
+
121
+ if _visited is None:
122
+ _visited = set()
123
+ if path in _visited:
124
+ raise ConfigError(f"ciclo de inclusion detectado en: {path}")
125
+ _visited.add(path)
126
+
127
+ try:
128
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
129
+ except (OSError, yaml.YAMLError) as exc:
130
+ raise ConfigError(f"no se pudo leer {path}: {exc}") from exc
131
+
132
+ if not isinstance(raw, dict):
133
+ raise ConfigError(f"{path}: la raiz debe ser un mapa")
134
+
135
+ services_raw = raw.get("services") or {}
136
+ if not isinstance(services_raw, dict):
137
+ raise ConfigError(f"{path}: 'services' debe ser un mapa")
138
+
139
+ services = {
140
+ name: _service(name, spec, root)
141
+ for name, spec in services_raw.items()
142
+ }
143
+
144
+ includes_raw = raw.get("includes")
145
+ if includes_raw is not None:
146
+ if isinstance(includes_raw, str):
147
+ includes_list = [includes_raw]
148
+ elif isinstance(includes_raw, list) and all(isinstance(i, str) for i in includes_raw):
149
+ includes_list = includes_raw
150
+ else:
151
+ raise ConfigError(f"{path}: 'includes' debe ser una ruta o lista de rutas")
152
+
153
+ for inc_item in includes_list:
154
+ inc_path = (root / inc_item).resolve()
155
+ if inc_path.is_dir():
156
+ target_file = None
157
+ for cname in CONFIG_NAMES:
158
+ if (inc_path / cname).is_file():
159
+ target_file = inc_path / cname
160
+ break
161
+ if target_file is None:
162
+ raise ConfigError(f"no se encontro stack.yaml en la ruta incluida: {inc_path}")
163
+ inc_stack = load(target_file, _visited=set(_visited))
164
+ elif inc_path.is_file():
165
+ inc_stack = load(inc_path, _visited=set(_visited))
166
+ else:
167
+ raise ConfigError(f"ruta de inclusion no encontrada: {inc_path}")
168
+
169
+ for s_name, s_svc in inc_stack.services.items():
170
+ if s_name in services:
171
+ raise ConfigError(
172
+ f"conflicto de servicio: '{s_name}' ya esta declarado y no puede ser importado desde '{inc_path}'"
173
+ )
174
+ services[s_name] = s_svc
175
+
176
+ if not services:
177
+ raise ConfigError(f"{path}: falta la seccion 'services' o esta vacia")
178
+
179
+ for service in services.values():
180
+ for dep in service.needs:
181
+ if dep not in services:
182
+ raise ConfigError(f"'{service.name}.needs' apunta a '{dep}', que no existe")
183
+
184
+ profiles = _profiles(raw.get("profiles"), services)
185
+ scripts = _scripts(raw.get("scripts"))
186
+
187
+ stack = Stack(
188
+ name=str(raw.get("name") or root.name),
189
+ root=root,
190
+ path=path,
191
+ services=services,
192
+ profiles=profiles,
193
+ scripts=scripts,
194
+ default=_default(raw.get("default"), services),
195
+ )
196
+ stack.resolve() # falla al cargar si hay ciclos, no en tiempo de arranque
197
+ return stack
198
+
199
+
200
+ def _service(name: str, spec: object, root: Path) -> Service:
201
+ where = f"services.{name}"
202
+ if not isinstance(spec, dict):
203
+ raise ConfigError(f"{where} debe ser un mapa")
204
+
205
+ unknown = set(spec) - {
206
+ "command", "cwd", "port", "ready", "needs", "env", "detached", "stop",
207
+ "env_file", "pre_start", "post_start", "url", "restart", "max_retries",
208
+ }
209
+ if unknown:
210
+ raise ConfigError(f"{where}: campos desconocidos: {', '.join(sorted(unknown))}")
211
+
212
+ command = spec.get("command")
213
+ if not isinstance(command, str) or not command.strip():
214
+ raise ConfigError(f"{where}.command es obligatorio y debe ser texto")
215
+
216
+ port = spec.get("port")
217
+ if port is not None:
218
+ if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535:
219
+ raise ConfigError(f"{where}.port debe ser un entero entre 1 y 65535")
220
+
221
+ ready = spec.get("ready", "port" if port else "none")
222
+ if not isinstance(ready, str) or not _valid_ready(ready):
223
+ raise ConfigError(
224
+ f"{where}.ready debe ser 'port', 'listen', 'none', 'log:<texto>' o una URL http"
225
+ )
226
+ if ready == "port" and port is None:
227
+ raise ConfigError(f"{where}.ready es 'port' pero no hay 'port' declarado")
228
+ if ready == "listen" and port is not None:
229
+ raise ConfigError(
230
+ f"{where}.ready es 'listen' pero el puerto esta declarado: usa 'port'"
231
+ )
232
+
233
+ needs = spec.get("needs", [])
234
+ if not isinstance(needs, list) or not all(isinstance(n, str) for n in needs):
235
+ raise ConfigError(f"{where}.needs debe ser una lista de nombres")
236
+ if name in needs:
237
+ raise ConfigError(f"{where}.needs se incluye a si mismo")
238
+
239
+ detached = spec.get("detached", False)
240
+ if not isinstance(detached, bool):
241
+ raise ConfigError(f"{where}.detached debe ser true o false")
242
+
243
+ stop = spec.get("stop")
244
+ if stop is not None and (not isinstance(stop, str) or not stop.strip()):
245
+ raise ConfigError(f"{where}.stop debe ser texto no vacio")
246
+
247
+ pre_start = spec.get("pre_start")
248
+ if pre_start is not None and (not isinstance(pre_start, str) or not pre_start.strip()):
249
+ raise ConfigError(f"{where}.pre_start debe ser texto no vacio")
250
+
251
+ post_start = spec.get("post_start")
252
+ if post_start is not None and (not isinstance(post_start, str) or not post_start.strip()):
253
+ raise ConfigError(f"{where}.post_start debe ser texto no vacio")
254
+
255
+ url = spec.get("url")
256
+ if url is not None:
257
+ if not isinstance(url, str) or not url.strip():
258
+ raise ConfigError(f"{where}.url debe ser texto no vacio")
259
+ url = url.strip()
260
+ if not url.startswith(("http://", "https://")):
261
+ raise ConfigError(
262
+ f"{where}.url debe empezar con http:// o https://, y es {url!r}"
263
+ )
264
+
265
+ restart = spec.get("restart", "no")
266
+ if not isinstance(restart, str) or restart not in ("no", "on-failure", "always"):
267
+ raise ConfigError(f"{where}.restart debe ser 'no', 'on-failure' o 'always'")
268
+
269
+ max_retries = spec.get("max_retries", 3)
270
+ if not isinstance(max_retries, int) or isinstance(max_retries, bool) or max_retries < 0:
271
+ raise ConfigError(f"{where}.max_retries debe ser un entero positivo o 0")
272
+
273
+ return Service(
274
+ name=name,
275
+ command=command,
276
+ cwd=_cwd(where, spec.get("cwd", "."), root),
277
+ port=port,
278
+ ready=ready,
279
+ needs=tuple(needs),
280
+ env=_env(where, spec.get("env")),
281
+ detached=detached,
282
+ stop=stop,
283
+ env_file=_env_files(where, spec.get("env_file"), root),
284
+ pre_start=pre_start,
285
+ post_start=post_start,
286
+ url=url,
287
+ restart=restart,
288
+ max_retries=max_retries,
289
+ )
290
+
291
+
292
+ def _valid_ready(ready: str) -> bool:
293
+ if ready in READY_KINDS:
294
+ return True
295
+ if ready.startswith("log:") and len(ready) > 4:
296
+ return True
297
+ return ready.startswith(("http://", "https://"))
298
+
299
+
300
+ def _cwd(where: str, value: object, root: Path) -> Path:
301
+ if not isinstance(value, str):
302
+ raise ConfigError(f"{where}.cwd debe ser una ruta relativa")
303
+ candidate = Path(value)
304
+ if candidate.is_absolute():
305
+ raise ConfigError(f"{where}.cwd debe ser relativa a la raiz del proyecto")
306
+
307
+ resolved = (root / candidate).resolve()
308
+ if not resolved.is_relative_to(root):
309
+ raise ConfigError(f"{where}.cwd sale de la raiz del proyecto: {value!r}")
310
+ if not resolved.is_dir():
311
+ raise ConfigError(f"{where}.cwd no existe: {value!r}")
312
+ return resolved
313
+
314
+
315
+ def _env(where: str, value: object) -> dict[str, str]:
316
+ if value is None:
317
+ return {}
318
+ if not isinstance(value, dict):
319
+ raise ConfigError(f"{where}.env debe ser un mapa")
320
+ env = {}
321
+ for key, item in value.items():
322
+ if isinstance(item, (dict, list)) or item is None:
323
+ raise ConfigError(f"{where}.env.{key} debe ser un valor simple")
324
+ env[str(key)] = str(item)
325
+ return env
326
+
327
+
328
+ def _default(value: object, services: dict[str, Service]) -> tuple[str, ...] | None:
329
+ """Servicios que arrancan sin pedir perfil. Ausente significa todos.
330
+
331
+ Existe para que `stackhelx init` pueda congelar un compose con `profiles:`
332
+ sin cambiar lo que arranca: en compose esos contenedores quedan afuera hasta
333
+ que los pedis, y sin esta clave el archivo congelado los prenderia a todos.
334
+ """
335
+ if value is None:
336
+ return None
337
+ if not isinstance(value, list) or not value:
338
+ raise ConfigError("'default' debe ser una lista no vacia de servicios")
339
+ for member in value:
340
+ if member not in services:
341
+ raise ConfigError(f"'default' incluye '{member}', que no existe")
342
+ return tuple(str(m) for m in value)
343
+
344
+
345
+ def _profiles(value: object, services: dict[str, Service]) -> dict[str, tuple[str, ...]]:
346
+ if value is None:
347
+ return {}
348
+ if not isinstance(value, dict):
349
+ raise ConfigError("'profiles' debe ser un mapa de nombre a lista de servicios")
350
+
351
+ profiles = {}
352
+ for name, members in value.items():
353
+ if not isinstance(members, list) or not members:
354
+ raise ConfigError(f"profiles.{name} debe ser una lista no vacia")
355
+ for member in members:
356
+ if member not in services:
357
+ raise ConfigError(f"profiles.{name} incluye '{member}', que no existe")
358
+ profiles[str(name)] = tuple(members)
359
+ return profiles
360
+
361
+
362
+ def _env_files(where: str, value: object, root: Path) -> tuple[Path, ...]:
363
+ if value is None:
364
+ return ()
365
+ raw_list: list[object]
366
+ if isinstance(value, str):
367
+ raw_list = [value]
368
+ elif isinstance(value, list):
369
+ raw_list = value
370
+ else:
371
+ raise ConfigError(f"{where}.env_file debe ser una ruta o lista de rutas")
372
+
373
+ paths: list[Path] = []
374
+ for item in raw_list:
375
+ if not isinstance(item, str) or not item.strip():
376
+ raise ConfigError(f"{where}.env_file debe contener rutas relativas de texto no vacias")
377
+ candidate = Path(item)
378
+ if candidate.is_absolute():
379
+ raise ConfigError(f"{where}.env_file debe ser relativa a la raiz del proyecto")
380
+ resolved = (root / candidate).resolve()
381
+ if not resolved.is_relative_to(root):
382
+ raise ConfigError(f"{where}.env_file sale de la raiz del proyecto: {item!r}")
383
+ paths.append(resolved)
384
+ return tuple(paths)
385
+
386
+
387
+ def parse_env_file(path: Path) -> dict[str, str]:
388
+ """Lee un archivo .env simple sin dependencias externas."""
389
+ if not path.is_file():
390
+ return {}
391
+ env: dict[str, str] = {}
392
+ try:
393
+ content = path.read_text(encoding="utf-8-sig")
394
+ except (OSError, UnicodeDecodeError):
395
+ # UnicodeDecodeError no es un OSError: un .env guardado en latin-1, que
396
+ # es lo que deja cualquier editor viejo en Windows, tumbaba el arranque
397
+ # entero del stack en vez de quedarse sin esas variables.
398
+ return {}
399
+
400
+ for raw_line in content.splitlines():
401
+ line = raw_line.strip()
402
+ if not line or line.startswith("#"):
403
+ continue
404
+ if line.startswith("export "):
405
+ line = line[7:].lstrip()
406
+ if "=" not in line:
407
+ continue
408
+ key, val = line.split("=", 1)
409
+ key = key.strip()
410
+ val = val.strip()
411
+ if not key:
412
+ continue
413
+ # El valor entrecomillado se corta en su comilla de cierre, no en el
414
+ # final de la linea: exigir que TERMINE en comilla fallaba en cuanto
415
+ # habia un comentario detras, y `TOKEN="abc" # el de prod` entregaba el
416
+ # token con las comillas pegadas. Y como lo de adentro se toma tal cual,
417
+ # un `#` dentro de las comillas sigue siendo parte del valor.
418
+ if len(val) >= 2 and val[0] in "\"'" and val.find(val[0], 1) != -1:
419
+ val = val[1 : val.find(val[0], 1)]
420
+ elif " #" in val:
421
+ val = val.split(" #", 1)[0].rstrip()
422
+ env[key] = val
423
+ return env
424
+
425
+
426
+ def _scripts(value: object) -> dict[str, tuple[str, ...]]:
427
+ if value is None:
428
+ return {}
429
+ if not isinstance(value, dict):
430
+ raise ConfigError("'scripts' debe ser un mapa de nombre a comando o lista de comandos")
431
+
432
+ scripts = {}
433
+ for name, cmd in value.items():
434
+ if not isinstance(name, str) or not name.strip():
435
+ raise ConfigError("el nombre del script debe ser texto no vacio")
436
+ if isinstance(cmd, str):
437
+ if not cmd.strip():
438
+ raise ConfigError(f"scripts.{name} no puede estar vacio")
439
+ scripts[str(name)] = (cmd.strip(),)
440
+ elif isinstance(cmd, list):
441
+ if not cmd or not all(isinstance(c, str) and c.strip() for c in cmd):
442
+ raise ConfigError(f"scripts.{name} debe ser una lista de comandos de texto no vacios")
443
+ scripts[str(name)] = tuple(str(c).strip() for c in cmd)
444
+ else:
445
+ raise ConfigError(f"scripts.{name} debe ser un texto o lista de comandos")
446
+ return scripts
447
+
448
+