paramsx 2.4.0__tar.gz → 2.5.0__tar.gz
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.
- {paramsx-2.4.0 → paramsx-2.5.0}/CHANGELOG.md +19 -0
- {paramsx-2.4.0/paramsx.egg-info → paramsx-2.5.0}/PKG-INFO +3 -4
- {paramsx-2.4.0 → paramsx-2.5.0}/README.md +1 -2
- paramsx-2.5.0/paramsx/__init__.py +1 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/main.py +4 -4
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/web/__init__.py +1 -1
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/web/app.py +11 -1
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/web/ssm.py +24 -3
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/web/static/app.css +28 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/web/static/paramsx.js +109 -4
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/web/templates/index.html +1 -0
- {paramsx-2.4.0 → paramsx-2.5.0/paramsx.egg-info}/PKG-INFO +3 -4
- {paramsx-2.4.0 → paramsx-2.5.0}/setup.py +3 -2
- {paramsx-2.4.0 → paramsx-2.5.0}/tests/test_web.py +114 -0
- paramsx-2.4.0/paramsx/__init__.py +0 -1
- {paramsx-2.4.0 → paramsx-2.5.0}/LICENSE +0 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/MANIFEST.in +0 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/core/__init__.py +0 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/core/config.py +0 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/core/rutas.py +0 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/funcions.py +0 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/paramsx_config.py +0 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/text.py +0 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/web/static/ajustes.js +0 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/web/static/comun.js +0 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx.egg-info/SOURCES.txt +0 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx.egg-info/dependency_links.txt +0 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx.egg-info/entry_points.txt +0 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx.egg-info/requires.txt +1 -1
- {paramsx-2.4.0 → paramsx-2.5.0}/paramsx.egg-info/top_level.txt +0 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/setup.cfg +0 -0
- {paramsx-2.4.0 → paramsx-2.5.0}/tests/test_core.py +0 -0
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.5.0
|
|
4
|
+
|
|
5
|
+
### Añadido
|
|
6
|
+
|
|
7
|
+
- **Reemplazar** en la web (botón «Reemplazar…» de la barra): buscar y reemplazar en valores, nombres, etiquetas o descripciones de los parámetros que se ven (con el filtro), con vista previa y una casilla por cambio. Solo cambia el editor: después se revisa y se aplica como siempre. Renombrar es crear uno nuevo con lo mismo y borrar el viejo, porque SSM no renombra.
|
|
8
|
+
- Los parámetros nuevos de la web traen la etiqueta `ManagedBy=paramsx`.
|
|
9
|
+
|
|
10
|
+
### Corregido
|
|
11
|
+
|
|
12
|
+
- **Lo que falla al aplicar ya no se pierde**: tras aplicar se recarga de AWS, pero lo que no se aplicó (error o no válido en la revisión) vuelve a ponerse en el editor. Si era un conflicto de versión, la siguiente revisión lo sigue marcando.
|
|
13
|
+
- Un parámetro nuevo cuyo nombre acaba en `/` ya no da error en AWS: se quitan las barras finales.
|
|
14
|
+
- **Renombrar nunca pierde el parámetro**: si el nuevo nombre no se puede crear (ya existe, sin permiso, error de AWS), el viejo no se borra. El nuevo conserva la clave KMS propia del viejo.
|
|
15
|
+
|
|
16
|
+
## 2.4.1
|
|
17
|
+
|
|
18
|
+
### Cambiado
|
|
19
|
+
|
|
20
|
+
- **La versión web viene incluida**: Flask pasa a ser una dependencia normal, así que basta con `pip install paramsx` (o `pipx install paramsx`) para tener las dos versiones. `paramsx[web]` se sigue aceptando, pero ya no añade nada.
|
|
21
|
+
|
|
3
22
|
## 2.4.0
|
|
4
23
|
|
|
5
24
|
### Añadido
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: paramsx
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: Librería para gestionar y respaldar parámetros de AWS SSM de manera sencilla y eficiente.
|
|
5
5
|
Home-page: https://github.com/pistatxos/paramsx
|
|
6
6
|
Author: Mariox
|
|
@@ -9,8 +9,8 @@ Description-Content-Type: text/markdown
|
|
|
9
9
|
License-File: LICENSE
|
|
10
10
|
Requires-Dist: boto3
|
|
11
11
|
Requires-Dist: windows-curses; platform_system == "Windows"
|
|
12
|
+
Requires-Dist: flask>=2.2
|
|
12
13
|
Provides-Extra: web
|
|
13
|
-
Requires-Dist: flask>=2.2; extra == "web"
|
|
14
14
|
Dynamic: author
|
|
15
15
|
Dynamic: author-email
|
|
16
16
|
Dynamic: description
|
|
@@ -68,7 +68,6 @@ Al arrancar pregunta **1. Web** o **2. Terminal** (o directo: `paramsx --web` /
|
|
|
68
68
|
## Versión web
|
|
69
69
|
|
|
70
70
|
```bash
|
|
71
|
-
pip install 'paramsx[web]' # añade Flask
|
|
72
71
|
paramsx --web
|
|
73
72
|
```
|
|
74
73
|
|
|
@@ -973,7 +972,7 @@ paramsx --version
|
|
|
973
972
|
| Comando | Descripción |
|
|
974
973
|
|---|---|
|
|
975
974
|
| `paramsx` | Pregunta: web o terminal. |
|
|
976
|
-
| `paramsx --web` | Abre la versión web
|
|
975
|
+
| `paramsx --web` | Abre la versión web en el navegador. |
|
|
977
976
|
| `paramsx --tui` | Abre el menú de terminal. |
|
|
978
977
|
| `paramsx configure` | Crea o comprueba la configuración. |
|
|
979
978
|
| `paramsx configure --ejemplo` | Genera la plantilla completa de configuración. |
|
|
@@ -45,7 +45,6 @@ Al arrancar pregunta **1. Web** o **2. Terminal** (o directo: `paramsx --web` /
|
|
|
45
45
|
## Versión web
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
|
-
pip install 'paramsx[web]' # añade Flask
|
|
49
48
|
paramsx --web
|
|
50
49
|
```
|
|
51
50
|
|
|
@@ -950,7 +949,7 @@ paramsx --version
|
|
|
950
949
|
| Comando | Descripción |
|
|
951
950
|
|---|---|
|
|
952
951
|
| `paramsx` | Pregunta: web o terminal. |
|
|
953
|
-
| `paramsx --web` | Abre la versión web
|
|
952
|
+
| `paramsx --web` | Abre la versión web en el navegador. |
|
|
954
953
|
| `paramsx --tui` | Abre el menú de terminal. |
|
|
955
954
|
| `paramsx configure` | Crea o comprueba la configuración. |
|
|
956
955
|
| `paramsx configure --ejemplo` | Genera la plantilla completa de configuración. |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.5.0"
|
|
@@ -783,7 +783,7 @@ ParamsX __VERSION__ - Gestión de Parámetros de AWS SSM
|
|
|
783
783
|
|
|
784
784
|
Comandos disponibles:
|
|
785
785
|
paramsx Pregunta si quieres la versión web o la de terminal.
|
|
786
|
-
paramsx --web Abre la versión web
|
|
786
|
+
paramsx --web Abre la versión web en el navegador.
|
|
787
787
|
paramsx --tui Abre la versión de terminal (requiere configuración previa).
|
|
788
788
|
paramsx configure Crea ~/.xsoft/paramsx_config.py la primera vez. Si ya lo tienes,
|
|
789
789
|
NO lo toca: revisa qué opciones nuevas te faltan y si es válido.
|
|
@@ -832,7 +832,7 @@ Si necesitas más ayuda puedes leer el readme en GitHub o en Pypi:
|
|
|
832
832
|
"""
|
|
833
833
|
print(help_text.replace("__VERSION__", version_actual()))
|
|
834
834
|
|
|
835
|
-
MENSAJE_SIN_FLASK = "La versión web necesita Flask: pip install
|
|
835
|
+
MENSAJE_SIN_FLASK = "La versión web necesita Flask y no está instalado: reinstala paramsx o haz pip install flask"
|
|
836
836
|
|
|
837
837
|
|
|
838
838
|
# Menú de arranque: web o terminal. Devuelve "web", "tui" o None (salir).
|
|
@@ -959,7 +959,7 @@ def _tarjetas(hay_web, defecto):
|
|
|
959
959
|
"│ ▤▤▤▤ ▤▤▤▤▤▤ ▤▤▤ │",
|
|
960
960
|
"│ ▤▤▤▤▤▤▤ ▤▤▤▤ ▤▤ │",
|
|
961
961
|
"└──────────────────────┘",
|
|
962
|
-
"En el navegador" if hay_web else "pip install
|
|
962
|
+
"En el navegador" if hay_web else "Falta Flask: pip install flask",
|
|
963
963
|
]
|
|
964
964
|
tui = [
|
|
965
965
|
"2 · TERMINAL",
|
|
@@ -996,7 +996,7 @@ def portada(hay_web, defecto):
|
|
|
996
996
|
columnas = shutil.get_terminal_size((80, 24)).columns
|
|
997
997
|
if columnas < 70:
|
|
998
998
|
lineas = [f"ParamsX {__version__} · {FOOTER_TEXT}",
|
|
999
|
-
" 1. Web " + ("(en el navegador)" if hay_web else "(pip install
|
|
999
|
+
" 1. Web " + ("(en el navegador)" if hay_web else "(falta Flask: pip install flask)"),
|
|
1000
1000
|
" 2. Terminal (el menú de siempre)"]
|
|
1001
1001
|
return "\n".join(lineas)
|
|
1002
1002
|
|
|
@@ -39,6 +39,12 @@ class ErrorWeb(Exception):
|
|
|
39
39
|
self.mensaje, self.status, self.extra = mensaje, status, extra
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def sin_barra_final(nombre):
|
|
43
|
+
"""«/dev/app/x/» → «/dev/app/x». Si no queda nada más que barras, se deja (lo rechaza la revisión)."""
|
|
44
|
+
nombre = nombre.strip()
|
|
45
|
+
return nombre.rstrip("/") or nombre
|
|
46
|
+
|
|
47
|
+
|
|
42
48
|
def _sesion_boto3(perfil, region):
|
|
43
49
|
return boto3.Session(profile_name=perfil or None, region_name=region or None)
|
|
44
50
|
|
|
@@ -245,7 +251,11 @@ def crear_app(config_path=None, token=None, sesion_factory=None, perfiles_aws=No
|
|
|
245
251
|
items = datos.get("items")
|
|
246
252
|
if not isinstance(items, list) or not items or len(items) > MAX_ITEMS:
|
|
247
253
|
raise ErrorWeb("No hay cambios que revisar.")
|
|
248
|
-
|
|
254
|
+
items = [it for it in items if isinstance(it, dict)]
|
|
255
|
+
for it in items: # un parámetro no puede acabar en «/»: en los nuevos se quita en vez de dar error en AWS
|
|
256
|
+
if it.get("accion") == "nuevo" and isinstance(it.get("name"), str):
|
|
257
|
+
it["name"] = sin_barra_final(it["name"])
|
|
258
|
+
return items
|
|
249
259
|
|
|
250
260
|
# ------------------------------------------------------------ páginas
|
|
251
261
|
|
|
@@ -305,8 +305,10 @@ def validar_item(it, cfg):
|
|
|
305
305
|
def _revisar(ssm, items, cfg):
|
|
306
306
|
"""El plan y lo leído de AWS para hacerlo: (filas, meta). Se lee AWS en este momento."""
|
|
307
307
|
nombres = sorted({it["name"] for it in items if isinstance(it, dict) and it.get("name")})
|
|
308
|
+
# De los renombrados también se lee el viejo (su clave KMS pasa al nuevo), aunque no se borre
|
|
309
|
+
viejos = {it["de"] for it in items if it.get("accion") == "nuevo" and isinstance(it.get("de"), str) and it["de"]}
|
|
308
310
|
try:
|
|
309
|
-
meta = describir(ssm, "", nombres) if nombres else {}
|
|
311
|
+
meta = describir(ssm, "", sorted(set(nombres) | viejos)) if nombres else {}
|
|
310
312
|
except ClientError as exc:
|
|
311
313
|
if not denegado(exc):
|
|
312
314
|
raise ParametrosError(motivo(exc)) from None
|
|
@@ -352,6 +354,13 @@ def _revisar(ssm, items, cfg):
|
|
|
352
354
|
else:
|
|
353
355
|
fila["errores"].append("Acción no válida.")
|
|
354
356
|
filas.append(fila)
|
|
357
|
+
|
|
358
|
+
# Renombrar es crear el nuevo y borrar el viejo: si el nuevo no se puede crear, el viejo no se borra (se perdería)
|
|
359
|
+
borrados = {f["name"]: f for f in filas if f["accion"] == "borrar"}
|
|
360
|
+
for it, fila in zip(items, filas):
|
|
361
|
+
viejo = borrados.get(it.get("de") or "") if it.get("accion") == "nuevo" else None
|
|
362
|
+
if viejo and fila["errores"] and not viejo["errores"]:
|
|
363
|
+
viejo["errores"].append(f"No se borra: su nuevo nombre ({fila['name']}) no se puede crear.")
|
|
355
364
|
return filas, meta or {}
|
|
356
365
|
|
|
357
366
|
|
|
@@ -401,7 +410,7 @@ def aplicar(ssm, items, cfg):
|
|
|
401
410
|
[{name, accion, ok, error, version, cambios}] sin valores."""
|
|
402
411
|
filas, meta = _revisar(ssm, items, cfg)
|
|
403
412
|
revisado = {f["name"]: f for f in filas}
|
|
404
|
-
resultados, borrar = [], []
|
|
413
|
+
resultados, borrar, sin_sustituto = [], [], set()
|
|
405
414
|
|
|
406
415
|
for it in items:
|
|
407
416
|
nombre, accion = it.get("name", ""), it.get("accion")
|
|
@@ -416,12 +425,21 @@ def aplicar(ssm, items, cfg):
|
|
|
416
425
|
borrar.append(res)
|
|
417
426
|
continue
|
|
418
427
|
try:
|
|
419
|
-
|
|
428
|
+
m = meta.get(it.get("de") or "", {}) if accion == "nuevo" else meta.get(nombre, {})
|
|
429
|
+
res["version"] = _aplicar_uno(ssm, it, fila, m, accion)
|
|
420
430
|
res["ok"] = True
|
|
421
431
|
except (ClientError, BotoCoreError) as exc:
|
|
422
432
|
res["error"] = motivo(exc)
|
|
433
|
+
if accion == "nuevo" and it.get("de"):
|
|
434
|
+
sin_sustituto.add(it["de"])
|
|
423
435
|
resultados.append(res)
|
|
424
436
|
|
|
437
|
+
# Su nuevo nombre no se pudo crear: el viejo se queda
|
|
438
|
+
for b in [b for b in borrar if b["name"] in sin_sustituto]:
|
|
439
|
+
b["error"] = "No se borra: su nuevo nombre no se pudo crear."
|
|
440
|
+
borrar.remove(b)
|
|
441
|
+
resultados.append(b)
|
|
442
|
+
|
|
425
443
|
# Borrados de 10 en 10 (el máximo de DeleteParameters)
|
|
426
444
|
for i in range(0, len(borrar), 10):
|
|
427
445
|
lote = borrar[i:i + 10]
|
|
@@ -452,6 +470,9 @@ def _aplicar_uno(ssm, it, fila, m, accion):
|
|
|
452
470
|
kw["Tags"] = tags
|
|
453
471
|
if grande:
|
|
454
472
|
kw["Tier"] = "Advanced"
|
|
473
|
+
# Renombrado (m es el viejo): se conserva su clave KMS propia en vez de la de por defecto
|
|
474
|
+
if it["type"] == "SecureString" and m.get("key_id") and m["key_id"] not in KMS_DEFECTO:
|
|
475
|
+
kw["KeyId"] = m["key_id"]
|
|
455
476
|
return _put(ssm, **kw).get("Version")
|
|
456
477
|
|
|
457
478
|
version = None
|
|
@@ -260,3 +260,31 @@ details.rama[open] > summary.exp-fila::before { transform: rotate(90deg); }
|
|
|
260
260
|
.barra-acciones { margin-left: 0; }
|
|
261
261
|
.tabla { table-layout: auto; }
|
|
262
262
|
}
|
|
263
|
+
|
|
264
|
+
/* ---------------------------------------------------------------- reemplazar */
|
|
265
|
+
.reemplazar { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 12px 0; }
|
|
266
|
+
.reemplazar input { font-family: var(--mono); }
|
|
267
|
+
.reemplazar-donde { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-bottom: 10px; }
|
|
268
|
+
.reemplazar-et { font-size: 12px; font-weight: 700; color: var(--suave); }
|
|
269
|
+
.pildoras { display: inline-flex; border: 1px solid var(--campo); border-radius: 9px; overflow: hidden; }
|
|
270
|
+
.pildora { position: relative; display: inline-flex; cursor: pointer; margin: 0; }
|
|
271
|
+
.pildora input { position: absolute; opacity: 0; pointer-events: none; }
|
|
272
|
+
.pildora span { padding: 7px 13px; font-size: 13px; font-weight: 600; color: var(--suave); background: #fff; border-left: 1px solid var(--borde); user-select: none; }
|
|
273
|
+
.pildoras .pildora:first-child span { border-left: 0; }
|
|
274
|
+
.pildora input:checked + span { background: var(--tinta); color: #fff; }
|
|
275
|
+
.pildora input:focus-visible + span { outline: 2px solid #9cc0f5; outline-offset: -2px; }
|
|
276
|
+
.pildora.sola span { border: 1px solid var(--campo); border-radius: 9px; }
|
|
277
|
+
.modal .reemplazar-aviso { margin: 6px 0 10px; font-size: 12.5px; color: var(--ambar); }
|
|
278
|
+
.reemplazar-lista { max-height: 45vh; overflow: auto; border: 1px solid var(--borde); border-radius: 10px; padding: 6px 10px; background: #fbfcfd; margin-bottom: 4px; }
|
|
279
|
+
.modal .reemplazar-cuenta { margin: 0 0 8px; font-size: 12.5px; }
|
|
280
|
+
.reemplazar-tabla { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
|
281
|
+
.reemplazar-tabla th { text-align: left; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--suave); padding: 6px 8px; border-bottom: 1px solid var(--borde); position: sticky; top: 0; background: #fbfcfd; }
|
|
282
|
+
.reemplazar-tabla td { padding: 8px; border-bottom: 1px solid var(--borde-suave); vertical-align: top; }
|
|
283
|
+
.reemplazar-tabla tr:last-child td { border-bottom: 0; }
|
|
284
|
+
.reemplazar-tabla th:first-child, .reemplazar-tabla td:first-child { width: 28px; text-align: center; }
|
|
285
|
+
.reemplazar-tabla td:nth-child(2), .reemplazar-tabla td:nth-child(3) { white-space: nowrap; }
|
|
286
|
+
.reemplazar-tabla td:nth-child(3) small { display: block; color: var(--tenue); margin-top: 3px; }
|
|
287
|
+
.reemplazar-antes, .reemplazar-despues { display: block; font-family: var(--mono); font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; padding: 2px 6px; border-radius: 5px; }
|
|
288
|
+
.reemplazar-antes { background: var(--rojo-fondo); color: var(--rojo); text-decoration: line-through; margin-bottom: 3px; }
|
|
289
|
+
.reemplazar-despues { background: var(--ok-fondo); color: var(--ok); }
|
|
290
|
+
@media (max-width: 640px) { .reemplazar { grid-template-columns: 1fr; } }
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
const obligatorias = () => cfg().tags_activas ? (cfg().tags_obligatorias || []) : [];
|
|
12
12
|
const entornoDe = seg => cfg().entornos.find(e => e === String(seg).toLowerCase());
|
|
13
13
|
const claseEntorno = e => !e ? '' : /^prod/.test(e) ? 'prod' : /^(stg|pre|staging|qa|test|uat)/.test(e) ? 'stg' : 'dev';
|
|
14
|
+
// Un parámetro no puede acabar en «/»: se quitan las barras finales si queda algo más (el servidor hace lo mismo)
|
|
15
|
+
const sinBarraFinal = n => { const t = String(n || '').trim(); return t.replace(/\/+$/, '') || t; };
|
|
16
|
+
const nombreEnvio = p => p.nuevo ? sinBarraFinal(p.name) : p.name.trim();
|
|
14
17
|
|
|
15
18
|
// ------------------------------------------------ perfil, región y grupos
|
|
16
19
|
async function pintarDonde() {
|
|
@@ -272,6 +275,7 @@
|
|
|
272
275
|
if (e.target.dataset.campo !== 'name') return;
|
|
273
276
|
const input = e.target, p = S.items[+input.closest('.param').dataset.i];
|
|
274
277
|
const g = grupoSel();
|
|
278
|
+
p.name = sinBarraFinal(p.name); input.value = p.name;
|
|
275
279
|
try {
|
|
276
280
|
const r = await post('/api/nombre-nuevo', { base: S.ruta, nombre: p.name.trim(), perfil: g ? g.perfil : '' });
|
|
277
281
|
p.name = r.nombre;
|
|
@@ -305,6 +309,7 @@
|
|
|
305
309
|
if (!S.ruta) return toast('Carga antes un grupo o una ruta.', true);
|
|
306
310
|
const entorno = S.entorno || (S.ruta.split('/').map(entornoDe).find(Boolean) || '');
|
|
307
311
|
const tags = Object.fromEntries(obligatorias().map(k => [k, k === 'Environment' ? entorno : '']));
|
|
312
|
+
tags.ManagedBy = tags.ManagedBy || 'paramsx'; // quién lo creó; si es obligatoria, ya va rellena
|
|
308
313
|
S.items.unshift({ name: S.ruta.replace(/\/$/, '') + '/', type: cfg().forzar_securestring === false ? 'String' : 'SecureString', value: '', description: '', tags, nuevo: true, borrar: false, ver: true, orig: null, descripcion_legible: true });
|
|
309
314
|
pintar();
|
|
310
315
|
const input = $('pLista').querySelector('[data-i="0"] [data-campo="name"]');
|
|
@@ -319,8 +324,8 @@
|
|
|
319
324
|
window.addEventListener('beforeunload', e => { if (pendientes().length) { e.preventDefault(); e.returnValue = ''; } });
|
|
320
325
|
|
|
321
326
|
// ------------------------------------------------ revisar y aplicar
|
|
322
|
-
const paraEnviar = () => pendientes().map(p => ({ name: p
|
|
323
|
-
value: p.value, description: p.description || '', type: p.type, tags: p.tags }));
|
|
327
|
+
const paraEnviar = () => pendientes().map(p => ({ name: nombreEnvio(p), accion: p.nuevo ? 'nuevo' : p.borrar ? 'borrar' : 'editar', version: p.orig ? p.orig.version : null,
|
|
328
|
+
value: p.value, description: p.description || '', type: p.type, tags: p.tags, ...(p.nuevo && p.de ? { de: p.de } : {}) }));
|
|
324
329
|
const ETIQUETA = { nuevo: 'Nuevo', editar: 'Modificado', borrar: 'Borrar' };
|
|
325
330
|
$('pRevisar').onclick = async () => {
|
|
326
331
|
const items = paraEnviar();
|
|
@@ -332,7 +337,7 @@
|
|
|
332
337
|
const frase = `APLICAR EN ${r.account}`;
|
|
333
338
|
modal.abrir(`<h2>Revisar cambios</h2><p>Comparado con lo que hay ahora en AWS, en la cuenta <b>${esc(r.account)}</b> · ${esc(r.region_name)}.${r.plan.length - validos.length ? ` <span class="warn">${r.plan.length - validos.length} no se aplicarán (ver motivo).</span>` : ''}${conflictos ? ' <b class="error">Hay conflictos: recarga el grupo para ver lo que hay ahora.</b>' : ''}</p>
|
|
334
339
|
<div class="plan">${r.plan.map(f => {
|
|
335
|
-
const actual = S.items.find(p => p
|
|
340
|
+
const actual = S.items.find(p => nombreEnvio(p) === f.name) || {};
|
|
336
341
|
return `<div class="plan-fila ${f.errores.length ? 'mal' : ''}"><div>${f.conflicto ? '<span class="pill conflicto">Conflicto</span>' : `<span class="pill ${f.errores.length ? 'mal' : f.accion}">${ETIQUETA[f.accion] || esc(f.accion)}</span>`}<code>${esc(f.name)}</code> ${f.accion === 'editar' ? f.cambios.map(c => `<span class="cambio">${esc(c)}</span>`).join(' ') : ''}</div>
|
|
337
342
|
${f.errores.map(x => `<small class="error">${esc(x)}</small>`).join('')}${f.avisos.map(x => `<small class="warn">⚠ ${esc(x)}</small>`).join('')}
|
|
338
343
|
${f.cambios.includes('valor') && f.accion === 'editar' ? `<details><summary>Ver el valor antes y después</summary><div class="diff"><div><small>Ahora en AWS</small><pre>${esc(f.antes ?? '')}</pre></div><div><small>Después</small><pre>${esc(actual.value ?? '')}</pre></div></div></details>` : ''}
|
|
@@ -355,13 +360,113 @@
|
|
|
355
360
|
const ok = res.resultados.filter(x => x.ok).length, mal = res.resultados.filter(x => !x.ok);
|
|
356
361
|
modal.abrir(`<h2>${mal.length ? 'Aplicado en parte' : 'Aplicado'}</h2><p>${ok} de ${res.resultados.length} cambios hechos en AWS. El grupo se ha vuelto a cargar.</p>${mal.length ? `<div class="plan">${mal.map(x => `<div class="plan-fila mal"><code>${esc(x.name)}</code><small class="error">${esc(x.error)}</small></div>`).join('')}</div>` : ''}<div class="acciones"><button type="button" class="primario" id="pSeguir">Cerrar</button></div>`);
|
|
357
362
|
$('pSeguir').onclick = modal.cerrar;
|
|
358
|
-
|
|
363
|
+
// Se recarga de AWS (puede haber rutas nuevas o borradas), pero lo que no se aplicó (falló o la revisión lo dejó
|
|
364
|
+
// fuera) se guarda antes y se vuelve a poner, para corregirlo sin escribirlo otra vez
|
|
365
|
+
const hechos = new Set(res.resultados.filter(x => x.ok).map(x => x.name));
|
|
366
|
+
const sinHacer = pendientes().filter(p => !hechos.has(nombreEnvio(p))).map(p => ({ ...p, name: nombreEnvio(p) }));
|
|
367
|
+
const conflictos = new Set([...r.plan.filter(f => f.conflicto).map(f => f.name), ...mal.filter(x => /^Conflicto/.test(x.error)).map(x => x.name)]);
|
|
368
|
+
S.items = []; S.carpetas = null;
|
|
359
369
|
await cargar();
|
|
370
|
+
if (sinHacer.length && S.ruta) { reponer(S.items, sinHacer, conflictos); pintar(); }
|
|
360
371
|
toast(mal.length ? `${ok} aplicados, ${mal.length} con error` : `${ok} cambios aplicados`, !!mal.length);
|
|
361
372
|
} catch (e) { $('pAplicar').disabled = false; $('pAplicar').textContent = 'Aplicar'; toast(e.message, true); }
|
|
362
373
|
};
|
|
363
374
|
};
|
|
364
375
|
|
|
376
|
+
// Tras aplicar y recargar: pone otra vez sobre lo recién leído de AWS lo que no se aplicó. Los nuevos, arriba y en
|
|
377
|
+
// su orden; en los existentes, lo editado y la marca de borrar. Si era un conflicto, se queda con la versión que se
|
|
378
|
+
// cargó entonces, para que la revisión lo siga marcando y no pise sin avisar el cambio de otro.
|
|
379
|
+
function reponer(items, sinHacer, conflictos) {
|
|
380
|
+
for (const g of [...sinHacer].reverse()) {
|
|
381
|
+
if (g.nuevo) { items.unshift(g); continue; }
|
|
382
|
+
const p = items.find(q => q.name === g.name); if (!p) continue;
|
|
383
|
+
Object.assign(p, { value: g.value, description: g.description, type: g.type, tags: g.tags ? { ...g.tags } : p.tags, borrar: g.borrar, ver: g.ver });
|
|
384
|
+
if (conflictos.has(g.name) && g.orig) p.orig = { ...p.orig, version: g.orig.version };
|
|
385
|
+
}
|
|
386
|
+
return items;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ------------------------------------------------ reemplazar (buscar y reemplazar en lo que se ve con el filtro)
|
|
390
|
+
// Solo cambia el editor, como si se hubiera escrito a mano; después, «Revisar cambios» como siempre. Un nombre nuevo es
|
|
391
|
+
// crear otro parámetro con lo mismo y borrar el viejo (en SSM no se puede renombrar).
|
|
392
|
+
const escRe = t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
393
|
+
const recorte = (t, re) => { // en valores largos, el trozo de alrededor de la primera coincidencia
|
|
394
|
+
const m = t.search(re); if (t.length <= 90 || m < 0) return t;
|
|
395
|
+
const ini = Math.max(0, m - 35); return (ini ? '…' : '') + t.slice(ini, ini + 90) + (ini + 90 < t.length ? '…' : '');
|
|
396
|
+
};
|
|
397
|
+
const visibles = () => [...$('pLista').querySelectorAll('.param:not([hidden])')].map(c => +c.dataset.i);
|
|
398
|
+
function coincidencias(buscar, por, donde, mayus) {
|
|
399
|
+
const reT = new RegExp(escRe(buscar), mayus ? '' : 'i'), reG = new RegExp(escRe(buscar), mayus ? 'g' : 'gi'), out = [];
|
|
400
|
+
for (const i of visibles()) {
|
|
401
|
+
const p = S.items[i]; if (!p || p.bloqueado || p.borrar) continue;
|
|
402
|
+
const mira = (campo, clave, antes) => {
|
|
403
|
+
if (typeof antes !== 'string' || !reT.test(antes)) return;
|
|
404
|
+
const despues = antes.replace(reG, () => por);
|
|
405
|
+
if (despues === antes) return; // no cambia nada (y un nombre igual sería crear y borrar el mismo)
|
|
406
|
+
out.push({ i, campo, clave, antes, despues, n: (antes.match(reG) || []).length, reT });
|
|
407
|
+
};
|
|
408
|
+
if (donde.nombre) mira('nombre', '', p.name);
|
|
409
|
+
if (donde.valor) mira('valor', '', p.value);
|
|
410
|
+
if (donde.descripcion && p.descripcion_legible !== false) mira('descripción', '', p.description || '');
|
|
411
|
+
if (donde.etiquetas && p.tags) Object.entries(p.tags).forEach(([k, v]) => mira('etiqueta', k, String(v)));
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
$('pReemplazar').onclick = () => {
|
|
416
|
+
if (!S.ruta) return toast('Carga antes un grupo o una ruta.', true);
|
|
417
|
+
const n = visibles().length;
|
|
418
|
+
modal.abrir(`<h2>Reemplazar</h2><p>En los ${n} parámetros que ves ahora${$('pFiltro').value.trim() ? ' (con el filtro)' : ''}. Solo cambia el editor: después, «Revisar cambios» como siempre.</p>
|
|
419
|
+
<div class="reemplazar"><label class="campo">Buscar<input id="rBuscar" ${NOFILL}></label><label class="campo">Reemplazar por<input id="rPor" ${NOFILL}></label></div>
|
|
420
|
+
<div class="reemplazar-donde"><span class="reemplazar-et">Buscar en</span><span class="pildoras">${[['rValor', 'Valores', true], ['rNombre', 'Nombres', false], ['rEtiquetas', 'Etiquetas', false], ['rDesc', 'Descripciones', false]]
|
|
421
|
+
.map(([id, t, on]) => `<label class="pildora"><input type="checkbox" id="${id}" ${on ? 'checked' : ''}><span>${t}</span></label>`).join('')}</span>
|
|
422
|
+
<label class="pildora sola"><input type="checkbox" id="rMayus"><span>Aa · distinguir mayúsculas</span></label></div>
|
|
423
|
+
<p class="aviso reemplazar-aviso" id="rAvisoNombre" hidden>⚠ En SSM un parámetro no se renombra: se crea uno nuevo con el mismo valor, tipo, descripción y etiquetas, y se borra el viejo. Lo que lo lea por el nombre viejo dejará de encontrarlo, y su historial de versiones no pasa al nuevo. Si el nuevo no se puede crear, el viejo no se borra.</p>
|
|
424
|
+
<div id="rLista" class="reemplazar-lista"><p class="muted">Escribe qué buscar.</p></div>
|
|
425
|
+
<div class="acciones"><button type="button" class="primario" id="rPoner" disabled>Poner en el editor</button><button type="button" class="secundario" id="rNo">Cancelar</button></div>`);
|
|
426
|
+
let lista = [];
|
|
427
|
+
const donde = () => ({ valor: $('rValor').checked, nombre: $('rNombre').checked, etiquetas: $('rEtiquetas').checked, descripcion: $('rDesc').checked });
|
|
428
|
+
const oculto = c => c.campo === 'valor' && S.items[c.i].type === 'SecureString' && !S.items[c.i].ver && !$('pMostrar').checked;
|
|
429
|
+
const ver = () => {
|
|
430
|
+
const buscar = $('rBuscar').value;
|
|
431
|
+
$('rAvisoNombre').hidden = !$('rNombre').checked;
|
|
432
|
+
lista = buscar ? coincidencias(buscar, $('rPor').value, donde(), $('rMayus').checked) : [];
|
|
433
|
+
const rePor = new RegExp(escRe($('rPor').value) || '^');
|
|
434
|
+
$('rLista').innerHTML = !buscar ? '<p class="muted">Escribe qué buscar.</p>' : !lista.length ? '<p class="muted">Nada que coincida.</p>'
|
|
435
|
+
: `<p class="reemplazar-cuenta">${lista.length} ${lista.length === 1 ? 'cambio' : 'cambios'} en ${new Set(lista.map(c => c.i)).size} parámetros · desmarca los que no quieras</p>
|
|
436
|
+
<table class="reemplazar-tabla"><thead><tr><th><input type="checkbox" id="rTodos" checked title="Todos" aria-label="Todos"></th><th>Parámetro</th><th>Dónde</th><th>Antes → después</th></tr></thead><tbody>${lista.map((c, k) => `<tr>
|
|
437
|
+
<td><input type="checkbox" data-r="${k}" checked aria-label="Aplicar este cambio"></td><td><code>${esc(S.items[c.i].name)}</code></td>
|
|
438
|
+
<td><span class="cambio">${c.campo}${c.clave ? ' · ' + esc(c.clave) : ''}</span>${c.n > 1 ? `<small>${c.n} veces</small>` : ''}</td>
|
|
439
|
+
<td>${oculto(c) ? '<small class="muted">Valor cifrado: marca «Mostrar valores» para verlo</small>'
|
|
440
|
+
: `<span class="reemplazar-antes">${esc(recorte(c.antes, c.reT))}</span><span class="reemplazar-despues">${esc(recorte(c.despues, rePor))}</span>`}</td></tr>`).join('')}</tbody></table>`;
|
|
441
|
+
if ($('rTodos')) $('rTodos').addEventListener('change', e => $('rLista').querySelectorAll('[data-r]').forEach(x => { x.checked = e.target.checked; }));
|
|
442
|
+
$('rPoner').disabled = !lista.length;
|
|
443
|
+
};
|
|
444
|
+
['rBuscar', 'rPor'].forEach(id => $(id).addEventListener('input', ver));
|
|
445
|
+
['rValor', 'rNombre', 'rEtiquetas', 'rDesc', 'rMayus'].forEach(id => $(id).addEventListener('change', ver));
|
|
446
|
+
$('rNo').onclick = modal.cerrar; $('rBuscar').focus();
|
|
447
|
+
$('rPoner').onclick = () => {
|
|
448
|
+
const elegidos = lista.filter((_, k) => ($('rLista').querySelector(`[data-r="${k}"]`) || {}).checked);
|
|
449
|
+
const nombres = elegidos.filter(c => c.campo === 'nombre'), choques = [];
|
|
450
|
+
for (const c of elegidos.filter(c => c.campo !== 'nombre')) {
|
|
451
|
+
const p = S.items[c.i];
|
|
452
|
+
if (c.campo === 'valor') p.value = c.despues;
|
|
453
|
+
else if (c.campo === 'descripción') p.description = c.despues;
|
|
454
|
+
else p.tags = { ...p.tags, [c.clave]: c.despues };
|
|
455
|
+
}
|
|
456
|
+
// Los nombres al final y de abajo arriba: cada nuevo va justo debajo del que sustituye, y el viejo se marca para borrar
|
|
457
|
+
for (const c of nombres.sort((a, b) => b.i - a.i)) {
|
|
458
|
+
const p = S.items[c.i], nuevo = sinBarraFinal(c.despues);
|
|
459
|
+
if (S.items.some(q => q !== p && nombreEnvio(q) === nuevo)) { choques.push(nuevo); continue; }
|
|
460
|
+
if (p.nuevo) { p.name = nuevo; continue; }
|
|
461
|
+
const copia = { name: nuevo, type: p.type, value: p.value, description: p.description || '', tags: p.tags ? { ...p.tags } : {}, nuevo: true, borrar: false, ver: p.ver, orig: null, descripcion_legible: true, de: p.name };
|
|
462
|
+
Object.assign(p, { value: p.orig.value, type: p.orig.type, description: p.orig.description, tags: p.orig.tags ? { ...p.orig.tags } : null, borrar: true });
|
|
463
|
+
S.items.splice(c.i + 1, 0, copia);
|
|
464
|
+
}
|
|
465
|
+
modal.cerrar(); pintar();
|
|
466
|
+
toast(`${elegidos.length - choques.length} ${elegidos.length - choques.length === 1 ? 'cambio puesto' : 'cambios puestos'} en el editor${choques.length ? `; ${choques.length} ${choques.length === 1 ? 'nombre no, porque ya existe' : 'nombres no, porque ya existen'}: ${choques.join(', ')}` : ''}. Revisa y aplica.`, !!choques.length);
|
|
467
|
+
};
|
|
468
|
+
};
|
|
469
|
+
|
|
365
470
|
// ------------------------------------------------ historial (las versiones de AWS)
|
|
366
471
|
async function historial(i) {
|
|
367
472
|
const p = S.items[i];
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
<label class="check"><input type="checkbox" id="pMostrar"><span>Mostrar valores</span></label>
|
|
43
43
|
<span class="resumen" id="pResumen"></span>
|
|
44
44
|
<span class="barra-acciones">
|
|
45
|
+
<button type="button" class="secundario" id="pReemplazar" title="Buscar y reemplazar en valores, nombres, etiquetas o descripciones de lo que ves (con el filtro)">Reemplazar…</button>
|
|
45
46
|
<button type="button" class="secundario" id="pNuevo">+ Nuevo</button>
|
|
46
47
|
<button type="button" class="secundario" id="pDescartar" hidden>Descartar cambios</button>
|
|
47
48
|
<button type="button" class="primario" id="pRevisar" disabled>Revisar cambios</button>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: paramsx
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: Librería para gestionar y respaldar parámetros de AWS SSM de manera sencilla y eficiente.
|
|
5
5
|
Home-page: https://github.com/pistatxos/paramsx
|
|
6
6
|
Author: Mariox
|
|
@@ -9,8 +9,8 @@ Description-Content-Type: text/markdown
|
|
|
9
9
|
License-File: LICENSE
|
|
10
10
|
Requires-Dist: boto3
|
|
11
11
|
Requires-Dist: windows-curses; platform_system == "Windows"
|
|
12
|
+
Requires-Dist: flask>=2.2
|
|
12
13
|
Provides-Extra: web
|
|
13
|
-
Requires-Dist: flask>=2.2; extra == "web"
|
|
14
14
|
Dynamic: author
|
|
15
15
|
Dynamic: author-email
|
|
16
16
|
Dynamic: description
|
|
@@ -68,7 +68,6 @@ Al arrancar pregunta **1. Web** o **2. Terminal** (o directo: `paramsx --web` /
|
|
|
68
68
|
## Versión web
|
|
69
69
|
|
|
70
70
|
```bash
|
|
71
|
-
pip install 'paramsx[web]' # añade Flask
|
|
72
71
|
paramsx --web
|
|
73
72
|
```
|
|
74
73
|
|
|
@@ -973,7 +972,7 @@ paramsx --version
|
|
|
973
972
|
| Comando | Descripción |
|
|
974
973
|
|---|---|
|
|
975
974
|
| `paramsx` | Pregunta: web o terminal. |
|
|
976
|
-
| `paramsx --web` | Abre la versión web
|
|
975
|
+
| `paramsx --web` | Abre la versión web en el navegador. |
|
|
977
976
|
| `paramsx --tui` | Abre el menú de terminal. |
|
|
978
977
|
| `paramsx configure` | Crea o comprueba la configuración. |
|
|
979
978
|
| `paramsx configure --ejemplo` | Genera la plantilla completa de configuración. |
|
|
@@ -92,10 +92,11 @@ setup(
|
|
|
92
92
|
install_requires= [
|
|
93
93
|
"boto3",
|
|
94
94
|
"windows-curses; platform_system == 'Windows'",
|
|
95
|
+
"flask>=2.2", # la versión web
|
|
95
96
|
],
|
|
96
|
-
#
|
|
97
|
+
# 'paramsx[web]' se mantiene para quien lo instaló así en la 2.4.0; ya no añade nada
|
|
97
98
|
extras_require={
|
|
98
|
-
"web": [
|
|
99
|
+
"web": [],
|
|
99
100
|
},
|
|
100
101
|
entry_points={
|
|
101
102
|
"console_scripts": [
|
|
@@ -293,3 +293,117 @@ def test_guardar_no_reordena_perfiles(cliente, config_path):
|
|
|
293
293
|
assert list(cfg["perfiles"]) == ["min", "max"]
|
|
294
294
|
assert cliente.put("/api/config", json={"config": cfg}).status_code == 200
|
|
295
295
|
assert list(core_config.cargar_config_literal(config_path)["perfiles"]) == ["min", "max"]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def renombrar(p, nuevo):
|
|
299
|
+
"""Lo que manda «Reemplazar» en nombres: el alta del nuevo (con «de») y el borrado del viejo."""
|
|
300
|
+
return [item(p, accion="nuevo", name=nuevo, version=None, de=p["name"]), item(p, accion="borrar")]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_renombrar_conserva_la_clave_kms_y_nunca_pierde_el_viejo(cliente, ssm, monkeypatch):
|
|
304
|
+
clave = "arn:aws:kms:eu-south-2:1:key/propia"
|
|
305
|
+
ssm.poner("/dev/app/db", "secreto", tags=TAGS, key_id=clave)
|
|
306
|
+
ssm.poner("/dev/app/url", "https://x", tipo="String", tags=TAGS)
|
|
307
|
+
ssm.poner("/dev/app/grande", "ya estaba", tipo="String", tags=TAGS)
|
|
308
|
+
ps = cargar(cliente)
|
|
309
|
+
r = cliente.post("/api/aplicar", json={"items": renombrar(ps["/dev/app/db"], "/dev/app/db2"),
|
|
310
|
+
"confirmacion": CONFIRMA, "borrados": "1"}).get_json()
|
|
311
|
+
assert all(x["ok"] for x in r["resultados"]), r
|
|
312
|
+
assert ssm.ops("PutParameter")[0]["KeyId"] == clave # la clave propia del viejo, no la de por defecto
|
|
313
|
+
assert ssm.params["/dev/app/db2"]["key_id"] == clave and "/dev/app/db" not in ssm.params
|
|
314
|
+
|
|
315
|
+
# El nuevo nombre ya existe en AWS: la revisión no deja borrar el viejo
|
|
316
|
+
filas = {f["name"]: f for f in cliente.post("/api/plan", json={
|
|
317
|
+
"items": renombrar(ps["/dev/app/url"], "/dev/app/grande")}).get_json()["plan"]}
|
|
318
|
+
assert "No se borra" in " ".join(filas["/dev/app/url"]["errores"])
|
|
319
|
+
|
|
320
|
+
# Y si falla al crearlo en AWS (aquí, otro lo crea justo antes), el viejo tampoco se borra
|
|
321
|
+
real = ssm.put_parameter
|
|
322
|
+
|
|
323
|
+
def lo_crea_otro(**kw):
|
|
324
|
+
if kw["Name"] not in ssm.params:
|
|
325
|
+
ssm.poner(kw["Name"], "de otro", tipo="String")
|
|
326
|
+
return real(**kw)
|
|
327
|
+
monkeypatch.setattr(ssm, "put_parameter", lo_crea_otro)
|
|
328
|
+
r = {x["name"]: x for x in cliente.post("/api/aplicar", json={
|
|
329
|
+
"items": renombrar(ps["/dev/app/url"], "/dev/app/url2"), "confirmacion": CONFIRMA,
|
|
330
|
+
"borrados": "1"}).get_json()["resultados"]}
|
|
331
|
+
assert not r["/dev/app/url2"]["ok"] and not r["/dev/app/url"]["ok"]
|
|
332
|
+
assert "No se borra" in r["/dev/app/url"]["error"]
|
|
333
|
+
assert "/dev/app/url" in ssm.params and not [o for o in ssm.ops("DeleteParameters") if "/dev/app/url" in o["Names"]]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ---------------------------------------------------------------- nombre sin barra final
|
|
337
|
+
|
|
338
|
+
def test_nuevo_con_barra_final_se_quita(cliente, ssm):
|
|
339
|
+
nuevo = {"name": " /dev/app/n// ", "accion": "nuevo", "value": "v", "type": "String", "tags": TAGS}
|
|
340
|
+
plan = cliente.post("/api/plan", json={"items": [dict(nuevo)]}).get_json()["plan"]
|
|
341
|
+
assert plan[0]["name"] == "/dev/app/n" and not plan[0]["errores"]
|
|
342
|
+
r = cliente.post("/api/aplicar", json={"items": [nuevo], "confirmacion": CONFIRMA}).get_json()
|
|
343
|
+
assert r["resultados"][0]["ok"] and r["resultados"][0]["name"] == "/dev/app/n"
|
|
344
|
+
assert ssm.ops("PutParameter")[0]["Name"] == "/dev/app/n" and "/dev/app/n" in ssm.params
|
|
345
|
+
# Solo barras: no queda nombre, así que se deja y la revisión lo rechaza
|
|
346
|
+
solo = cliente.post("/api/plan", json={"items": [{**nuevo, "name": "//"}]}).get_json()["plan"][0]
|
|
347
|
+
assert solo["name"] == "//" and solo["errores"]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_editar_con_barra_final_no_se_toca(cliente, ssm):
|
|
351
|
+
# Solo los nuevos: un existente se manda por su nombre tal cual
|
|
352
|
+
plan = cliente.post("/api/plan", json={"items": [
|
|
353
|
+
{"name": "/dev/app/x/", "accion": "editar", "value": "v", "type": "String", "tags": TAGS}]}).get_json()["plan"]
|
|
354
|
+
assert plan[0]["name"] == "/dev/app/x/" and plan[0]["errores"]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ---------------------------------------------------------------- JS: lo que no se aplicó no se pierde
|
|
358
|
+
|
|
359
|
+
def _funcion_js(fuente, nombre):
|
|
360
|
+
"""El código de `function nombre(...) {...}` de paramsx.js (va sangrado dos espacios dentro del IIFE)."""
|
|
361
|
+
ini = fuente.index(f" function {nombre}(")
|
|
362
|
+
return fuente[ini:fuente.index("\n }\n", ini) + 4]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_js_reponer_lo_no_aplicado(tmp_path):
|
|
366
|
+
import json
|
|
367
|
+
import shutil
|
|
368
|
+
import subprocess
|
|
369
|
+
from pathlib import Path
|
|
370
|
+
|
|
371
|
+
node = shutil.which("node")
|
|
372
|
+
if not node:
|
|
373
|
+
pytest.skip("Sin node no se puede probar el JS")
|
|
374
|
+
fuente = (Path(__file__).parent.parent / "paramsx/web/static/paramsx.js").read_text(encoding="utf-8")
|
|
375
|
+
script = _funcion_js(fuente, "reponer") + r"""
|
|
376
|
+
// Lo recién cargado de AWS: a se aplicó (v2), b no (falló), c era conflicto (en AWS ya va por v5)
|
|
377
|
+
const de = (name, value, version) => ({ name, value, type: 'String', description: '', tags: { A: '1' }, borrar: false, nuevo: false,
|
|
378
|
+
orig: { name, value, type: 'String', description: '', tags: { A: '1' }, version } });
|
|
379
|
+
const items = [de('/r/a', 'a-nuevo', 2), de('/r/b', 'b', 1), de('/r/c', 'c-de-otro', 5), de('/r/d', 'd', 1)];
|
|
380
|
+
const sinHacer = [
|
|
381
|
+
{ name: '/r/n1', value: 'v1', type: 'SecureString', description: 'uno', tags: { ManagedBy: 'paramsx' }, nuevo: true, borrar: false, orig: null },
|
|
382
|
+
{ name: '/r/n2', value: 'v2', type: 'String', description: '', tags: {}, nuevo: true, borrar: false, orig: null },
|
|
383
|
+
{ ...de('/r/b', 'b-editado', 1), description: 'desc', type: 'SecureString', tags: { A: '2' } },
|
|
384
|
+
{ ...de('/r/c', 'c-mio', 3) },
|
|
385
|
+
{ ...de('/r/d', 'd', 1), borrar: true },
|
|
386
|
+
{ ...de('/r/ya-no-esta', 'x', 1) },
|
|
387
|
+
];
|
|
388
|
+
const r = reponer(items, sinHacer, new Set(['/r/c']));
|
|
389
|
+
console.log(JSON.stringify(r));
|
|
390
|
+
"""
|
|
391
|
+
ruta = tmp_path / "prueba.js"
|
|
392
|
+
ruta.write_text(script, encoding="utf-8")
|
|
393
|
+
out = json.loads(subprocess.run([node, str(ruta)], capture_output=True, text=True, check=True).stdout)
|
|
394
|
+
por = {p["name"]: p for p in out}
|
|
395
|
+
# Los nuevos, arriba y en su orden, con todo lo que llevaban
|
|
396
|
+
assert [p["name"] for p in out[:2]] == ["/r/n1", "/r/n2"]
|
|
397
|
+
assert por["/r/n1"]["value"] == "v1" and por["/r/n1"]["description"] == "uno" and por["/r/n1"]["type"] == "SecureString"
|
|
398
|
+
assert por["/r/n1"]["tags"] == {"ManagedBy": "paramsx"}
|
|
399
|
+
# Lo aplicado, como viene de AWS
|
|
400
|
+
assert por["/r/a"]["value"] == "a-nuevo" and por["/r/a"]["orig"]["version"] == 2
|
|
401
|
+
# Lo que falló, con lo editado otra vez encima de lo leído
|
|
402
|
+
b = por["/r/b"]
|
|
403
|
+
assert (b["value"], b["description"], b["type"], b["tags"]) == ("b-editado", "desc", "SecureString", {"A": "2"})
|
|
404
|
+
assert b["orig"]["value"] == "b"
|
|
405
|
+
# El conflicto conserva la versión que se cargó, para que la revisión lo siga marcando
|
|
406
|
+
assert por["/r/c"]["value"] == "c-mio" and por["/r/c"]["orig"]["version"] == 3
|
|
407
|
+
assert por["/r/d"]["borrar"] is True
|
|
408
|
+
# Lo que ya no está en AWS no se inventa
|
|
409
|
+
assert "/r/ya-no-esta" not in por and len(out) == 6
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.4.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|