209 lines
7.0 KiB
Python
209 lines
7.0 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)
|
|
|
|
_write_env_if_missing()
|
|
|
|
# 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
|
|
|
|
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")
|