feat(install): --update-in-place, actualización sin privilegios y reversible

Hay entornos donde no se usa root en absoluto, así que ni `sudo -n` ni una cuenta
root son opciones. Este modo actualiza una instalación existente dejando su unit
de systemd intacto, que es lo único que una actualización necesita de verdad:
reemplazar el binario y reiniciar el proceso.

Se apoya en dos hechos, uno de ellos contrario a lo que decía el propio repo:

- `install` NO sufre ETXTBSY. A diferencia de `cp` —que abre con O_TRUNC—,
  desvincula el destino antes de crearlo, y por eso `make install` funciona sobre
  binarios en ejecución. Comprobado: `cp` sobre un ELF corriendo da "Text file
  busy" y `install` no. La consecuencia es la que importa: reemplazar el binario
  exige escritura en el DIRECTORIO, no en el archivo. El comentario de install.sh,
  BUILD.md y el CHANGELOG afirmaban lo contrario y mandaban al operador a
  diagnosticar un archivo en uso cuando lo que tenía era un EACCES.
- El unit corre como el usuario que instaló (cadena SUDO_USER) y trae
  Restart=always, así que esa cuenta puede señalizar el proceso y systemd lo
  relevanta con el binario nuevo. No hace falta systemctl ni tocar /etc.

Robustez, que es donde estaba el trabajo real:

- **Reversible.** Respalda el binario antes de reemplazarlo y, si el nuevo no
  arranca, lo restaura y confirma que el proceso volvió. Sin esto, una
  actualización fallida deja el servidor sin agente. Si tampoco puede revertir,
  conserva el respaldo y lo dice en vez de fingir éxito.
- **No interrumpe restauraciones.** El agente no atiende SIGTERM: matarlo a media
  restauración deja ese respaldo vetado para siempre (has_blocking_job_by_hash) y
  puede dejar la base en SINGLE_USER. Se comprueba Temp/ dos veces —antes de
  copiar y otra vez justo antes de señalizar, para cerrar la ventana— y sale con
  75 (EX_TEMPFAIL), que el panel traduce a "reintenta luego" y no a un fallo.
- **Diagnostica por qué no volvió**: distingue un unit sin Restart=always de un
  StartLimitBurst agotado, con el comando de recuperación.
- Omite el bootstrap de 20 s: es redundante en una actualización
  (ensure_runtime_layout corre en cada arranque) y una segunda instancia junto a
  la viva purgaría el Temp de la que está trabajando.

Un bug que solo aparecía fuera del camino feliz: con `set -euo pipefail`, un
`$(pgrep ... | head -1)` sin resultados hace fallar la sustitución y `set -e`
mataba el script en silencio — justo en el caso "el proceso no volvió", que es el
que había que manejar. Por eso el rollback no se ejecutaba nunca.
This commit is contained in:
2026-07-30 15:40:37 -06:00
parent 17c0dea4bf
commit f02cd1f4c3
3 changed files with 160 additions and 14 deletions

View File

@@ -31,8 +31,9 @@
claves `CLOUDRESTORE_PANEL_*` en `config/.env` (replace-or-append, idempotente, con lista
blanca) y borran el archivo. El token viaja por archivo 0600, nunca por argumentos, para
que no quede visible en `ps` ni en el historial del destino.
- `install.sh` detiene el servicio antes de reemplazar el binario (un ELF en ejecución da
`ETXTBSY`) y lo vuelve a levantar si estaba activo.
- `install.sh` detiene el servicio antes de reemplazar el binario y lo vuelve a levantar si
estaba activo. (La razón que se dio aquí —que un ELF en ejecución da `ETXTBSY`— era incorrecta:
eso le pasa a `cp`, no a `install`, que desvincula el destino antes de crearlo. Ver BUILD.md.)
#### Versionado
- `app/__init__.py` es la fuente única de la versión; el diálogo *Acerca de* ya no la trae