Actualizar a 1.1.4 fallaba con "no escribio config\.version tras la actualizacion" aunque el binario nuevo estuviera instalado y corriendo desde la ruta correcta. La causa era el ORDEN dentro de ensure_runtime_layout(): el sello iba al final, detras del re-despliegue de las deps embebidas (7-Zip y ODBC). Al cambiar de version esas deps se re-copian ENTERAS, asi que el sello quedaba por detras de esa copia y del desempaquetado del onefile de ~254 MB con el antivirus escaneando cada archivo. El PANEL se rendia esperandolo y daba por fallida una actualizacion que iba bien. - El sello se escribe lo primero, en cuanto existen las carpetas. Es tambien mas honesto sobre lo que significa —"que binario esta corriendo"—, que es cierto desde que el proceso arranca. El sello de DEPS sigue yendo al final, donde su comentario explica por que: si la copia falla a medias, el proximo arranque reintenta en vez de quedar marcado como al dia. - La version va en la PRIMERA linea del log de arranque. Permite comprobar que binario corre de verdad mirando solo config/logs, sin depender del sello ni del reporte al panel: verificar una actualizacion deja de obligar a creerse lo que diga otro sistema. La prueba nueva observa el estado del sello EN EL MOMENTO en que empieza la copia de deps, no al final, que es la unica forma de fijar el orden. Comprobado que muerde: devolviendo el sello al final falla con `assert None == '1.1.5'`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
219 lines
7.7 KiB
Python
219 lines
7.7 KiB
Python
"""Creación automática de config/ y carpetas de trabajo."""
|
|
|
|
import hashlib
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from .. import __version__
|
|
from ..constants import (
|
|
APP_DIR,
|
|
BUNDLE_DIR,
|
|
BUNDLED_SOURCE_7ZIP,
|
|
BUNDLED_SOURCE_ODBC,
|
|
BUNDLED_STAMP_PATH,
|
|
CONFIG_DIR,
|
|
DATA_DIR,
|
|
DIR_ENTRADA,
|
|
DIR_FALLADOS,
|
|
DIR_PROCESADOS,
|
|
DIR_TEMP,
|
|
ENV_PATH,
|
|
IS_WINDOWS,
|
|
LOGS_DIR,
|
|
ODBC_DIR,
|
|
SEVEN_ZIP_DIR,
|
|
VERSION_PATH,
|
|
)
|
|
from ..db.database import DatabaseManager
|
|
from .env_loader import render_env_template
|
|
|
|
|
|
def _bundled_resource(rel_path: str) -> Optional[Path]:
|
|
"""
|
|
Recurso empaquetado: primero dentro del onefile (BUNDLE_DIR), luego junto al código
|
|
en desarrollo. Mismo orden de preferencia que usa _write_env_if_missing.
|
|
"""
|
|
for base in (BUNDLE_DIR, APP_DIR):
|
|
candidate = base / rel_path
|
|
if candidate.is_file():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _read_stamp(path: Path) -> str:
|
|
try:
|
|
return path.read_text(encoding="utf-8").strip()
|
|
except OSError:
|
|
return ""
|
|
|
|
|
|
def _write_stamp(path: Path, content: str) -> None:
|
|
"""Escribe un sello solo si cambió, para no tocar disco en cada arranque."""
|
|
value = content.strip()
|
|
if _read_stamp(path) == value:
|
|
return
|
|
try:
|
|
path.write_text(value, encoding="utf-8")
|
|
except OSError:
|
|
# Los sellos son informativos: si config/ no es escribible, el arranque sigue.
|
|
# La consecuencia es re-copiar las deps embebidas en el próximo arranque.
|
|
pass
|
|
|
|
|
|
def _bundled_deps_stamp() -> str:
|
|
"""
|
|
Huella de las dependencias embebidas en ESTE build: el sha256 de
|
|
packaging/bundled-versions.json, que es el archivo que fija las versiones de 7-Zip,
|
|
del driver ODBC y de unixODBC. Si no viene empaquetado, cae a la versión de la app.
|
|
"""
|
|
manifest = _bundled_resource("packaging/bundled-versions.json")
|
|
if manifest is not None:
|
|
try:
|
|
return "sha256:" + hashlib.sha256(manifest.read_bytes()).hexdigest()
|
|
except OSError:
|
|
pass
|
|
return f"app:{__version__}"
|
|
|
|
|
|
def _backup_before_refresh(dest: Path) -> None:
|
|
"""
|
|
Aparta el contenido actual a `<dest>.bak` antes de re-copiarlo.
|
|
|
|
El re-despliegue usa copytree(dirs_exist_ok=True), que SOBRESCRIBE los archivos: si el
|
|
operador editó a mano algo como odbcinst.ini, se perdería en la primera ejecución tras
|
|
actualizar y sin aviso. Esa edición no se puede reconstruir, así que se conserva.
|
|
|
|
Un solo respaldo rotatorio, sin fecha: estas carpetas viven en servidores que almacenan
|
|
respaldos de bases y no conviene acumular una copia del driver ODBC por cada actualización.
|
|
"""
|
|
backup = dest.with_name(f"{dest.name}.bak")
|
|
try:
|
|
if backup.exists():
|
|
shutil.rmtree(backup, ignore_errors=True)
|
|
shutil.copytree(dest, backup, dirs_exist_ok=True)
|
|
except OSError:
|
|
# El respaldo es una red de seguridad, no un requisito: si el disco no da o los
|
|
# permisos no alcanzan, el refresco debe seguir su curso.
|
|
pass
|
|
|
|
|
|
def _copy_bundled_tree(src: Path, dest: Path, refresh: bool) -> None:
|
|
"""
|
|
Despliega 7-Zip/ODBC del bundle a config/. Copia si el destino está vacío y RE-copia
|
|
cuando refresh es True, es decir cuando este build trae otras versiones embebidas:
|
|
una actualización con driver ODBC nuevo debe reemplazar el viejo, no conservarlo.
|
|
|
|
Antes de re-copiar sobre contenido existente se aparta una copia a `<dest>.bak`, porque
|
|
el copytree sobrescribe y las ediciones manuales del operador no son reconstruibles.
|
|
|
|
dest puede existir pero VACÍO: ensure_runtime_layout crea ODBC_DIR/SEVEN_ZIP_DIR
|
|
antes de llamar aquí, así que la condición mira el contenido, no la existencia.
|
|
"""
|
|
if not src.is_dir():
|
|
return
|
|
has_content = dest.exists() and any(dest.iterdir())
|
|
if has_content and not refresh:
|
|
return
|
|
if has_content and refresh:
|
|
_backup_before_refresh(dest)
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
shutil.copytree(src, dest, dirs_exist_ok=True)
|
|
|
|
|
|
def _ensure_seven_zip_executable() -> None:
|
|
"""
|
|
Asegura el bit de ejecución del 7-Zip embebido en Linux/macOS. shutil.copytree
|
|
suele preservar los permisos, pero esto es una salvaguarda barata: si el binario
|
|
quedó sin 'x', la extracción fallaría con PermissionError.
|
|
"""
|
|
if IS_WINDOWS:
|
|
return
|
|
for exe_name in ("7zz", "7za", "7z"):
|
|
exe_path = SEVEN_ZIP_DIR / exe_name
|
|
if exe_path.is_file():
|
|
try:
|
|
exe_path.chmod(0o755)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _write_env_if_missing() -> None:
|
|
if ENV_PATH.exists():
|
|
return
|
|
template_path = BUNDLE_DIR / "packaging" / "templates" / "env.default"
|
|
if not template_path.is_file():
|
|
template_path = APP_DIR / "packaging" / "templates" / "env.default"
|
|
if template_path.is_file():
|
|
content = template_path.read_text(encoding="utf-8")
|
|
content = content.replace("{APP_DIR}", str(APP_DIR))
|
|
else:
|
|
content = render_env_template(APP_DIR)
|
|
ENV_PATH.write_text(content, encoding="utf-8")
|
|
try:
|
|
ENV_PATH.chmod(0o600)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def ensure_runtime_layout() -> None:
|
|
"""
|
|
Idempotente: crea config/, carpetas de trabajo y .env si no existen.
|
|
No sobrescribe .env ni app.db existentes.
|
|
"""
|
|
for directory in (
|
|
CONFIG_DIR,
|
|
DATA_DIR,
|
|
LOGS_DIR,
|
|
ODBC_DIR,
|
|
SEVEN_ZIP_DIR,
|
|
DIR_ENTRADA,
|
|
DIR_PROCESADOS,
|
|
DIR_FALLADOS,
|
|
DIR_TEMP,
|
|
):
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Los sellos se ubican bajo el CONFIG_DIR vigente (no la ruta absoluta precalculada)
|
|
# para que respeten el monkeypatch de las pruebas y no escriban en el config/ real.
|
|
deps_stamp_path = CONFIG_DIR / BUNDLED_STAMP_PATH.name
|
|
version_stamp_path = CONFIG_DIR / VERSION_PATH.name
|
|
|
|
# Sello de versión: lo lee el instalador remoto del PANEL por SFTP para verificar el
|
|
# despliegue (en Windows el .exe es console=False y no tiene stdout confiable).
|
|
#
|
|
# Se escribe LO PRIMERO, en cuanto existen las carpetas. Antes iba al final, detrás del
|
|
# re-despliegue de las deps embebidas, y eso lo hacía inservible justo cuando más importa: al
|
|
# cambiar de versión las deps se re-copian enteras, así que el sello quedaba por detrás de esa
|
|
# copia y del desempaquetado del onefile. El PANEL se rendía esperándolo y daba por fallida una
|
|
# actualización que en realidad iba bien.
|
|
#
|
|
# Escribirlo aquí es además más honesto sobre lo que el sello significa: "qué binario está
|
|
# corriendo", que es cierto desde que el proceso arranca. Si el despliegue de deps fallara
|
|
# después, el sello de DEPS —que sí va al final— lo detecta y el próximo arranque reintenta.
|
|
_write_stamp(version_stamp_path, __version__)
|
|
|
|
_write_env_if_missing()
|
|
|
|
# Las deps embebidas se re-despliegan cuando el build trae otras versiones. Su sello se
|
|
# escribe DESPUÉS de copiar: si la copia falla a medias, el próximo arranque lo reintenta
|
|
# en lugar de quedar marcado como al día.
|
|
deps_stamp = _bundled_deps_stamp()
|
|
refresh_deps = _read_stamp(deps_stamp_path) != deps_stamp
|
|
_copy_bundled_tree(BUNDLED_SOURCE_7ZIP, SEVEN_ZIP_DIR, refresh_deps)
|
|
_copy_bundled_tree(BUNDLED_SOURCE_ODBC, ODBC_DIR, refresh_deps)
|
|
_ensure_seven_zip_executable()
|
|
if refresh_deps:
|
|
_write_stamp(deps_stamp_path, deps_stamp)
|
|
|
|
from ..constants import DB_PATH
|
|
|
|
if not DB_PATH.exists():
|
|
DatabaseManager(DB_PATH)
|
|
|
|
if getattr(sys, "frozen", False):
|
|
marker = CONFIG_DIR / ".bootstrap_ok"
|
|
if not marker.exists():
|
|
marker.write_text("ok", encoding="utf-8")
|