1477 lines
49 KiB
Markdown
1477 lines
49 KiB
Markdown
# PymesBot Installer — Especificación Técnica Completa
|
||
|
||
> **Documento para construir el sistema de instalación web**
|
||
> Versión 1.0 · Febrero 2026 · RVConsultas
|
||
>
|
||
> Este documento describe **exclusivamente** el sistema de instalación (`pymesbot-installer`).
|
||
> Es un proyecto separado del backend principal de PymesBot.
|
||
>
|
||
> **INSTRUCCIÓN PARA LA IA:**
|
||
> Leé este documento completo antes de escribir una sola línea de código.
|
||
> Cada sección es importante. No omitas nada. No asumas nada que no esté escrito acá.
|
||
> Si algo es ambiguo, implementá la opción más simple y segura.
|
||
|
||
---
|
||
|
||
## ÍNDICE
|
||
|
||
1. [Qué es el instalador](#1-qué-es-el-instalador)
|
||
2. [Cómo se usa](#2-cómo-se-usa)
|
||
3. [Estructura de archivos completa](#3-estructura-de-archivos-completa)
|
||
4. [Stack tecnológico](#4-stack-tecnológico)
|
||
5. [El archivo que descarga el operador](#5-el-archivo-que-descarga-el-operador)
|
||
6. [Módulo detect.py — detección del entorno](#6-módulo-detectpy--detección-del-entorno)
|
||
7. [Módulo installer.py — instalación base](#7-módulo-installerpy--instalación-base)
|
||
8. [Módulo client_setup.py — alta de cliente](#8-módulo-client_setuppy--alta-de-cliente)
|
||
9. [Módulo templates_engine.py — generador de configs](#9-módulo-templates_enginepy--generador-de-configs)
|
||
10. [Backend FastAPI — rutas y WebSocket](#10-backend-fastapi--rutas-y-websocket)
|
||
11. [El wizard HTML — especificación completa](#11-el-wizard-html--especificación-completa)
|
||
12. [Templates Jinja2 que genera el instalador](#12-templates-jinja2-que-genera-el-instalador)
|
||
13. [Seguridad](#13-seguridad)
|
||
14. [Testing y validación](#14-testing-y-validación)
|
||
15. [Orden de construcción recomendado](#15-orden-de-construcción-recomendado)
|
||
|
||
---
|
||
|
||
## 1. Qué es el instalador
|
||
|
||
El instalador es un **contenedor Docker temporal** que:
|
||
|
||
1. Se levanta en el servidor con `docker compose up -d`
|
||
2. Expone una interfaz web en `http://IP-DEL-SERVIDOR/install`
|
||
3. El operador (Guillermo) abre esa URL en su navegador, responde preguntas
|
||
4. El instalador detecta el estado del servidor y ejecuta las acciones necesarias
|
||
5. Al terminar, muestra las credenciales del cliente nuevo
|
||
6. Se apaga manualmente con `docker compose down`
|
||
|
||
**Principio central:** el instalador detecta automáticamente si el servidor está limpio (primera vez) o si ya tiene infraestructura. El operador nunca tiene que saber en qué estado está el servidor.
|
||
|
||
### Qué puede hacer el instalador
|
||
|
||
**Modo A — VPS limpia (primera instalación):**
|
||
- Instalar Nginx, Certbot
|
||
- Crear la estructura de carpetas `/opt/pymesbot/`
|
||
- Crear la red Docker `pymesbot_net`
|
||
- Dar de alta el primer cliente
|
||
|
||
**Modo B — Infraestructura existente, nuevo cliente:**
|
||
- Crear la carpeta del cliente con toda su estructura
|
||
- Generar todos los archivos de configuración
|
||
- Agregar el bloque de Nginx para el nuevo subdominio
|
||
- Emitir el certificado SSL
|
||
- Levantar los contenedores Docker del cliente
|
||
- Generar credenciales de acceso
|
||
|
||
**Modo C — Cliente ya existente (conflicto):**
|
||
- Detectar el conflicto y avisarle al operador
|
||
- Ofrecer actualizar la configuración sin tocar la DB
|
||
- O cambiar el slug a uno disponible
|
||
|
||
---
|
||
|
||
## 2. Cómo se usa
|
||
|
||
### Paso 1: Descargar en el servidor
|
||
|
||
```bash
|
||
# En el servidor (via SSH)
|
||
mkdir -p ~/pymesbot-install
|
||
cd ~/pymesbot-install
|
||
curl -O https://rvconsultas.com/release/pymesbot-installer/docker-compose.yml
|
||
```
|
||
|
||
### Paso 2: Levantar el instalador
|
||
|
||
```bash
|
||
docker compose up -d
|
||
```
|
||
|
||
### Paso 3: Abrir el wizard en el navegador
|
||
|
||
```
|
||
http://IP-DEL-SERVIDOR/install?token=TOKEN_QUE_SE_IMPRIME_EN_LA_TERMINAL
|
||
```
|
||
|
||
> El token se imprime en la terminal al levantar el contenedor. Sin este token, `/install` devuelve 404.
|
||
|
||
### Paso 4: Seguir el wizard
|
||
|
||
6 pasos en el navegador. Ver sección 11 para el detalle de cada paso.
|
||
|
||
### Paso 5: Apagar el instalador
|
||
|
||
```bash
|
||
docker compose down
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Estructura de archivos completa
|
||
|
||
Este es el proyecto completo que hay que construir. Cada archivo mencionado acá tiene que existir.
|
||
|
||
```
|
||
pymesbot-installer/
|
||
│
|
||
├── docker-compose.yml ← EL ARCHIVO QUE DESCARGA EL OPERADOR
|
||
├── Dockerfile ← imagen del instalador
|
||
├── .dockerignore
|
||
│
|
||
├── app/ ← código Python del backend del instalador
|
||
│ ├── main.py ← FastAPI app + todas las rutas
|
||
│ ├── detect.py ← detección del estado del servidor
|
||
│ ├── installer.py ← lógica de instalación de infraestructura base (Modo A)
|
||
│ ├── client_setup.py ← lógica de alta de cliente (Modo B y Modo A post-infra)
|
||
│ ├── templates_engine.py ← generador de archivos de config con Jinja2
|
||
│ ├── security.py ← token de sesión, validación sudo
|
||
│ ├── state.py ← estado de la instalación en memoria
|
||
│ └── config_templates/ ← templates Jinja2 para generar configs
|
||
│ ├── nginx_site.conf.j2
|
||
│ ├── docker-compose.client.yml.j2
|
||
│ ├── picoclaw_config.json.j2
|
||
│ ├── env_client.j2
|
||
│ └── schema.sql ← schema SQL de la DB del cliente (NO es template, es static)
|
||
│
|
||
├── static/ ← frontend del wizard
|
||
│ ├── index.html ← el wizard completo (todo en UN archivo HTML)
|
||
│ ├── style.css ← estilos del wizard
|
||
│ └── wizard.js ← lógica del wizard
|
||
│
|
||
└── requirements.txt
|
||
```
|
||
|
||
**IMPORTANTE:** El `static/index.html` es el wizard completo. Todo el frontend en un solo archivo. No crear carpetas separadas por componentes. Debe funcionar sin CDN externo si es necesario (aunque puede usar Google Fonts).
|
||
|
||
---
|
||
|
||
## 4. Stack tecnológico
|
||
|
||
| Componente | Tecnología | Versión | Notas |
|
||
|-----------|-----------|---------|-------|
|
||
| Backend del instalador | Python + FastAPI | Python 3.11, FastAPI 0.110 | — |
|
||
| ASGI server | Uvicorn | 0.29 | — |
|
||
| Templates de config | Jinja2 | 3.x | Para generar nginx.conf, docker-compose.yml, etc. |
|
||
| Ejecución de comandos shell | `asyncio.create_subprocess_shell` | stdlib | Nunca usar `subprocess.run` síncrono dentro de async |
|
||
| Streaming de output al frontend | WebSocket (FastAPI nativo) | — | `ws://{IP}/install/ws/progress` |
|
||
| Frontend del wizard | HTML + CSS + JS vanilla | ES2020 | Sin frameworks, sin npm, sin build step |
|
||
| Fuentes web | Google Fonts (IBM Plex Mono + Space Grotesk) | — | Si no hay internet, fallback a monospace y sans-serif |
|
||
| Contenedor | Docker | 24+ | — |
|
||
|
||
### Python packages (`requirements.txt`)
|
||
|
||
```
|
||
fastapi==0.110.0
|
||
uvicorn[standard]==0.29.0
|
||
jinja2==3.1.3
|
||
python-multipart==0.0.9
|
||
python-dotenv==1.0.1
|
||
httpx==0.27.0
|
||
aiofiles==23.2.1
|
||
secrets # stdlib, no instalar
|
||
```
|
||
|
||
---
|
||
|
||
## 5. El archivo que descarga el operador
|
||
|
||
Este es el `docker-compose.yml` del instalador. Es el único archivo que el operador necesita descargar.
|
||
|
||
```yaml
|
||
version: '3.8'
|
||
|
||
services:
|
||
pymesbot_installer:
|
||
image: rvconsultas/pymesbot-installer:latest
|
||
# Alternativa para desarrollo local:
|
||
# build:
|
||
# context: .
|
||
# dockerfile: Dockerfile
|
||
container_name: pymesbot_installer
|
||
restart: "no" # NO reiniciar solo, el operador lo baja manualmente
|
||
ports:
|
||
- "80:8000" # wizard accesible en puerto 80
|
||
volumes:
|
||
# Estos 4 volúmenes son CRÍTICOS. Sin ellos el instalador no puede actuar sobre el host.
|
||
- /var/run/docker.sock:/var/run/docker.sock # para ejecutar docker compose en el host
|
||
- /usr/bin/docker:/usr/bin/docker:ro # binario docker disponible en el contenedor
|
||
- /opt/pymesbot:/opt/pymesbot # carpeta de clientes
|
||
- /etc/nginx:/etc/nginx # configs de Nginx
|
||
- /usr/sbin/nginx:/usr/sbin/nginx:ro # para ejecutar nginx -s reload
|
||
environment:
|
||
- INSTALLER_MODE=production
|
||
# El token de seguridad se genera al arrancar y se imprime en los logs
|
||
# Ver el token con: docker logs pymesbot_installer
|
||
```
|
||
|
||
### Dockerfile del instalador
|
||
|
||
```dockerfile
|
||
FROM python:3.11-slim
|
||
|
||
LABEL maintainer="RVConsultas"
|
||
LABEL description="PymesBot Installer — wizard de instalación web"
|
||
|
||
WORKDIR /installer
|
||
|
||
# Instalar herramientas del sistema necesarias
|
||
# curl: para health checks
|
||
# sudo: para ejecutar comandos privilegiados con la contraseña del operador
|
||
RUN apt-get update && apt-get install -y \
|
||
curl \
|
||
sudo \
|
||
procps \
|
||
&& rm -rf /var/lib/apt/lists/*
|
||
|
||
# Copiar e instalar dependencias Python
|
||
COPY requirements.txt .
|
||
RUN pip install --no-cache-dir -r requirements.txt
|
||
|
||
# Copiar el código del instalador
|
||
COPY app/ ./app/
|
||
COPY static/ ./static/
|
||
|
||
# Exponer puerto
|
||
EXPOSE 8000
|
||
|
||
# Al arrancar: generar token, imprimirlo, y levantar uvicorn
|
||
CMD ["sh", "-c", "python app/main.py"]
|
||
```
|
||
|
||
### .dockerignore
|
||
|
||
```
|
||
__pycache__/
|
||
*.pyc
|
||
*.pyo
|
||
.env
|
||
.git/
|
||
*.md
|
||
tests/
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Módulo detect.py — detección del entorno
|
||
|
||
Este módulo examina el servidor y devuelve un objeto con el estado de cada componente.
|
||
|
||
### Función principal: `async def detectar_entorno() -> DetectionResult`
|
||
|
||
```python
|
||
# app/detect.py
|
||
|
||
import asyncio
|
||
import os
|
||
import json
|
||
from dataclasses import dataclass, field
|
||
from typing import List, Optional
|
||
|
||
@dataclass
|
||
class ComponentStatus:
|
||
nombre: str
|
||
ok: bool
|
||
version: Optional[str] = None
|
||
detalle: Optional[str] = None
|
||
|
||
@dataclass
|
||
class ClienteExistente:
|
||
slug: str
|
||
nombre: str
|
||
puerto: int
|
||
activo: bool
|
||
|
||
@dataclass
|
||
class DetectionResult:
|
||
docker_ok: bool
|
||
docker_version: Optional[str]
|
||
nginx_ok: bool
|
||
nginx_version: Optional[str]
|
||
certbot_ok: bool
|
||
certbot_version: Optional[str]
|
||
pymesbot_dir_ok: bool
|
||
red_docker_ok: bool
|
||
clientes_existentes: List[ClienteExistente]
|
||
modo: str # "A" | "B" | informational
|
||
mensaje_modo: str
|
||
siguiente_puerto: int
|
||
componentes: List[ComponentStatus]
|
||
```
|
||
|
||
### Lógica de detección — implementar en este orden exacto
|
||
|
||
```python
|
||
async def detectar_entorno() -> DetectionResult:
|
||
"""
|
||
Detecta el estado del servidor y determina el modo de instalación.
|
||
Nunca lanza excepciones. Si algo falla, marca ese componente como no-ok.
|
||
"""
|
||
|
||
# 1. Detectar Docker
|
||
# Ejecutar: docker --version
|
||
# Parsear la versión del output
|
||
# Si falla: docker_ok = False
|
||
|
||
# 2. Detectar Nginx
|
||
# Ejecutar: nginx -v 2>&1
|
||
# (nginx imprime la versión en stderr, por eso el 2>&1)
|
||
# Si falla: nginx_ok = False
|
||
|
||
# 3. Detectar Certbot
|
||
# Ejecutar: certbot --version
|
||
# Si falla: certbot_ok = False
|
||
|
||
# 4. Detectar /opt/pymesbot
|
||
# os.path.exists("/opt/pymesbot") y os.path.isdir("/opt/pymesbot")
|
||
|
||
# 5. Detectar red Docker pymesbot_net
|
||
# Ejecutar: docker network ls --filter name=pymesbot_net --format "{{.Name}}"
|
||
# Si el output contiene "pymesbot_net": ok
|
||
|
||
# 6. Listar clientes existentes
|
||
# Listar directorios en /opt/pymesbot/
|
||
# Excluir: "nginx", "scripts", archivos (no dirs), dirs que empiecen con "."
|
||
# Para cada cliente, leer su .env para obtener nombre y puerto
|
||
|
||
# 7. Determinar modo
|
||
# Modo A: cualquiera de docker_ok, nginx_ok, pymesbot_dir_ok es False
|
||
# Modo B: todo ok, y el slug del nuevo cliente no existe
|
||
# Modo C: el slug ya existe (esto se detecta después cuando el operador ingresa el slug)
|
||
|
||
# 8. Determinar siguiente puerto disponible
|
||
# Puertos usados = [c.puerto for c in clientes_existentes]
|
||
# Siguiente = próximo disponible empezando desde 8200
|
||
# Verificar que el puerto no esté en uso: ss -tuln | grep :PUERTO
|
||
```
|
||
|
||
### Helper: ejecutar comandos de forma segura
|
||
|
||
```python
|
||
async def run_cmd(cmd: str, timeout: int = 30) -> tuple[int, str, str]:
|
||
"""
|
||
Ejecuta un comando shell de forma asíncrona.
|
||
Returns: (return_code, stdout, stderr)
|
||
Nunca lanza excepciones. Si algo falla, devuelve (1, "", str(error))
|
||
"""
|
||
try:
|
||
proc = await asyncio.create_subprocess_shell(
|
||
cmd,
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.PIPE
|
||
)
|
||
stdout, stderr = await asyncio.wait_for(
|
||
proc.communicate(),
|
||
timeout=timeout
|
||
)
|
||
return proc.returncode, stdout.decode(), stderr.decode()
|
||
except asyncio.TimeoutError:
|
||
return 1, "", f"Timeout después de {timeout}s"
|
||
except Exception as e:
|
||
return 1, "", str(e)
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Módulo installer.py — instalación base
|
||
|
||
Este módulo se usa solo en **Modo A** (VPS limpia). Instala la infraestructura base.
|
||
|
||
### Función principal: `async def instalar_infraestructura(config, progress_callback)`
|
||
|
||
El parámetro `progress_callback` es una función async que se llama para enviar eventos al frontend via WebSocket.
|
||
|
||
```python
|
||
# Formato del evento de progreso
|
||
# progress_callback(evento: dict) → None
|
||
|
||
# Eventos posibles:
|
||
{
|
||
"type": "step_start",
|
||
"step_id": "nginx_install",
|
||
"label": "Instalando Nginx y Certbot..."
|
||
}
|
||
|
||
{
|
||
"type": "output",
|
||
"step_id": "nginx_install",
|
||
"line": "Reading package lists... Done"
|
||
}
|
||
|
||
{
|
||
"type": "step_done",
|
||
"step_id": "nginx_install",
|
||
"duration_ms": 4320
|
||
}
|
||
|
||
{
|
||
"type": "step_error",
|
||
"step_id": "nginx_install",
|
||
"error": "E: Package 'nginx' has no installation candidate",
|
||
"hint": "Verificá que el servidor tenga acceso a internet y que los repositorios estén actualizados."
|
||
}
|
||
```
|
||
|
||
### Pasos de instalación base (Modo A)
|
||
|
||
Ejecutar en este orden exacto. Si algún paso falla, detenerse y reportar el error con `step_error`.
|
||
|
||
#### Paso A1: `apt_update` — Actualizar repositorios
|
||
|
||
```bash
|
||
# Comando a ejecutar (con la contraseña sudo del operador):
|
||
echo '{SUDO_PASSWORD}' | sudo -S apt-get update -y
|
||
```
|
||
|
||
- Label para el frontend: `"Actualizando repositorios del sistema..."`
|
||
- Timeout: 120 segundos
|
||
- Si falla: hint = `"Verificá que el servidor tenga conexión a internet."`
|
||
|
||
#### Paso A2: `install_packages` — Instalar Nginx y Certbot
|
||
|
||
```bash
|
||
echo '{SUDO_PASSWORD}' | sudo -S apt-get install -y nginx certbot python3-certbot-nginx
|
||
```
|
||
|
||
- Label: `"Instalando Nginx y Certbot..."`
|
||
- Timeout: 300 segundos (puede tardar mucho descargando paquetes)
|
||
- Si falla: hint = `"Intentá correr manualmente: sudo apt-get install nginx certbot python3-certbot-nginx"`
|
||
|
||
#### Paso A3: `create_dirs` — Crear estructura de carpetas
|
||
|
||
```bash
|
||
echo '{SUDO_PASSWORD}' | sudo -S mkdir -p /opt/pymesbot/nginx/conf.d
|
||
echo '{SUDO_PASSWORD}' | sudo -S mkdir -p /opt/pymesbot/scripts
|
||
echo '{SUDO_PASSWORD}' | sudo -S chmod 755 /opt/pymesbot
|
||
```
|
||
|
||
- Label: `"Creando estructura de carpetas /opt/pymesbot..."`
|
||
- Timeout: 10 segundos
|
||
- Idempotente: `mkdir -p` no falla si ya existe
|
||
|
||
#### Paso A4: `create_docker_network` — Crear red Docker
|
||
|
||
```bash
|
||
docker network create pymesbot_net
|
||
```
|
||
|
||
**IMPORTANTE:** Este comando puede fallar con "network already exists". Eso NO es un error. Si falla con ese mensaje exacto, marcar como `step_done` igualmente.
|
||
|
||
- Label: `"Creando red Docker pymesbot_net..."`
|
||
- Timeout: 15 segundos
|
||
|
||
#### Paso A5: `nginx_base_config` — Configurar Nginx base
|
||
|
||
Escribir el archivo `/etc/nginx/nginx.conf` base si no existe o si el existente no tiene la directiva `include /etc/nginx/conf.d/*.conf`.
|
||
|
||
```bash
|
||
# Verificar si ya tiene el include
|
||
grep -q "include /etc/nginx/conf.d/" /etc/nginx/nginx.conf
|
||
```
|
||
|
||
Si ya lo tiene: marcar como done sin hacer nada.
|
||
Si no lo tiene: agregar la línea al bloque `http {}`.
|
||
|
||
Después:
|
||
```bash
|
||
echo '{SUDO_PASSWORD}' | sudo -S nginx -t
|
||
echo '{SUDO_PASSWORD}' | sudo -S systemctl enable nginx
|
||
echo '{SUDO_PASSWORD}' | sudo -S systemctl start nginx
|
||
```
|
||
|
||
- Label: `"Configurando Nginx base..."`
|
||
- Si `nginx -t` falla: `step_error` con el output del error como hint
|
||
|
||
#### Paso A6: Continuar con Modo B
|
||
|
||
Una vez terminada la instalación base, continuar directamente con el flujo de alta del primer cliente (llama a `client_setup.py`).
|
||
|
||
---
|
||
|
||
## 8. Módulo client_setup.py — alta de cliente
|
||
|
||
Este módulo da de alta un nuevo cliente. Se usa en Modo B y en la segunda parte del Modo A.
|
||
|
||
### Input: clase `ClientConfig`
|
||
|
||
```python
|
||
@dataclass
|
||
class ClientConfig:
|
||
slug: str # "castillo" — solo minúsculas, guiones, sin espacios
|
||
nombre: str # "Librería Castillo"
|
||
email: str # "dueño@gmail.com"
|
||
rubro: str # "libreria" | "kiosco" | "bazar" | "otro"
|
||
moneda: str # "ARS" | "USD" | "UYU"
|
||
dominio_base: str # "rvconsultas.com"
|
||
anthropic_api_key: str # "sk-ant-..."
|
||
sudo_password: str # contraseña sudo del operador (nunca loguear)
|
||
puerto: int # asignado por detect.py, ej: 8201
|
||
admin_password: str # generada aleatoriamente
|
||
vendor_pin: str # generada aleatoriamente (4 dígitos)
|
||
```
|
||
|
||
### Función principal: `async def setup_cliente(config: ClientConfig, progress_callback)`
|
||
|
||
Ejecutar estos pasos en orden. Si alguno falla, ejecutar rollback.
|
||
|
||
#### Paso C1: `create_client_dirs` — Crear carpetas del cliente
|
||
|
||
```bash
|
||
mkdir -p /opt/pymesbot/{slug}/picoclaw
|
||
mkdir -p /opt/pymesbot/{slug}/backend/templates
|
||
mkdir -p /opt/pymesbot/{slug}/data/uploads
|
||
```
|
||
|
||
- Label: `"Creando estructura de carpetas del cliente..."`
|
||
- Idempotente: `mkdir -p` no falla si ya existe
|
||
|
||
#### Paso C2: `generate_configs` — Generar archivos de configuración
|
||
|
||
Llamar a `templates_engine.py` para generar:
|
||
|
||
1. `/opt/pymesbot/{slug}/.env`
|
||
2. `/opt/pymesbot/{slug}/docker-compose.yml`
|
||
3. `/opt/pymesbot/{slug}/picoclaw/config.json`
|
||
|
||
- Label: `"Generando archivos de configuración..."`
|
||
- Si falla la escritura: verificar permisos de la carpeta
|
||
|
||
#### Paso C3: `init_database` — Inicializar la base de datos SQLite
|
||
|
||
Copiar el archivo `schema.sql` desde `app/config_templates/schema.sql` y ejecutarlo:
|
||
|
||
```bash
|
||
sqlite3 /opt/pymesbot/{slug}/data/stock.db < /installer/app/config_templates/schema.sql
|
||
```
|
||
|
||
Después de crear la DB, insertar la configuración inicial del negocio:
|
||
|
||
```sql
|
||
UPDATE config SET valor = '{nombre}' WHERE clave = 'nombre_negocio';
|
||
UPDATE config SET valor = '{rubro}' WHERE clave = 'rubro';
|
||
UPDATE config SET valor = '{moneda}' WHERE clave = 'moneda';
|
||
UPDATE config SET valor = '{admin_password}' WHERE clave = 'admin_password';
|
||
UPDATE config SET valor = '{vendor_pin}' WHERE clave = 'vendedor_pin';
|
||
```
|
||
|
||
- Label: `"Inicializando base de datos SQLite..."`
|
||
- Timeout: 10 segundos
|
||
|
||
#### Paso C4: `copy_backend` — Copiar el código del backend
|
||
|
||
El instalador lleva consigo el código del backend de PymesBot dentro de la imagen Docker.
|
||
Copiar desde `/installer/pymesbot-backend/` a `/opt/pymesbot/{slug}/backend/`.
|
||
|
||
```bash
|
||
cp -r /installer/pymesbot-backend/. /opt/pymesbot/{slug}/backend/
|
||
```
|
||
|
||
**NOTA PARA LA IA:** El proyecto `pymesbot-backend` es un directorio incluido en la imagen del instalador. Contiene el código FastAPI del backend de PymesBot (el que se describe en `01_PYMESBOT_PROJECT_SPEC.md`). Al construir la imagen del instalador, este código debe estar incluido.
|
||
|
||
- Label: `"Copiando código del backend..."`
|
||
|
||
#### Paso C5: `configure_nginx` — Configurar Nginx para el subdominio
|
||
|
||
1. Generar el archivo de configuración usando el template `nginx_site.conf.j2`
|
||
2. Escribirlo en `/etc/nginx/conf.d/{slug}.conf`
|
||
3. Validar: `nginx -t`
|
||
4. Recargar: `systemctl reload nginx`
|
||
|
||
```bash
|
||
echo '{SUDO_PASSWORD}' | sudo -S nginx -t
|
||
echo '{SUDO_PASSWORD}' | sudo -S systemctl reload nginx
|
||
```
|
||
|
||
- Label: `"Configurando Nginx para {slug}.{dominio}..."`
|
||
- Si `nginx -t` falla: reportar el error exacto de nginx como hint
|
||
|
||
#### Paso C6: `emit_ssl` — Emitir certificado SSL
|
||
|
||
```bash
|
||
echo '{SUDO_PASSWORD}' | sudo -S certbot --nginx \
|
||
-d {slug}.{dominio_base} \
|
||
--non-interactive \
|
||
--agree-tos \
|
||
-m instalador@rvconsultas.com
|
||
```
|
||
|
||
- Label: `"Emitiendo certificado SSL para {slug}.{dominio_base}..."`
|
||
- Timeout: 120 segundos
|
||
- Si falla con "DNS problem": hint = `"Verificá que el registro DNS A de {slug}.{dominio_base} apunte a la IP de este servidor ({ip_publica}) y que haya propagado. Podés verificarlo en https://dnschecker.org"`
|
||
- Si falla con "too many certificates": hint = `"Let's Encrypt tiene un límite de certificados por semana. Esperá unos días o usá un subdominio diferente."`
|
||
- **Este paso es el que más puede fallar.** Manejar todos los errores posibles de certbot.
|
||
|
||
#### Paso C7: `docker_up` — Levantar los contenedores
|
||
|
||
```bash
|
||
docker compose -f /opt/pymesbot/{slug}/docker-compose.yml up -d --build
|
||
```
|
||
|
||
- Label: `"Levantando contenedores Docker..."`
|
||
- Timeout: 300 segundos (puede tardar si tiene que construir la imagen)
|
||
- `--build` fuerza rebuild si el código cambió
|
||
|
||
#### Paso C8: `health_check` — Verificar que el cliente esté vivo
|
||
|
||
Hacer GET a `https://{slug}.{dominio_base}/health` con reintentos:
|
||
|
||
```python
|
||
for intento in range(1, 6): # 5 intentos
|
||
await asyncio.sleep(10) # esperar 10 segundos entre intentos
|
||
try:
|
||
response = await httpx.get(f"https://{slug}.{dominio_base}/health", timeout=10)
|
||
if response.status_code == 200:
|
||
# ¡Éxito!
|
||
break
|
||
except:
|
||
pass
|
||
# Si llegamos al intento 5 y falló: step_error
|
||
```
|
||
|
||
- Label: `"Verificando que el cliente esté respondiendo..."`
|
||
- Si falla después de 5 intentos: hint = `"Los contenedores levantaron pero el servicio no responde. Revisá los logs: docker logs pymesbot_{slug}"`
|
||
|
||
#### Paso C9: `generate_credentials` — Guardar credenciales
|
||
|
||
Guardar en `/opt/pymesbot/{slug}/credentials.json`:
|
||
|
||
```json
|
||
{
|
||
"url": "https://castillo.rvconsultas.com",
|
||
"admin_user": "admin",
|
||
"admin_password": "Xk7mQ2p",
|
||
"vendor_pin": "4821",
|
||
"instalado_el": "2026-02-14T15:30:00",
|
||
"cliente": "Librería Castillo"
|
||
}
|
||
```
|
||
|
||
- Label: `"Guardando credenciales..."`
|
||
- **Nunca loguear el contenido de este archivo**
|
||
|
||
### Rollback ante fallo
|
||
|
||
Si cualquier paso de C1 a C9 falla:
|
||
|
||
```python
|
||
async def rollback_cliente(slug: str, sudo_password: str, progress_callback):
|
||
"""
|
||
Intenta deshacer todo lo que se hizo para el cliente.
|
||
No lanza excepciones aunque el rollback falle.
|
||
"""
|
||
# 1. Bajar los contenedores si están corriendo
|
||
await run_cmd(f"docker compose -f /opt/pymesbot/{slug}/docker-compose.yml down 2>/dev/null")
|
||
|
||
# 2. Eliminar la carpeta del cliente
|
||
await run_cmd(f"echo '{sudo_password}' | sudo -S rm -rf /opt/pymesbot/{slug}")
|
||
|
||
# 3. Eliminar el archivo de Nginx
|
||
await run_cmd(f"echo '{sudo_password}' | sudo -S rm -f /etc/nginx/conf.d/{slug}.conf")
|
||
|
||
# 4. Recargar Nginx
|
||
await run_cmd(f"echo '{sudo_password}' | sudo -S systemctl reload nginx 2>/dev/null")
|
||
|
||
# 5. Notificar al frontend
|
||
await progress_callback({
|
||
"type": "rollback_done",
|
||
"message": f"Se revirtieron todos los cambios para el cliente '{slug}'."
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Módulo templates_engine.py — generador de configs
|
||
|
||
Genera todos los archivos de configuración usando Jinja2.
|
||
|
||
```python
|
||
# app/templates_engine.py
|
||
|
||
from jinja2 import Environment, FileSystemLoader
|
||
import os
|
||
|
||
TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "config_templates")
|
||
env = Environment(loader=FileSystemLoader(TEMPLATES_DIR))
|
||
|
||
def generar_env_cliente(config: ClientConfig) -> str:
|
||
"""Genera el contenido del archivo .env del cliente"""
|
||
template = env.get_template("env_client.j2")
|
||
return template.render(
|
||
anthropic_api_key=config.anthropic_api_key,
|
||
cliente_nombre=config.nombre,
|
||
cliente_id=config.slug,
|
||
puerto=config.puerto,
|
||
admin_password=config.admin_password,
|
||
vendor_pin=config.vendor_pin,
|
||
dominio_base=config.dominio_base
|
||
)
|
||
|
||
def generar_docker_compose_cliente(config: ClientConfig) -> str:
|
||
"""Genera el docker-compose.yml del cliente"""
|
||
template = env.get_template("docker-compose.client.yml.j2")
|
||
return template.render(slug=config.slug)
|
||
|
||
def generar_picoclaw_config(config: ClientConfig) -> str:
|
||
"""Genera el config.json de PicoClaw con el system prompt"""
|
||
template = env.get_template("picoclaw_config.json.j2")
|
||
return template.render(
|
||
nombre_negocio=config.nombre,
|
||
rubro=config.rubro
|
||
)
|
||
|
||
def generar_nginx_conf(config: ClientConfig) -> str:
|
||
"""Genera el bloque de Nginx para el subdominio del cliente"""
|
||
template = env.get_template("nginx_site.conf.j2")
|
||
return template.render(
|
||
slug=config.slug,
|
||
dominio=config.dominio_base,
|
||
puerto=config.puerto
|
||
)
|
||
|
||
def escribir_archivo(ruta: str, contenido: str) -> None:
|
||
"""Escribe el contenido en la ruta especificada. Crea directorios si no existen."""
|
||
os.makedirs(os.path.dirname(ruta), exist_ok=True)
|
||
with open(ruta, "w", encoding="utf-8") as f:
|
||
f.write(contenido)
|
||
```
|
||
|
||
---
|
||
|
||
## 10. Backend FastAPI — rutas y WebSocket
|
||
|
||
### Archivo principal: `app/main.py`
|
||
|
||
```python
|
||
# app/main.py
|
||
|
||
import asyncio
|
||
import secrets
|
||
import os
|
||
import time
|
||
from fastapi import FastAPI, WebSocket, HTTPException, Request, Depends
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||
from pydantic import BaseModel
|
||
from typing import Optional
|
||
|
||
from .detect import detectar_entorno
|
||
from .installer import instalar_infraestructura
|
||
from .client_setup import setup_cliente, ClientConfig, rollback_cliente
|
||
from .security import validar_sudo, generar_credenciales, INSTALL_TOKEN
|
||
from .state import get_session, save_session
|
||
|
||
app = FastAPI(title="PymesBot Installer", docs_url=None, redoc_url=None)
|
||
|
||
# Montar archivos estáticos
|
||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||
|
||
# ── TOKEN DE SEGURIDAD ────────────────────────────────────────────────────────
|
||
|
||
@app.on_event("startup")
|
||
async def startup():
|
||
# Imprimir el token en la terminal para que el operador lo vea
|
||
print("\n" + "="*60)
|
||
print(f" 🔐 TOKEN DE ACCESO: {INSTALL_TOKEN}")
|
||
print(f" 🌐 URL: http://TU-SERVIDOR/install?token={INSTALL_TOKEN}")
|
||
print("="*60 + "\n")
|
||
# También guardar en un archivo para referencia
|
||
with open("/tmp/install_token.txt", "w") as f:
|
||
f.write(INSTALL_TOKEN)
|
||
|
||
|
||
# ── RUTAS DEL WIZARD ─────────────────────────────────────────────────────────
|
||
|
||
def verificar_token(request: Request):
|
||
"""Dependencia de FastAPI: verifica el token en query param o en cookie"""
|
||
token = request.query_params.get("token") or request.cookies.get("install_token")
|
||
if token != INSTALL_TOKEN:
|
||
raise HTTPException(status_code=404, detail="No encontrado")
|
||
return token
|
||
|
||
|
||
@app.get("/install")
|
||
async def wizard_html(token: str = Depends(verificar_token)):
|
||
"""Sirve el HTML del wizard"""
|
||
return FileResponse("static/index.html")
|
||
|
||
|
||
@app.get("/install/detect")
|
||
async def api_detect(token: str = Depends(verificar_token)):
|
||
"""Detecta el estado del servidor y devuelve el resultado como JSON"""
|
||
result = await detectar_entorno()
|
||
return result
|
||
|
||
|
||
@app.post("/install/validate-sudo")
|
||
async def api_validate_sudo(body: dict, token: str = Depends(verificar_token)):
|
||
"""
|
||
Valida la contraseña sudo.
|
||
Body: { "password": "..." }
|
||
Response: { "ok": true } | { "ok": false, "error": "..." }
|
||
"""
|
||
password = body.get("password", "")
|
||
if not password:
|
||
return JSONResponse({"ok": False, "error": "Contraseña vacía"})
|
||
|
||
ok, error = await validar_sudo(password)
|
||
if ok:
|
||
# Guardar en la sesión (en memoria, nunca en disco)
|
||
save_session("sudo_password", password)
|
||
return {"ok": True}
|
||
else:
|
||
return JSONResponse({"ok": False, "error": error})
|
||
|
||
|
||
@app.post("/install/check-slug")
|
||
async def api_check_slug(body: dict, token: str = Depends(verificar_token)):
|
||
"""
|
||
Verifica si un slug está disponible.
|
||
Body: { "slug": "castillo" }
|
||
Response: { "disponible": true } | { "disponible": false, "razon": "..." }
|
||
"""
|
||
slug = body.get("slug", "").strip().lower()
|
||
|
||
# Validar formato
|
||
import re
|
||
if not re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', slug) or len(slug) < 3:
|
||
return {"disponible": False, "razon": "El slug debe tener al menos 3 caracteres, solo minúsculas, números y guiones."}
|
||
|
||
# Verificar que no existe
|
||
if os.path.exists(f"/opt/pymesbot/{slug}"):
|
||
return {"disponible": False, "razon": f"Ya existe un cliente con el slug '{slug}'."}
|
||
|
||
return {"disponible": True}
|
||
|
||
|
||
@app.post("/install/start")
|
||
async def api_start_installation(body: dict, token: str = Depends(verificar_token)):
|
||
"""
|
||
Inicia la instalación. Guarda la config en sesión y devuelve el session_id para el WebSocket.
|
||
Body: ClientConfig completo
|
||
Response: { "session_id": "abc123" }
|
||
"""
|
||
sudo_password = get_session("sudo_password")
|
||
if not sudo_password:
|
||
raise HTTPException(status_code=400, detail="Sesión expirada. Volvé al paso de autenticación.")
|
||
|
||
# Generar credenciales aleatorias
|
||
admin_password = secrets.token_urlsafe(8)
|
||
vendor_pin = str(secrets.randbelow(9000) + 1000) # 4 dígitos, no empieza con 0
|
||
|
||
config = ClientConfig(
|
||
slug=body["slug"],
|
||
nombre=body["nombre"],
|
||
email=body.get("email", ""),
|
||
rubro=body.get("rubro", "libreria"),
|
||
moneda=body.get("moneda", "ARS"),
|
||
dominio_base=body["dominio_base"],
|
||
anthropic_api_key=body["anthropic_api_key"],
|
||
sudo_password=sudo_password,
|
||
puerto=body["puerto"], # asignado por el frontend (viene del detect)
|
||
admin_password=admin_password,
|
||
vendor_pin=vendor_pin
|
||
)
|
||
|
||
session_id = secrets.token_hex(8)
|
||
save_session(session_id, {
|
||
"config": config,
|
||
"modo": body.get("modo", "B"),
|
||
"status": "pending"
|
||
})
|
||
|
||
return {"session_id": session_id}
|
||
|
||
|
||
@app.websocket("/install/ws/{session_id}")
|
||
async def ws_progress(websocket: WebSocket, session_id: str):
|
||
"""
|
||
WebSocket de progreso. El frontend se conecta acá para recibir eventos en tiempo real.
|
||
"""
|
||
await websocket.accept()
|
||
|
||
session = get_session(session_id)
|
||
if not session:
|
||
await websocket.send_json({"type": "error", "error": "Sesión no encontrada"})
|
||
await websocket.close()
|
||
return
|
||
|
||
config = session["config"]
|
||
modo = session["modo"]
|
||
|
||
async def progress_callback(evento: dict):
|
||
"""Envía un evento al frontend via WebSocket"""
|
||
try:
|
||
await websocket.send_json(evento)
|
||
except:
|
||
pass # Si el WebSocket se cerró, ignorar
|
||
|
||
try:
|
||
# Modo A: primero instalar infraestructura base, luego el cliente
|
||
if modo == "A":
|
||
await instalar_infraestructura(config, progress_callback)
|
||
|
||
# Siempre dar de alta el cliente (tanto Modo A como Modo B)
|
||
await setup_cliente(config, progress_callback)
|
||
|
||
# Enviar evento final de éxito
|
||
await websocket.send_json({
|
||
"type": "done",
|
||
"url": f"https://{config.slug}.{config.dominio_base}",
|
||
"credentials": {
|
||
"admin_user": "admin",
|
||
"admin_password": config.admin_password,
|
||
"vendor_pin": config.vendor_pin,
|
||
"cliente": config.nombre
|
||
}
|
||
})
|
||
|
||
except Exception as e:
|
||
# Error inesperado: intentar rollback y notificar
|
||
await progress_callback({
|
||
"type": "fatal_error",
|
||
"error": str(e),
|
||
"hint": "Error inesperado. Revisá los logs del instalador."
|
||
})
|
||
# Intentar rollback
|
||
try:
|
||
await rollback_cliente(config.slug, config.sudo_password, progress_callback)
|
||
except:
|
||
pass
|
||
finally:
|
||
await websocket.close()
|
||
```
|
||
|
||
---
|
||
|
||
## 11. El wizard HTML — especificación completa
|
||
|
||
El wizard es un único archivo HTML (`static/index.html`). Contiene HTML, CSS y JavaScript.
|
||
|
||
### Diseño visual
|
||
|
||
**Paleta de colores:**
|
||
```css
|
||
--bg: #080E1A; /* fondo principal */
|
||
--bg2: #0D1526; /* fondo secundario (sidebar, header) */
|
||
--bg3: #111D33; /* fondo terciario (cards, inputs) */
|
||
--border: #1E2D4A; /* bordes */
|
||
--accent: #00D4FF; /* azul cian — color principal */
|
||
--accent2: #00FF9D; /* verde — éxito */
|
||
--warn: #FFB800; /* amarillo — advertencia */
|
||
--err: #FF4757; /* rojo — error */
|
||
--text: #C8D8F0; /* texto principal */
|
||
--text2: #6B89B0; /* texto secundario */
|
||
```
|
||
|
||
**Fuentes:**
|
||
```html
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||
|
||
/* Body: Space Grotesk */
|
||
/* Código, slugs, terminales: IBM Plex Mono */
|
||
```
|
||
|
||
### Layout general
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ ⚙️ PymesBot / installer v1.0.0 │ ← header fijo
|
||
├────────────────┬────────────────────────────┤
|
||
│ │ │
|
||
│ SIDEBAR │ CONTENIDO DEL PASO │
|
||
│ (240px) │ (flexible) │
|
||
│ │ │
|
||
│ ○ Paso 1 │ │
|
||
│ ○ Paso 2 │ │
|
||
│ ● Paso 3 ◄ │ │
|
||
│ ○ Paso 4 │ │
|
||
│ ○ Paso 5 │ │
|
||
│ ○ Paso 6 │ │
|
||
│ │ │
|
||
└────────────────┴────────────────────────────┘
|
||
```
|
||
|
||
Los pasos completados tienen ícono ✓ verde. El paso actual tiene una línea de color en el borde izquierdo y el texto en blanco. Los pasos futuros tienen el número y texto en gris.
|
||
|
||
### PASO 0 — Detección del entorno
|
||
|
||
**Trigger:** se ejecuta automáticamente al cargar la página (si el token es válido).
|
||
|
||
**Mientras carga:**
|
||
- Spinner animado en el centro
|
||
- Texto: `"Analizando el servidor..."`
|
||
|
||
**Cuando termina:**
|
||
- Grid 2x3 de tarjetas, una por componente detectado:
|
||
- Docker ✅ v24.0.7 / ❌ no encontrado
|
||
- Nginx ✅ / ❌
|
||
- Certbot ✅ / ❌
|
||
- /opt/pymesbot ✅ existe / ❌ no existe
|
||
- Red Docker pymesbot_net ✅ / ❌
|
||
- Clientes activos: `N instancias`
|
||
|
||
- Caja de modo detectado (color según el modo):
|
||
- **Modo A** (fondo verde oscuro): `"VPS limpia detectada. Se instalará la infraestructura base y luego el primer cliente."`
|
||
- **Modo B** (fondo azul oscuro): `"Infraestructura detectada. N clientes activos. Se dará de alta un cliente nuevo."`
|
||
|
||
- Botón `[Continuar →]`
|
||
|
||
**API call:** `GET /install/detect`
|
||
|
||
---
|
||
|
||
### PASO 1 — Autenticación sudo
|
||
|
||
**Campos:**
|
||
- Input `type="password"` con label `"Contraseña sudo del servidor"`
|
||
- Texto de ayuda: `"La contraseña se usa solo durante esta sesión para configurar el servidor. No se guarda en ningún archivo."`
|
||
|
||
**Al hacer click en "Verificar permisos":**
|
||
1. Deshabilitar el botón
|
||
2. Mostrar spinner en el botón
|
||
3. `POST /install/validate-sudo` con `{ password: "..." }`
|
||
4. Si responde `{ ok: true }`: avanzar al paso 2
|
||
5. Si responde `{ ok: false }`: mostrar el mensaje de error debajo del input, habilitar el botón
|
||
|
||
---
|
||
|
||
### PASO 2 — Datos del servidor (solo Modo A)
|
||
|
||
**Este paso aparece SOLO si el modo detectado es A.**
|
||
Si es Modo B, saltar directo al paso 3.
|
||
|
||
**Campos:**
|
||
| Campo | Tipo | Placeholder | Validación |
|
||
|-------|------|-------------|-----------|
|
||
| Dominio raíz | text | `rvconsultas.com` | Formato de dominio válido, sin `http://` ni `/` |
|
||
| Email SSL | email | `admin@rvconsultas.com` | Email válido |
|
||
| API Key Anthropic | password | `sk-ant-...` | Debe empezar con `sk-ant-` |
|
||
|
||
**Al continuar:** guardar los valores en memoria del wizard (variables JS).
|
||
|
||
---
|
||
|
||
### PASO 3 — Datos del cliente nuevo
|
||
|
||
**Campos:**
|
||
|
||
**Nombre del negocio** (text, requerido)
|
||
- Al escribir: auto-generar el slug en tiempo real (ver lógica abajo)
|
||
|
||
**Slug** (text, requerido)
|
||
- Se auto-llena mientras el usuario escribe el nombre
|
||
- Editable manualmente
|
||
- Al perder el foco (onblur): `POST /install/check-slug` con el slug
|
||
- Mostrar debajo: `castillo.rvconsultas.com ✓ disponible` (verde) o `❌ ya existe` (rojo)
|
||
|
||
**Lógica de auto-generación del slug (JS):**
|
||
```javascript
|
||
function nombreASlug(nombre) {
|
||
return nombre
|
||
.toLowerCase()
|
||
.replace(/[áàä]/g, 'a').replace(/[éèë]/g, 'e')
|
||
.replace(/[íìï]/g, 'i').replace(/[óòö]/g, 'o')
|
||
.replace(/[úùü]/g, 'u').replace(/ñ/g, 'n')
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-+|-+$/g, '');
|
||
}
|
||
```
|
||
|
||
**Rubro** (select)
|
||
- Librería / Papelería
|
||
- Kiosco
|
||
- Bazar
|
||
- Otro
|
||
|
||
**Moneda** (select)
|
||
- 🇦🇷 ARS — Peso argentino
|
||
- 🇺🇸 USD — Dólar
|
||
- 🇺🇾 UYU — Peso uruguayo
|
||
|
||
**Email del dueño** (email, opcional)
|
||
- Texto de ayuda: `"Se envían las credenciales de acceso a esta dirección."`
|
||
|
||
---
|
||
|
||
### PASO 4 — Confirmación antes de ejecutar
|
||
|
||
**Muestra un resumen de acciones:**
|
||
|
||
Lista numerada de las acciones que se van a realizar (adaptada según el modo):
|
||
|
||
**Modo A:**
|
||
1. Actualizar repositorios del sistema
|
||
2. Instalar Nginx y Certbot
|
||
3. Crear estructura /opt/pymesbot/
|
||
4. Crear red Docker pymesbot_net
|
||
5. Crear estructura del cliente '{nombre}'
|
||
6. Generar archivos de configuración
|
||
7. Inicializar base de datos SQLite
|
||
8. Configurar subdominio Nginx ({slug}.{dominio})
|
||
9. Emitir certificado SSL (requiere que el DNS ya apunte a este servidor)
|
||
10. Levantar contenedores Docker
|
||
11. Verificar que el servicio responde
|
||
|
||
**Modo B:** solo los pasos 5 en adelante.
|
||
|
||
**Checkbox:** `☐ Entiendo que esta operación modificará la configuración del servidor`
|
||
|
||
**Botón "Ejecutar":** deshabilitado hasta que el checkbox esté marcado. Al hacer click:
|
||
1. `POST /install/start` con toda la config
|
||
2. Si responde con `session_id`: avanzar al paso 5 y conectar el WebSocket
|
||
|
||
---
|
||
|
||
### PASO 5 — Ejecución con progreso en tiempo real
|
||
|
||
**Barra de progreso** (0% → 100%, animada)
|
||
|
||
**Lista de pasos** — se van actualizando en tiempo real con el WebSocket:
|
||
- ⬜ paso pendiente (gris)
|
||
- 🔄 paso en ejecución (ícono girando animado)
|
||
- ✅ paso completado (verde + duración en ms)
|
||
- ❌ paso con error (rojo + mensaje de error expandible + hint)
|
||
|
||
**Panel de output** (colapsable, debajo de cada paso):
|
||
- Fondo oscuro tipo terminal
|
||
- Muestra el output raw del comando (los eventos `type: "output"`)
|
||
- El usuario puede hacer click en "ver detalles" para expandirlo
|
||
|
||
**Si hay un error:**
|
||
- El paso se marca con ❌
|
||
- Aparece el mensaje de error y el hint en rojo
|
||
- Aparece un botón `[Reintentar desde este paso]`
|
||
- La barra de progreso se detiene
|
||
|
||
**WebSocket:**
|
||
```javascript
|
||
const ws = new WebSocket(`ws://${location.host}/install/ws/${sessionId}`);
|
||
|
||
ws.onmessage = (event) => {
|
||
const data = JSON.parse(event.data);
|
||
switch(data.type) {
|
||
case 'step_start': // mostrar paso como 🔄
|
||
case 'output': // agregar línea al panel de output de ese paso
|
||
case 'step_done': // marcar como ✅ con la duración
|
||
case 'step_error': // marcar como ❌, mostrar error y hint
|
||
case 'done': // avanzar automáticamente al paso 6
|
||
case 'fatal_error': // mostrar error crítico con opción de reintentar todo
|
||
}
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
### PASO 6 — Resumen final (éxito)
|
||
|
||
**Animación de entrada:** el número del check animado con un efecto de escala
|
||
|
||
**Contenido:**
|
||
- `🎉` grande animado
|
||
- `"¡{NOMBRE} está activo!"`
|
||
- Link clickeable: `https://{slug}.{dominio}` (se abre en nueva pestaña)
|
||
|
||
**Tarjetas de credenciales** (3 en fila):
|
||
- Usuario admin: `admin`
|
||
- Contraseña: `Xk7mQ2p` (con botón de copiar individual)
|
||
- PIN vendedor: `4821`
|
||
|
||
**Advertencia destacada** (fondo amarillo):
|
||
```
|
||
⚠️ Cerrá el instalador cuando termines:
|
||
docker compose down
|
||
```
|
||
|
||
**Botones:**
|
||
- `[📋 Copiar todas las credenciales]` → copia un texto formateado al portapapeles
|
||
- `[📧 Enviar por email]` → si se configuró un email en el paso 3 (sino, deshabilitado)
|
||
- `[⬇️ Descargar resumen]` → descarga un `.txt` con las credenciales
|
||
|
||
**Al fondo (separado):**
|
||
- `[➕ Dar de alta otro cliente]` → vuelve al paso 3 con todo limpio
|
||
|
||
---
|
||
|
||
## 12. Templates Jinja2 que genera el instalador
|
||
|
||
### `env_client.j2`
|
||
|
||
```jinja2
|
||
# Generado automáticamente por PymesBot Installer
|
||
# No editar manualmente. Regenerar con el instalador.
|
||
|
||
ANTHROPIC_API_KEY={{ anthropic_api_key }}
|
||
CLIENTE_NOMBRE={{ cliente_nombre }}
|
||
CLIENTE_ID={{ cliente_id }}
|
||
PUERTO={{ puerto }}
|
||
ADMIN_PASSWORD={{ admin_password }}
|
||
VENDOR_PIN={{ vendor_pin }}
|
||
DOMINIO_BASE={{ dominio_base }}
|
||
```
|
||
|
||
### `docker-compose.client.yml.j2`
|
||
|
||
```jinja2
|
||
version: '3.8'
|
||
|
||
services:
|
||
|
||
backend_{{ slug }}:
|
||
build:
|
||
context: ./backend
|
||
dockerfile: Dockerfile
|
||
container_name: pymesbot_{{ slug }}
|
||
restart: always
|
||
ports:
|
||
- "${PUERTO}:8000"
|
||
volumes:
|
||
- ./data:/app/data
|
||
env_file:
|
||
- .env
|
||
networks:
|
||
- pymesbot_net
|
||
healthcheck:
|
||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||
interval: 30s
|
||
timeout: 10s
|
||
retries: 3
|
||
|
||
picoclaw_{{ slug }}:
|
||
image: ghcr.io/sipeed/picoclaw:latest
|
||
container_name: picoclaw_{{ slug }}
|
||
restart: always
|
||
volumes:
|
||
- ./picoclaw:/root/.picoclaw
|
||
- ./data:/data:ro
|
||
depends_on:
|
||
backend_{{ slug }}:
|
||
condition: service_healthy
|
||
networks:
|
||
- pymesbot_net
|
||
environment:
|
||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||
|
||
networks:
|
||
pymesbot_net:
|
||
external: true
|
||
```
|
||
|
||
### `picoclaw_config.json.j2`
|
||
|
||
```jinja2
|
||
{
|
||
"agents": {
|
||
"defaults": {
|
||
"model": "claude-sonnet-4-5-20250929",
|
||
"max_tokens": 1000,
|
||
"temperature": 0.3,
|
||
"max_tool_iterations": 5,
|
||
"system": "Sos el asistente de ventas de {{ nombre_negocio }}, una {{ rubro }} en Argentina.\nTu trabajo es ayudar al vendedor a atender clientes rápido y con información exacta.\n\nHERRAMIENTAS DISPONIBLES:\n- buscar_stock: busca productos por nombre\n- confirmar_venta: registra una venta y descuenta el stock\n- armar_combo: genera lista de productos para un presupuesto\n- ver_promociones: muestra las promos activas de hoy\n\nREGLAS OBLIGATORIAS:\n1. NUNCA des precios aproximados. Siempre usá buscar_stock.\n2. Si no hay stock, ofrecé siempre la alternativa más cercana.\n3. Al armar combos: preguntá presupuesto, edad, género (optativo).\n4. Al final de cada consulta: preguntá si se concretó la venta.\n5. Si confirman la venta: llamá a confirmar_venta inmediatamente.\n6. Respondé en español argentino coloquial pero profesional.\n7. Sé conciso: máximo 3-4 líneas por respuesta.\n8. Nunca menciones otros negocios ni competidores."
|
||
}
|
||
},
|
||
"providers": {
|
||
"anthropic": {
|
||
"api_key": "${ANTHROPIC_API_KEY}"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### `nginx_site.conf.j2`
|
||
|
||
```jinja2
|
||
server {
|
||
listen 80;
|
||
server_name {{ slug }}.{{ dominio }};
|
||
|
||
location / {
|
||
proxy_pass http://127.0.0.1:{{ puerto }};
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Upgrade $http_upgrade;
|
||
proxy_set_header Connection "upgrade";
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
proxy_cache_bypass $http_upgrade;
|
||
proxy_read_timeout 300s;
|
||
proxy_send_timeout 300s;
|
||
}
|
||
}
|
||
```
|
||
|
||
### `schema.sql` (no es template Jinja2, es SQL puro)
|
||
|
||
Ver la sección 7 del documento `01_PYMESBOT_PROJECT_SPEC.md` para el SQL completo.
|
||
|
||
---
|
||
|
||
## 13. Seguridad
|
||
|
||
### Token de acceso (`app/security.py`)
|
||
|
||
```python
|
||
# app/security.py
|
||
import secrets
|
||
import os
|
||
|
||
# Generar token al importar el módulo (una vez al arrancar el contenedor)
|
||
INSTALL_TOKEN = secrets.token_urlsafe(16)
|
||
|
||
async def validar_sudo(password: str) -> tuple[bool, str]:
|
||
"""
|
||
Verifica que la contraseña sudo sea correcta.
|
||
Usa 'sudo -n' primero para no lockear la cuenta.
|
||
Returns: (ok: bool, error_msg: str)
|
||
"""
|
||
from .detect import run_cmd
|
||
|
||
# Verificar con un comando inofensivo: whoami
|
||
rc, stdout, stderr = await run_cmd(
|
||
f"echo '{password}' | sudo -S whoami",
|
||
timeout=10
|
||
)
|
||
|
||
if rc == 0 and stdout.strip() == "root":
|
||
return True, ""
|
||
else:
|
||
if "incorrect password" in stderr.lower() or "no passwd" in stderr.lower():
|
||
return False, "Contraseña incorrecta"
|
||
elif "not in the sudoers" in stderr.lower():
|
||
return False, "Este usuario no tiene permisos sudo"
|
||
else:
|
||
return False, f"Error al verificar: {stderr.strip()}"
|
||
```
|
||
|
||
### Estado de sesión (`app/state.py`)
|
||
|
||
```python
|
||
# app/state.py
|
||
# Estado en memoria. Se pierde al reiniciar el contenedor.
|
||
# NUNCA persistir la contraseña sudo en disco.
|
||
|
||
import time
|
||
from typing import Any, Optional
|
||
|
||
_store: dict = {}
|
||
_timestamps: dict = {}
|
||
SESSION_TIMEOUT = 1800 # 30 minutos
|
||
|
||
def save_session(key: str, value: Any) -> None:
|
||
_store[key] = value
|
||
_timestamps[key] = time.time()
|
||
|
||
def get_session(key: str) -> Optional[Any]:
|
||
if key not in _store:
|
||
return None
|
||
# Verificar expiración
|
||
if time.time() - _timestamps[key] > SESSION_TIMEOUT:
|
||
del _store[key]
|
||
del _timestamps[key]
|
||
return None
|
||
return _store[key]
|
||
|
||
def clear_session(key: str) -> None:
|
||
_store.pop(key, None)
|
||
_timestamps.pop(key, None)
|
||
```
|
||
|
||
### Reglas de seguridad del instalador
|
||
|
||
1. **La contraseña sudo NUNCA se loguea.** Ni en `print()`, ni en logs de uvicorn, ni en archivos. Usar `{SUDO_PASSWORD}` en comandos sin loguear la variable.
|
||
2. **El token de acceso se regenera cada vez que el contenedor arranca.** No hay forma de reutilizar un token viejo.
|
||
3. **Sin token, `/install` devuelve 404**, no 401 ni 403. No revelar que existe el endpoint.
|
||
4. **El instalador no escucha en puertos que no sea el 80.** Nginx del host tampoco debe proxear el puerto del instalador una vez que termina.
|
||
5. **`restart: "no"` en el docker-compose.** El instalador no se reinicia solo.
|
||
6. **No hay endpoint de API sin verificación de token.** Absolutamente todos los endpoints verifican el token.
|
||
|
||
---
|
||
|
||
## 14. Testing y validación
|
||
|
||
### Cómo testear el instalador localmente
|
||
|
||
Para testear sin un servidor real, usar Docker-in-Docker:
|
||
|
||
```bash
|
||
# Levantar un Ubuntu limpio con Docker disponible
|
||
docker run -it --rm \
|
||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||
-p 8080:8000 \
|
||
ubuntu:24.04 bash
|
||
|
||
# Dentro del contenedor: instalar Docker y Python
|
||
apt-get update && apt-get install -y docker.io python3 python3-pip curl
|
||
|
||
# Simular que es un VPS limpio
|
||
# (docker ya está, nginx y certbot no)
|
||
|
||
# Desde afuera: levantar el instalador apuntando al Ubuntu
|
||
docker compose up -d
|
||
# Entrar a http://localhost:8080/install?token=...
|
||
```
|
||
|
||
### Checklist de validación antes de marcar como completo
|
||
|
||
**Detección:**
|
||
- [ ] Detecta correctamente cuando Docker no está instalado
|
||
- [ ] Detecta correctamente cuando Nginx sí está instalado
|
||
- [ ] Lista correctamente los clientes existentes en /opt/pymesbot
|
||
- [ ] Determina el modo correcto (A, B o C)
|
||
- [ ] Encuentra el próximo puerto disponible correctamente
|
||
|
||
**Wizard UI:**
|
||
- [ ] El spinner de detección aparece y desaparece
|
||
- [ ] Los 6 pasos del sidebar se marcan correctamente
|
||
- [ ] El auto-complete del slug funciona con acentos y ñ
|
||
- [ ] La verificación de slug disponible funciona en tiempo real
|
||
- [ ] El checkbox de confirmación habilita el botón de ejecutar
|
||
- [ ] El WebSocket muestra el progreso en tiempo real
|
||
- [ ] Los errores de steps muestran el hint correcto
|
||
- [ ] Las credenciales se muestran en el paso 6
|
||
|
||
**Seguridad:**
|
||
- [ ] Sin token en la URL, `/install` devuelve 404
|
||
- [ ] Con token incorrecto, `/install` devuelve 404
|
||
- [ ] La contraseña sudo no aparece en los logs de uvicorn
|
||
- [ ] La contraseña sudo no se guarda en ningún archivo
|
||
|
||
**Idempotencia:**
|
||
- [ ] Correr el instalador dos veces para el mismo slug falla graciosamente con Modo C
|
||
- [ ] Crear la red Docker cuando ya existe no falla
|
||
- [ ] `mkdir -p` no falla si la carpeta ya existe
|
||
|
||
---
|
||
|
||
## 15. Orden de construcción recomendado
|
||
|
||
Construir en este orden exacto. Cada paso debe funcionar antes de seguir con el siguiente.
|
||
|
||
### Sprint 1: Fundación (empezar acá)
|
||
|
||
1. **`detect.py`**: implementar `detectar_entorno()` y `run_cmd()`. Testear localmente que detecta bien Docker y Nginx.
|
||
2. **`main.py` esqueleto**: FastAPI con las rutas `/install`, `/install/detect`, y el token de seguridad. Probar que sin token devuelve 404.
|
||
3. **`static/index.html`** paso 0: solo el paso de detección. Que llame a `/install/detect` y muestre las tarjetas. Sin el resto del wizard.
|
||
|
||
### Sprint 2: Wizard completo (sin ejecutar nada real)
|
||
|
||
4. Completar los **6 pasos del wizard** con datos hardcodeados o mock. El wizard debe navegar entre pasos, validar campos, mostrar/ocultar según el modo.
|
||
5. Conectar el **WebSocket** de progreso con datos mock (simular que los pasos pasan de ⬜ a 🔄 a ✅ con delays).
|
||
|
||
### Sprint 3: Backend real
|
||
|
||
6. **`security.py`**: validación sudo real con `run_cmd`.
|
||
7. **`templates_engine.py`**: generar los 4 archivos de configuración. Testear que el output es correcto para un cliente de ejemplo.
|
||
8. **`client_setup.py`**: implementar los 9 pasos de C1 a C9. Testear en un servidor de prueba real.
|
||
9. **`installer.py`**: implementar los 5 pasos de A1 a A5. Testear en un Ubuntu limpio.
|
||
|
||
### Sprint 4: Integración y pulido
|
||
|
||
10. Conectar el wizard real con el backend real (reemplazar los mocks del Sprint 2).
|
||
11. Implementar el **rollback** completo.
|
||
12. Pasar el checklist de validación de la sección 14.
|
||
13. Construir la imagen Docker y publicar en el registry.
|
||
|
||
---
|
||
|
||
*Fin del documento. Versión 1.0 — PymesBot Installer Spec*
|