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

@@ -154,14 +154,32 @@ credenciales. Lo de abajo es el camino manual y lo que el PANEL ejecuta por dent
tar xzf CloudRestoreAS-<version>-linux-x86_64.tar.gz && cd CloudRestoreAS
sudo ./install.sh --service # servicio systemd 24/7 headless (recomendado en servidor)
./install.sh --user-service # 24/7 SIN privilegios: unit de systemd de usuario
./install.sh --update-in-place # actualiza una instalación existente SIN privilegios
./install.sh --desktop # autostart .desktop (requiere sesión gráfica)
./install.sh # solo instala + bootstrap; lo corres a mano
./install.sh --help
```
Variables: `PREFIX=/opt/cloudrestoreas` (destino), `SERVICE_USER=<usuario>` (usuario del servicio).
Detiene el servicio antes de reemplazar el binario (un ELF en ejecución da `ETXTBSY`) y lo
vuelve a levantar si estaba activo.
Detiene el servicio antes de reemplazar el binario en los modos de servicio, para que el apagado
sea ordenado. **No** porque la copia lo exija: `install` desvincula el destino antes de crearlo —a
diferencia de `cp`, que abre con `O_TRUNC` y sí da `ETXTBSY`—, y por eso `make install` funciona
sobre binarios en ejecución. Comprobado. Lo que hace falta para reemplazar el binario es permiso de
escritura en el **directorio**, no en el archivo. (Esta nota decía lo contrario y mandó a más de
uno por la pista equivocada al diagnosticar un `EACCES`.)
### Sin privilegios
`--user-service` instala bajo el home con un unit de systemd **de usuario** (lingering, y si el
destino no lo permite, `@reboot` en el crontab del usuario más un vigilante). Sirve para
instalaciones nuevas donde nunca vas a tener root.
`--update-in-place` actualiza una instalación **que ya existe**, dejando su unit intacto: solo
reemplaza el binario y señaliza al proceso para que `Restart=always` lo relevante. Exige que el
directorio sea escribible por la cuenta, que el unit corra con ese mismo usuario y que tenga
`Restart=always`. Se niega si hay una restauración en curso (sale con **75**, `EX_TEMPFAIL`) y
**revierte al binario anterior** si el nuevo no arranca.
Servicio systemd:
```bash

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

View File

