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.
Files changed (32) hide show
  1. {paramsx-2.4.0 → paramsx-2.5.0}/CHANGELOG.md +19 -0
  2. {paramsx-2.4.0/paramsx.egg-info → paramsx-2.5.0}/PKG-INFO +3 -4
  3. {paramsx-2.4.0 → paramsx-2.5.0}/README.md +1 -2
  4. paramsx-2.5.0/paramsx/__init__.py +1 -0
  5. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/main.py +4 -4
  6. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/web/__init__.py +1 -1
  7. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/web/app.py +11 -1
  8. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/web/ssm.py +24 -3
  9. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/web/static/app.css +28 -0
  10. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/web/static/paramsx.js +109 -4
  11. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/web/templates/index.html +1 -0
  12. {paramsx-2.4.0 → paramsx-2.5.0/paramsx.egg-info}/PKG-INFO +3 -4
  13. {paramsx-2.4.0 → paramsx-2.5.0}/setup.py +3 -2
  14. {paramsx-2.4.0 → paramsx-2.5.0}/tests/test_web.py +114 -0
  15. paramsx-2.4.0/paramsx/__init__.py +0 -1
  16. {paramsx-2.4.0 → paramsx-2.5.0}/LICENSE +0 -0
  17. {paramsx-2.4.0 → paramsx-2.5.0}/MANIFEST.in +0 -0
  18. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/core/__init__.py +0 -0
  19. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/core/config.py +0 -0
  20. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/core/rutas.py +0 -0
  21. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/funcions.py +0 -0
  22. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/paramsx_config.py +0 -0
  23. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/text.py +0 -0
  24. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/web/static/ajustes.js +0 -0
  25. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx/web/static/comun.js +0 -0
  26. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx.egg-info/SOURCES.txt +0 -0
  27. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx.egg-info/dependency_links.txt +0 -0
  28. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx.egg-info/entry_points.txt +0 -0
  29. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx.egg-info/requires.txt +1 -1
  30. {paramsx-2.4.0 → paramsx-2.5.0}/paramsx.egg-info/top_level.txt +0 -0
  31. {paramsx-2.4.0 → paramsx-2.5.0}/setup.cfg +0 -0
  32. {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.4.0
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 (necesita `paramsx[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 (necesita `paramsx[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 (necesita: pip install 'paramsx[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 'paramsx[web]'"
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 'paramsx[web]'",
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 'paramsx[web]')"),
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
 
@@ -1,5 +1,5 @@
1
1
  """Versión web de paramsx: un Flask local en 127.0.0.1 que se abre en el navegador.
2
- Necesita el extra [web]: pip install 'paramsx[web]'."""
2
+ Flask viene con paramsx desde la 2.4.1."""
3
3
 
4
4
 
5
5
  def flask_disponible():
@@ -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
- return [it for it in items if isinstance(it, dict)]
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
- res["version"] = _aplicar_uno(ssm, it, fila, meta.get(nombre, {}), accion)
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.name.trim(), accion: p.nuevo ? 'nuevo' : p.borrar ? 'borrar' : 'editar', version: p.orig ? p.orig.version : null,
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.name.trim() === f.name) || {};
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
- S.items = []; S.carpetas = null; // se recarga de AWS (puede haber rutas nuevas o borradas)
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.4.0
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 (necesita `paramsx[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
- # La versión web es opcional: pip install 'paramsx[web]'
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": ["flask>=2.2"],
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
@@ -1,7 +1,7 @@
1
1
  boto3
2
+ flask>=2.2
2
3
 
3
4
  [:platform_system == "Windows"]
4
5
  windows-curses
5
6
 
6
7
  [web]
7
- flask>=2.2
File without changes
File without changes