diff --git a/BUILD.md b/BUILD.md index eb4f597..3e32108 100644 --- a/BUILD.md +++ b/BUILD.md @@ -153,15 +153,33 @@ credenciales. Lo de abajo es el camino manual y lo que el PANEL ejecuta por dent ```bash tar xzf CloudRestoreAS--linux-x86_64.tar.gz && cd CloudRestoreAS -sudo ./install.sh --service # servicio systemd 24/7 headless (recomendado en servidor) - ./install.sh --desktop # autostart .desktop (requiere sesión gráfica) - ./install.sh # solo instala + bootstrap; lo corres a mano +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 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index fa20886..5057b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/install.sh b/install.sh index 64b96da..8f29fa9 100755 --- a/install.sh +++ b/install.sh @@ -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 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