@@ -10,9 +10,17 @@
# Uso:
# sudo ./install.sh --service # 24/7 headless vía systemd (recomendado en servidor)
# ./install.sh --user-service # 24/7 headless SIN root: unit de systemd de usuario
# ./install.sh --update-in-place # actualiza en su sitio, SIN privilegios
# ./install.sh --desktop # autostart de escritorio (requiere sesión gráfica)
# ./install.sh # solo instala; sin arranque automático
#
# --update-in-place actualiza una instalación EXISTENTE sin privilegios, dejando el unit de
# systemd como está. Se apoya en dos hechos: `install` desvincula el destino antes de crearlo (a
# diferencia de `cp`, que da ETXTBSY), así que reemplazar el binario solo exige escritura en el
# DIRECTORIO; y el unit trae Restart=always, así que basta señalizar al proceso —desde la cuenta
# que lo corre— para que systemd lo relevante con el binario nuevo. No toca /etc ni systemctl.
# Se niega a actuar si hay una restauración en curso.
#
# --user-service no necesita privilegios: instala donde apunte PREFIX (que debe ser escribible
# por el usuario, típicamente bajo su home), registra el unit en ~/.config/systemd/user/ y lo
# arranca con `systemctl --user`. Para que sobreviva al cierre de sesión intenta habilitar
@@ -38,6 +46,7 @@ while [[ $# -gt 0 ]]; do
case "$1" in
--service) MODE="service"; shift ;;
--user-service) MODE="user-service"; shift ;;
--update-in-place) MODE="update-in-place"; shift ;;
--desktop) MODE="desktop"; shift ;;
--panel-env-file)
[[ $# -ge 2 ]] || { echo "ERROR: --panel-env-file requiere una ruta" >&2; exit 2; }
@@ -80,15 +89,22 @@ echo " Modo : $MODE"
echo "==============================================="
# --- Detener el servicio si está corriendo ------------------------------------
# Un ELF en ejecución no se puede sobrescribir (ETXTBSY), así que una actualización
# tiene que detenerlo antes de reemplazar el binario. Se recuerda si estaba activo
# para volver a levantarlo al final incluso en los modos que no tocan systemd.
# Ojo con la razón, que estuvo mal escrita mucho tiempo: `install` NO sufre ETXTBSY. A
# diferencia de `cp` —que abre con O_TRUNC—, `install` desvincula el destino antes de crearlo
# (coreutils fija unlink_dest_before_opening), que es justo por lo que `make install` funciona
# sobre binarios en ejecución. Comprobado: `cp` sobre un ELF corriendo da "Text file busy" y
# `install` no. Lo que hace falta para reemplazarlo es permiso de escritura en el DIRECTORIO.
#
# Se detiene igual en los modos de servicio porque conviene un apagado ordenado, no porque la
# copia lo exija. Se recuerda si estaba activo para volver a levantarlo al final.
WAS_ACTIVE=0
# En modo usuario el unit vive en la instancia de systemd del propio usuario, que necesita
# XDG_RUNTIME_DIR: un `exec` de SSH no es una sesión de login y no siempre lo trae.
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
SYSTEMCTL_USER=(systemctl --user)
if command -v systemctl >/dev/null 2>&1; then
# En modo en-sitio NO se detiene nada: `install` reemplaza el binario con el proceso corriendo, y
# matarlo antes de tener el binario nuevo abriría una ventana en la que systemd relevanta el VIEJO.
if [[ "$MODE" != "update-in-place" ]] && command -v systemctl >/dev/null 2>&1; then
if [[ "$MODE" == "user-service" ]]; then
if "${SYSTEMCTL_USER[@]}" is-active --quiet "$UNIT_NAME" 2>/dev/null; then
WAS_ACTIVE=1
@@ -112,11 +128,40 @@ if command -v systemctl >/dev/null 2>&1; then
fi
fi
# Una restauración en curso no se interrumpe. El agente no atiende SIGTERM, así que matarlo a
# media restauración deja el job atascado —ese ZIP queda vetado en cada escaneo posterior— y puede
# dejar la base en SINGLE_USER. Cualquier subcarpeta de Temp/ es un job en vuelo.
# Código 75 (EX_TEMPFAIL) y no 1: le dice al panel "reintenta luego", no "falló la instalación".
if [[ "$MODE" == "update-in-place" ]] && [[ -d "$PREFIX/Temp" ]]; then
if [[ -n "$(ls -A "$PREFIX/Temp" 2>/dev/null)" ]]; then
echo "ERROR: hay una restauración en curso ($PREFIX/Temp no está vacío)." >&2
echo " No se actualiza para no dejarla a medias. Reintenta cuando termine." >&2
exit 75
fi
fi
# Respaldo para poder volver atrás. Una actualización en sitio reemplaza el binario de un
# servidor en producción sin red de seguridad: si el nuevo no arranca, sin esto el servidor queda
# sin agente y sin forma de recuperarlo salvo entrando a mano.
BACKUP=""
if [[ "$MODE" == "update-in-place" && -f "$PREFIX/$BIN_NAME" ]]; then
BACKUP="$PREFIX/.$BIN_NAME.prev"
if ! cp -p "$PREFIX/$BIN_NAME" "$BACKUP"; then
echo "ERROR: no se pudo respaldar el binario actual en $BACKUP" >&2
echo " Se aborta: actualizar sin poder revertir no es aceptable." >&2
exit 1
fi
echo "Respaldo del binario actual en $BACKUP"
fi
mkdir -p "$PREFIX"
if ! install -m 0755 "$SRC" "$PREFIX/$BIN_NAME"; then
echo "ERROR: no se pudo instalar el binario en $PREFIX/$BIN_NAME" >&2
echo " Si el archivo está en uso, detén el proceso y reintenta:" >&2
echo " sudo systemctl stop $UNIT_NAME # o mata el proceso de escritorio" >&2
# `install` desvincula el destino antes de crearlo, así que un binario EN USO no es el
# problema (eso es cosa de `cp`). Lo que falta casi siempre es permiso en el DIRECTORIO.
echo " Se necesita permiso de escritura en el directorio $PREFIX." >&2
echo " Dueño actual: $(stat -c '%U:%G %a' "$PREFIX" 2>/dev/null || echo 'desconocido')" >&2
echo " Usuario actual: $(id -un)" >&2
exit 1
fi
echo "Binario instalado en $PREFIX/$BIN_NAME"
@@ -125,11 +170,16 @@ echo "Binario instalado en $PREFIX/$BIN_NAME"
# al inicio de su arranque. Se corre una vez en modo offscreen (sin display) y se
# corta con timeout; así el usuario ya puede editar config/.env antes de habilitar
# el servicio.
# En una actualización en sitio se OMITE: el binario hace ensure_runtime_layout() en cada arranque,
# así que es redundante, y correr una segunda instancia junto a la viva es peligroso —al arrancar,
# el motor purga todas las subcarpetas de Temp, que son de la instancia en curso—.
if [[ "$MODE" != "update-in-place" ]]; then
echo "Inicializando config/ (bootstrap)..."
# `</dev/null` no es cosmético: es el único hijo que heredaría el stdin del canal SSH cuando el
# instalador corre en remoto. Cerrárselo hace estructural —y no accidental— que nada de lo que
# venga por ese canal pueda ser consumido aquí.
( cd "$PREFIX" && QT_QPA_PLATFORM=offscreen timeout 20 "$PREFIX/$BIN_NAME" --headless </dev/null >/dev/null 2>&1 || true )
fi
if [[ -f "$PREFIX/config/.env" ]]; then
echo "config/.env creado."
else
@@ -299,6 +349,82 @@ case "$MODE" in
echo " Estado : systemctl --user status cloudrestoreas"
echo " Logs : journalctl --user -u cloudrestoreas -f"
;;
update-in-place)
# El unit ya está registrado y no cambia: no se toca /etc, ni daemon-reload, ni enable.
# El binario nuevo ya está en su sitio; falta que el proceso lo tome.
if ! pgrep -f "$PGREP_PAT" >/dev/null 2>&1; then
echo "El agente no estaba corriendo; queda actualizado y systemd lo levantará."
else
# Segundo chequeo, inmediatamente antes de señalizar. El primero fue antes de copiar el
# binario y de sembrar el .env; en esos segundos pudo entrar una restauración, y matarla
# deja el respaldo vetado para siempre. Aquí ya no hay 270 MB de por medio: es barato.
if [[ -d "$PREFIX/Temp" ]] && [[ -n "$(ls -A "$PREFIX/Temp" 2>/dev/null)" ]]; then
echo "ERROR: entró una restauración mientras se actualizaba." >&2
echo " El binario nuevo YA está instalado y se activará en el próximo reinicio" >&2
echo " del agente; no se fuerza ahora para no interrumpirla." >&2
exit 75
fi
# `|| true` obligatorio: con pipefail, un pgrep sin resultados hace fallar la
# sustitución y `set -e` abortaría el script justo en el caso que hay que manejar.
OLD_PIDS="$(pgrep -f "$PGREP_PAT" | tr '\n' ' ' || true)"
echo "Señalizando al agente (PIDs: $OLD_PIDS) para que systemd lo relevante..."
# SIGTERM y no SIGKILL: si algún día el agente aprende a atender la señal, este camino ya
# le da la oportunidad de cerrar limpio.
pkill -TERM -f "$PGREP_PAT" >/dev/null 2>&1 || true
# Restart=always + RestartSec=5. Se espera con margen y se confirma que volvió: si el unit
# no tuviera Restart, matarlo lo dejaría muerto y eso NO puede pasar por bueno.
RESTARTED=0
for _ in $(seq 1 15); do
sleep 2
NEW_PID="$(pgrep -f "$PGREP_PAT" | head -1 || true)"
if [[ -n "$NEW_PID" ]] && [[ " $OLD_PIDS " != *" $NEW_PID "* ]]; then
RESTARTED=1
echo "Agente relevantado por systemd (PID $NEW_PID)."
break
fi
done
if [[ "$RESTARTED" -eq 0 ]]; then
# No volvió. Dejar el servidor sin agente no es una opción: se revierte al binario que
# sí funcionaba y se le da otra oportunidad a systemd.
echo "AVISO: el agente no volvió tras la señal; revirtiendo al binario anterior..." >&2
UNIT_STATE="$(systemctl is-active "$UNIT_NAME" 2>/dev/null || true)"
UNIT_RESTART="$(systemctl show -p Restart --value "$UNIT_NAME" 2>/dev/null || true)"
REVERTED=0
if [[ -n "$BACKUP" && -f "$BACKUP" ]]; then
if install -m 0755 "$BACKUP" "$PREFIX/$BIN_NAME"; then
for _ in $(seq 1 10); do
sleep 2
if pgrep -f "$PGREP_PAT" >/dev/null 2>&1; then REVERTED=1; break; fi
done
fi
fi
echo "ERROR: la actualización no dejó al agente corriendo." >&2
echo " Estado del unit: ${UNIT_STATE:-desconocido} (Restart=${UNIT_RESTART:-desconocido})" >&2
if [[ "${UNIT_RESTART}" != "always" ]]; then
echo " El unit no tiene Restart=always: nadie lo relevanta al terminar." >&2
elif [[ "$UNIT_STATE" == "failed" ]]; then
echo " systemd lo marcó como failed; puede haber agotado StartLimitBurst." >&2
echo " Reintentar con: systemctl reset-failed $UNIT_NAME && systemctl start $UNIT_NAME" >&2
fi
if [[ "$REVERTED" -eq 1 ]]; then
echo " REVERTIDO: el binario anterior está corriendo de nuevo. El servidor" >&2
echo " quedó como estaba; la versión nueva NO se aplicó." >&2
exit 1
fi
echo " NO se pudo revertir. El binario nuevo está en $PREFIX/$BIN_NAME y el" >&2
echo " anterior en ${BACKUP:-(sin respaldo)}. Hay que revisar el servidor a mano." >&2
exit 1
fi
# Solo tras confirmar que el agente nuevo corre se descarta el respaldo.
[[ -n "$BACKUP" && -f "$BACKUP" ]] && rm -f "$BACKUP" || true
fi
;;
desktop)
echo "Modo escritorio: la app registra su autostart .desktop al iniciarse."
echo "Ejecuta '$PREFIX/$BIN_NAME' en tu sesión gráfica."
@@ -311,7 +437,8 @@ esac
# Si se detuvo un servicio que estaba activo y el modo elegido no lo relevanta, se
# restaura: una actualización no debe dejar el restaurador apagado en silencio.
if [[ "$WAS_ACTIVE" -eq 1 && "$MODE" != "service" && "$MODE" != "user-service" ]]; then
if [[ "$WAS_ACTIVE" -eq 1 && "$MODE" != "service" && "$MODE" != "user-service" \
&& "$MODE" != "update-in-place" ]]; then
echo "Reiniciando $UNIT_NAME (estaba activo antes de la actualización)..."
systemctl start "$UNIT_NAME" >/dev/null 2>&1 || \
echo "AVISO: no se pudo reiniciar $UNIT_NAME; hazlo a mano." >&2