feature/generador-instaladores-linux-windows

This commit is contained in:
2026-07-30 07:34:17 -06:00
parent cafe3f1b87
commit c3f1d70e23
34 changed files with 3498 additions and 252 deletions

View File

@@ -1,14 +1,18 @@
"""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,
@@ -20,19 +24,100 @@ from ..constants import (
LOGS_DIR,
ODBC_DIR,
SEVEN_ZIP_DIR,
VERSION_PATH,
)
from ..db.database import DatabaseManager
from .env_loader import render_env_template
def _copy_tree_if_missing(src: Path, dest: Path) -> None:
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
# dest puede existir pero VACÍO: ensure_runtime_layout crea ODBC_DIR/SEVEN_ZIP_DIR
# antes de llamar aquí. Solo saltar si ya tiene contenido (evita re-copiar en cada
# arranque). Antes se saltaba por dest.exists(), dejando 7-Zip/ODBC sin desplegar.
if dest.exists() and any(dest.iterdir()):
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)
@@ -92,9 +177,25 @@ def ensure_runtime_layout() -> None:
_write_env_if_missing()
_copy_tree_if_missing(BUNDLED_SOURCE_7ZIP, SEVEN_ZIP_DIR)
_copy_tree_if_missing(BUNDLED_SOURCE_ODBC, ODBC_DIR)
# 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
# Las deps embebidas se re-despliegan cuando el build trae otras versiones. El 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)
# 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).
_write_stamp(version_stamp_path, __version__)
from ..constants import DB_PATH

View File

@@ -15,6 +15,7 @@ from ..constants import (
ENV_PATH,
default_seven_zip_path,
)
from ..utils.logger import app_logger
def _env_bool(name: str, default: bool = False) -> bool:
@@ -24,6 +25,31 @@ def _env_bool(name: str, default: bool = False) -> bool:
return raw in ("1", "true", "yes", "on")
def _env_int(
name: str,
default: int,
*,
min_value: int | None = None,
max_value: int | None = None,
) -> int:
"""Lee un entero de entorno con validación; ante valor inválido loguea y usa el default."""
raw = os.getenv(name, "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
app_logger.warning(f"{name}='{raw}' no es un entero válido; se usa {default}")
return default
if min_value is not None and value < min_value:
app_logger.warning(f"{name}={value} < {min_value} (mínimo); se usa {default}")
return default
if max_value is not None and value > max_value:
app_logger.warning(f"{name}={value} > {max_value} (máximo); se usa {default}")
return default
return value
def load_env_file() -> bool:
"""Carga config/.env si existe."""
if ENV_PATH.is_file():
@@ -101,6 +127,33 @@ def apply_env_overrides(config: dict) -> dict:
if os.getenv("CLOUDRESTORE_SQL_USE_WINDOWS_AUTH"):
sql["use_windows_auth"] = _env_bool("CLOUDRESTORE_SQL_USE_WINDOWS_AUTH", False)
retention = config.setdefault("retention", {})
if os.getenv("CLOUDRESTORE_RETENTION_ENABLED"):
retention["enabled"] = _env_bool("CLOUDRESTORE_RETENTION_ENABLED", True)
if os.getenv("CLOUDRESTORE_RETENTION_DAYS"):
retention["days"] = _env_int(
"CLOUDRESTORE_RETENTION_DAYS", retention.get("days", 2), min_value=0
)
if os.getenv("CLOUDRESTORE_RETENTION_FAILED_DAYS"):
retention["failed_days"] = _env_int(
"CLOUDRESTORE_RETENTION_FAILED_DAYS", retention.get("failed_days", 7), min_value=0
)
if os.getenv("CLOUDRESTORE_RETENTION_RUN_AT_HOUR"):
retention["run_at_hour"] = _env_int(
"CLOUDRESTORE_RETENTION_RUN_AT_HOUR",
retention.get("run_at_hour", 3),
min_value=0,
max_value=23,
)
if os.getenv("CLOUDRESTORE_RETENTION_CHECK_INTERVAL_SECONDS"):
retention["check_interval_seconds"] = _env_int(
"CLOUDRESTORE_RETENTION_CHECK_INTERVAL_SECONDS",
retention.get("check_interval_seconds", 3600),
min_value=60,
)
if os.getenv("CLOUDRESTORE_RETENTION_DRY_RUN"):
retention["dry_run"] = _env_bool("CLOUDRESTORE_RETENTION_DRY_RUN", True)
return config
@@ -136,4 +189,12 @@ CLOUDRESTORE_EXTRACT_FOLDER={p("Temp")}
# CLOUDRESTORE_SQL_SERVER=localhost
# CLOUDRESTORE_SQL_USERNAME=
# CLOUDRESTORE_DATA_SQL_FOLDER=
# Retención (limpieza diaria de respaldos obsoletos para no saturar el disco)
# CLOUDRESTORE_RETENTION_ENABLED=true
# CLOUDRESTORE_RETENTION_DAYS=2 # Procesados: por nodo, respecto al más reciente
# CLOUDRESTORE_RETENTION_FAILED_DAYS=7 # Fallados: por antigüedad absoluta
# CLOUDRESTORE_RETENTION_RUN_AT_HOUR=3 # hora local de la corrida diaria (0-23)
# CLOUDRESTORE_RETENTION_CHECK_INTERVAL_SECONDS=3600
# CLOUDRESTORE_RETENTION_DRY_RUN=true # true = solo simula; poner false para borrar
"""