feature/generador-instaladores-linux-windows
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user