dev #44

Merged
KYRBOT merged 7 commits from dev into main 2026-07-11 18:12:16 +00:00
67 changed files with 3779 additions and 1005 deletions
Showing only changes of commit e8653c1177 - Show all commits

113
.gitea/workflows/deploy.yml Normal file
View File

@@ -0,0 +1,113 @@
name: Build, MSI & Deploy
on:
push:
branches: [main, develop]
env:
GITEA_REGISTRY: build.omnioil.app:3005
jobs:
build-linux:
name: Build Docker images
runs-on: contabo-runner
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker login
run: docker login ${{ env.GITEA_REGISTRY }} -u ${{ secrets.GITEA_DEPLOY_USER }} -p ${{ secrets.GITEA_DEPLOY_TOKEN }}
- name: Build images
run: docker compose -f docker-compose.yml build
- name: Push to registry
run: docker compose push
build-msi:
name: Build Edge Agent MSI
runs-on: windows
needs: [build-linux]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cross-compile Edge Agent
run: |
cargo build --release --target x86_64-pc-windows-gnu -p edge-agent
- name: Build MSI
run: |
candle.exe services/edge-agent/installer/edge-agent.wxs
light.exe edge-agent.wixobj -o edge-agent.msi
- name: Upload MSI to MinIO
run: |
aws s3 cp edge-agent.msi s3://omnioil-releases/edge-agent-latest.msi
aws s3 cp edge-agent.msi s3://omnioil-releases/edge-agent-${{ github.sha }}.msi
env:
AWS_ENDPOINT_URL: ${{ secrets.MINIO_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.MINIO_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.MINIO_SECRET_KEY }}
deploy-primary:
name: Deploy to Production
runs-on: contabo-runner
needs: [build-linux]
# Solo deploy automático en main, no en develop
if: github.ref_name == 'main'
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_A_HOST }}
username: ${{ secrets.PROD_A_USER }}
key: ${{ secrets.PROD_A_SSH_KEY }}
script: |
cd /opt/omnioil
docker login ${{ env.GITEA_REGISTRY }} -u ${{ secrets.GITEA_DEPLOY_USER }} -p ${{ secrets.GITEA_DEPLOY_TOKEN }}
docker compose pull
docker compose up -d --remove-orphans
echo "Esperando health checks..."
for i in $(seq 1 12); do
if curl -sf http://localhost:8000/health > /dev/null 2>&1; then
echo "Backend API healthy"
break
fi
sleep 5
done
curl -sf http://localhost:8080/health && echo "Health endpoint OK"
sync-standby:
name: Sync Standby server
runs-on: contabo-runner
needs: [build-linux]
steps:
- name: Pull images on Standby
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_B_HOST }}
username: ${{ secrets.PROD_B_USER }}
key: ${{ secrets.PROD_B_SSH_KEY }}
script: |
cd /opt/omnioil
docker login ${{ env.GITEA_REGISTRY }} -u ${{ secrets.GITEA_DEPLOY_USER }} -p ${{ secrets.GITEA_DEPLOY_TOKEN }}
docker compose pull
notify:
name: Send notification
runs-on: contabo-runner
needs: [deploy-primary, build-msi]
if: always() && github.ref_name == 'main'
steps:
- name: Notify result
uses: appleboy/telegram-action@master
with:
to: ${{ secrets.TELEGRAM_CHAT_ID }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
message: |
🚀 *OmniOil Deploy* (${{ github.repository }})
*Branch:* ${{ github.ref_name }}
*Commit:* ${{ github.sha }}
*Build:* ${{ needs.build-linux.result }}
*Deploy:* ${{ needs.deploy-primary.result }}
*MSI:* ${{ needs.build-msi.result }}

View File

@@ -43,8 +43,6 @@ services:
ports: ports:
- "1025:1025" # SMTP - "1025:1025" # SMTP
- "8025:8025" # Web UI - "8025:8025" # Web UI
networks:
- anh_network
# Exponer frontend para ver la UI localmente # Exponer frontend para ver la UI localmente
landing: landing:

View File

@@ -15,8 +15,6 @@ services:
MQTT_PASSWORD: ${MQTT_PASSWORD} MQTT_PASSWORD: ${MQTT_PASSWORD}
volumes: volumes:
- ${TIMESCALEDB_DATA:-timescaledb_data}:/var/lib/postgresql/data - ${TIMESCALEDB_DATA:-timescaledb_data}:/var/lib/postgresql/data
networks:
- anh_network
healthcheck: healthcheck:
test: [ "CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}" ] test: [ "CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}" ]
interval: 5s interval: 5s
@@ -31,8 +29,6 @@ services:
command: [ "redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes" ] command: [ "redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes" ]
volumes: volumes:
- ${REDIS_DATA:-redis_data}:/data - ${REDIS_DATA:-redis_data}:/data
networks:
- anh_network
minio: minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1 image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1
@@ -45,8 +41,6 @@ services:
environment: environment:
MINIO_ROOT_USER: ${MINIO_USER} MINIO_ROOT_USER: ${MINIO_USER}
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD} MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}
networks:
- anh_network
volumes: volumes:
- ${MINIO_DATA:-minio_data}:/data - ${MINIO_DATA:-minio_data}:/data
@@ -95,8 +89,6 @@ services:
condition: service_started condition: service_started
mosquitto: mosquitto:
condition: service_started condition: service_started
networks:
- anh_network
healthcheck: healthcheck:
test: [ "CMD-SHELL", "curl -fsS http://localhost:8000/health >/dev/null || exit 1" ] test: [ "CMD-SHELL", "curl -fsS http://localhost:8000/health >/dev/null || exit 1" ]
interval: 5s interval: 5s
@@ -121,8 +113,6 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_started condition: service_started
networks:
- anh_network
telemetry-ingestor: telemetry-ingestor:
container_name: ${CONTAINER_PREFIX:-anh}_telemetry_ingestor container_name: ${CONTAINER_PREFIX:-anh}_telemetry_ingestor
@@ -144,8 +134,6 @@ services:
condition: service_healthy condition: service_healthy
mosquitto: mosquitto:
condition: service_started condition: service_started
networks:
- anh_network
json-generator: json-generator:
container_name: ${CONTAINER_PREFIX:-anh}_json_generator container_name: ${CONTAINER_PREFIX:-anh}_json_generator
@@ -169,8 +157,6 @@ services:
condition: service_healthy condition: service_healthy
minio: minio:
condition: service_started condition: service_started
networks:
- anh_network
landing: landing:
container_name: ${CONTAINER_PREFIX:-anh}_landing container_name: ${CONTAINER_PREFIX:-anh}_landing
@@ -184,8 +170,6 @@ services:
- LANDING_API_PROXY_TARGET=${LANDING_API_PROXY_TARGET:-http://backend-api:8000} - LANDING_API_PROXY_TARGET=${LANDING_API_PROXY_TARGET:-http://backend-api:8000}
depends_on: depends_on:
- backend-api - backend-api
networks:
- anh_network
frontend-pwa: frontend-pwa:
container_name: ${CONTAINER_PREFIX:-anh}_frontend_pwa container_name: ${CONTAINER_PREFIX:-anh}_frontend_pwa
@@ -201,8 +185,6 @@ services:
- VITE_API_URL=${VITE_API_URL} - VITE_API_URL=${VITE_API_URL}
depends_on: depends_on:
- backend-api - backend-api
networks:
- anh_network
frontend-admin: frontend-admin:
container_name: ${CONTAINER_PREFIX:-anh}_frontend_admin container_name: ${CONTAINER_PREFIX:-anh}_frontend_admin
@@ -220,8 +202,6 @@ services:
- VITE_LANDING_APP_ORIGIN=${VITE_LANDING_APP_ORIGIN:-https://omnioil.app/} - VITE_LANDING_APP_ORIGIN=${VITE_LANDING_APP_ORIGIN:-https://omnioil.app/}
depends_on: depends_on:
- backend-api - backend-api
networks:
- anh_network
frontend-console: frontend-console:
container_name: ${CONTAINER_PREFIX:-anh}_frontend_console container_name: ${CONTAINER_PREFIX:-anh}_frontend_console
@@ -238,8 +218,6 @@ services:
- "80" - "80"
depends_on: depends_on:
- backend-api - backend-api
networks:
- anh_network
mosquitto: mosquitto:
container_name: ${CONTAINER_PREFIX:-anh}_mosquitto container_name: ${CONTAINER_PREFIX:-anh}_mosquitto
@@ -256,8 +234,6 @@ services:
volumes: volumes:
- ${MOSQUITTO_DATA:-mosquitto_data}:/mosquitto/data - ${MOSQUITTO_DATA:-mosquitto_data}:/mosquitto/data
- ${MOSQUITTO_LOG:-mosquitto_log}:/mosquitto/log - ${MOSQUITTO_LOG:-mosquitto_log}:/mosquitto/log
networks:
- anh_network
depends_on: depends_on:
timescaledb: timescaledb:
condition: service_healthy condition: service_healthy
@@ -299,8 +275,6 @@ services:
condition: service_healthy condition: service_healthy
mosquitto: mosquitto:
condition: service_started condition: service_started
networks:
- anh_network
edge-agent-msi-builder: edge-agent-msi-builder:
container_name: ${CONTAINER_PREFIX:-anh}_msi_builder container_name: ${CONTAINER_PREFIX:-anh}_msi_builder
@@ -312,8 +286,6 @@ services:
volumes: volumes:
- ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming - ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming
- ${INSTALLER_OUTPUT:-installer_output}:/app/services/edge-agent/output - ${INSTALLER_OUTPUT:-installer_output}:/app/services/edge-agent/output
networks:
- anh_network
prometheus: prometheus:
image: prom/prometheus:v3.4.0 image: prom/prometheus:v3.4.0
@@ -328,8 +300,6 @@ services:
- ./infrastructure/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./infrastructure/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./infrastructure/monitoring/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro - ./infrastructure/monitoring/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro
- prometheus_data:/prometheus - prometheus_data:/prometheus
networks:
- anh_network
depends_on: depends_on:
- backend-api - backend-api
@@ -350,8 +320,6 @@ services:
- grafana_data:/var/lib/grafana - grafana_data:/var/lib/grafana
- ./infrastructure/monitoring/grafana/provisioning:/etc/grafana/provisioning:ro - ./infrastructure/monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./infrastructure/monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro - ./infrastructure/monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
networks:
- anh_network
depends_on: depends_on:
- prometheus - prometheus
@@ -363,8 +331,6 @@ services:
volumes: volumes:
- ./infrastructure/monitoring/loki-config.yml:/etc/loki/loki-config.yml:ro - ./infrastructure/monitoring/loki-config.yml:/etc/loki/loki-config.yml:ro
- loki_data:/loki - loki_data:/loki
networks:
- anh_network
promtail: promtail:
image: grafana/promtail:3.6.0 image: grafana/promtail:3.6.0
@@ -375,8 +341,6 @@ services:
- ./infrastructure/monitoring/promtail-config.yml:/etc/promtail/promtail-config.yml:ro - ./infrastructure/monitoring/promtail-config.yml:/etc/promtail/promtail-config.yml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro - /var/lib/docker/containers:/var/lib/docker/containers:ro
networks:
- anh_network
depends_on: depends_on:
- loki - loki
@@ -390,8 +354,6 @@ services:
volumes: volumes:
- ./infrastructure/monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro - ./infrastructure/monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- alertmanager_data:/alertmanager - alertmanager_data:/alertmanager
networks:
- anh_network
node-exporter: node-exporter:
image: prom/node-exporter:v1.9.1 image: prom/node-exporter:v1.9.1

197
docs/INFRAESTRUCTURA.md Normal file
View File

@@ -0,0 +1,197 @@
# Infraestructura OmniOil SCADA
## Arquitectura General
```
3x Contabo VPS M (6 vCPU, 16GB RAM, 400GB SSD) + Windows Local
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Contabo BUILD │ │ Contabo PROD A │ │ Contabo PROD B │
│ (Gitea + CI) │ │ (PRIMARY) │ │ (STANDBY) │
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ Gitea + Registry │ │ Dokploy │ │ Dokploy │
│ Gitea Actions │ │ TimescaleDB PRI │ │ TimescaleDB REP │
│ Runner (Linux) │ │ MinIO source │ │ MinIO target │
│ Docker (builds) │ │ Mosquitto │ │ FAILOVER-AGENT │
└─────────────────┘ │ Backend API │ │ (monitorea A) │
│ Frontends (4) │ │ │
┌─────────────────┐ │ Nginx Gateway │ │ [todos detenidos │
│ Windows (Local) │ │ Monitoring stack │ │ hasta failover] │
│ Gitea Runner │ │ Health endpoint │ │ Health endpoint │
│ Rust x-compile │ └─────────────────┘ └─────────────────┘
│ WiX Toolset │ Cloudflare LB (health checks c/30s)
│ MSI → MinIO S3 │
└─────────────────┘
```
## Servidores
### Build Server (Contabo VPS M)
- **Propósito**: Gitea + Container Registry + Actions Runner
- **IP**: `build.omnioil.app`
- **Especificación**: 6 vCPU, 16GB RAM, 400GB SSD
- **Servicios**: Docker, Gitea, Gitea Actions Runner
### Production Primary (Contabo VPS M)
- **Propósito**: Todos los servicios activos
- **IP**: `server-a.omnioil.app`
- **Especificación**: 6 vCPU, 16GB RAM, 400GB SSD
- **Servicios**: Dokploy + todos los contenedores
### Production Standby (Contabo VPS M)
- **Propósito**: Failover automático
- **IP**: `server-b.omnioil.app`
- **Especificación**: 6 vCPU, 16GB RAM, 400GB SSD
- **Servicios**: Dokploy + replicas de datos + failover-agent
### Windows Local
- **Propósito**: Compilación MSI del Edge Agent
- **Software**: Rust, WiX Toolset, Gitea Actions Runner
## Componentes
### Failover-Agent
Binario Rust que corre en Server B (standby) y monitorea Server A.
**Ciclo de vida:**
1. **MONITORING** — Cada 10s verifica health endpoint de Server A
2. **PROMOTING** — Si Server A falla 12 checks (~2min):
- Promueve TimescaleDB (`pg_promote()`)
- Inicia todos los servicios (`docker compose up -d`)
- Notifica por Telegram y Email
3. **FAILED_OVER** — Server B activo, espera failback manual
4. **ERROR** — Cualquier fallo notifica inmediatamente
**Protección split-brain:**
- Lock en MinIO (TTL 1 hora)
- Verifica conectividad ICMP + HTTP
- No auto-failback (evita flapping)
### Cloudflare
- **DNS**: Todos los subdominios apuntando al Load Balancer
- **Load Balancer**: Pool A (primary) + Pool B (standby)
- **Health Checks**: HTTP cada 30s al endpoint `:8080/health`
- **SSL**: Proxy habilitado (naranja) en todos los dominios
- **Failover**: Automático cuando Server A falla 3 health checks
### Dokploy
- Panel de gestión de Docker Compose
- SSL nativo con Let's Encrypt
- Webhooks para auto-deploy desde Gitea
- Cada servidor tiene su propio Dokploy (independientes)
## Flujo de CI/CD
```
Developer push → Gitea (main/develop)
Gitea Actions Pipeline (.gitea/workflows/deploy.yml)
├── Job 1: build-linux (Contabo runner)
│ ├── docker compose build
│ └── docker compose push → Gitea Registry
├── Job 2: build-msi (Windows runner local)
│ ├── cargo build --target x86_64-pc-windows-gnu -p edge-agent
│ ├── WiX → edge-agent.msi
│ └── aws s3 cp → MinIO (omnioil-releases/)
├── Job 3: deploy-primary (SSH → Server A)
│ ├── docker login (Gitea Registry)
│ ├── docker compose pull
│ ├── docker compose up -d
│ └── curl health check verification
└── Job 4: sync-standby (SSH → Server B)
├── docker compose pull
└── (solo cachea imágenes, no inicia servicios)
```
## Failover
### Estado Normal
- Server A corre todos los servicios
- Server B corre solo:
- TimescaleDB (replica streaming)
- MinIO (recibiendo replicación)
- Failover-agent (monitoreando)
- Health endpoint
### Failover Automático
```
1. Server A deja de responder health endpoint
2. Cloudflare detecta falla (3 checks = 90s)
3. Cloudflare LB redirige tráfico → Server B
4. Failover-agent en B detecta caída (~2 min)
5. `SELECT pg_promote()` → TimescaleDB ahora es primary
6. `docker compose up -d` → todos los servicios inician
7. Notificación: Telegram + Email
8. Tiempo total: ~3-4 minutos
```
### Failback Manual
```
1. Server A se recupera
2. Verificar que la data está consistente
3. Ejecutar `scripts/failback.sh`
- Detiene servicios en B (excepto datos)
- Reconstruye A como réplica desde B
- Promueve A a primary
- Re-inicia replicación
4. Cloudflare re-rutea a A
```
## Costos Mensuales
| Recurso | Proveedor | Costo |
|---------|-----------|-------|
| Build Server | Contabo VPS M | ~€25 |
| Prod Server A | Contabo VPS M | ~€25 |
| Prod Server B | Contabo VPS M | ~€25 |
| Windows Builder | Local | €0 |
| Cloudflare | Free Tier | €0 |
| **Total** | | **~€75/mes** |
## Scripts
| Script | Propósito |
|--------|-----------|
| `scripts/cloudflare-setup.sh` | DNS records + Load Balancer |
| `scripts/vps-bootstrap.sh` | Server A: Docker + Dokploy + deploy |
| `scripts/vps-standby-bootstrap.sh` | Server B: Docker + replicas + watchdog |
| `scripts/deploy.sh` | CI/CD deploy |
| `scripts/failback.sh` | Reversa de failover |
| `scripts/windows-runner-setup.ps1` | Setup Windows runner |
## Puertos
| Puerto | Servicio | Uso |
|--------|----------|-----|
| 22 | SSH | Administración |
| 80 | HTTP | Redirect a HTTPS |
| 443 | HTTPS | Nginx gateway |
| 1883 | MQTT | Mosquitto interno |
| 9883 | MQTT+WSS | Mosquitto público |
| 3000 | Dokploy | Panel de gestión |
| 8080 | Health | Cloudflare health checks |
## Variables de Entorno Clave
| Variable | Descripción |
|----------|-------------|
| `DB_USER` | Usuario PostgreSQL |
| `DB_PASSWORD` | Password PostgreSQL |
| `JWT_SECRET` | JWT signing secret |
| `MQTT_USER` | Usuario MQTT |
| `MQTT_PASSWORD` | Password MQTT |
| `TELEGRAM_BOT_TOKEN` | Bot token para notificaciones |
| `TELEGRAM_CHAT_ID` | Chat ID para notificaciones |
| `PRIMARY_HOST` | IP del servidor primario (para failover-agent) |
## Referencias
- [DESPLIEGUE.md](./DESPLIEGUE.md) — Guía de despliegue manual
- [SEGURIDAD.md](./SEGURIDAD.md) — Políticas de seguridad
- `services/failover-agent/` — Código del watchdog en Rust
- `infrastructure/health/` — Health endpoint para Cloudflare

View File

@@ -0,0 +1,3 @@
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080

View File

@@ -0,0 +1,14 @@
server {
listen 8080;
server_name _;
location /health {
return 200 "OK\n";
add_header Content-Type text/plain;
add_header Cache-Control no-cache;
}
location / {
return 404;
}
}

151
scripts/cloudflare-setup.sh Normal file
View File

@@ -0,0 +1,151 @@
#!/usr/bin/env bash
# =============================================================================
# cloudflare-setup.sh — Configura DNS y Load Balancer en Cloudflare
# =============================================================================
# Uso: ./cloudflare-setup.sh <dominio> <ip-server-a> <ip-server-b>
# Ejemplo: ./cloudflare-setup.sh omnioil.app 1.2.3.4 5.6.7.8
# =============================================================================
set -euo pipefail
DOMAIN="${1:?Uso: $0 <dominio> <ip-server-a> <ip-server-b>}"
IP_A="${2:?Uso: $0 <dominio> <ip-server-a> <ip-server-b>}"
IP_B="${3:?Uso: $0 <dominio> <ip-server-a> <ip-server-b>}"
CLOUDFLARE_API="${CLOUDFLARE_API:-https://api.cloudflare.com/client/v4}"
CF_TOKEN="${CF_TOKEN:?Variable CF_TOKEN requerida}"
CF_ZONE_ID="${CF_ZONE_ID:?Variable CF_ZONE_ID requerida}"
echo "=== Configurando Cloudflare para $DOMAIN ==="
# Subdominios a crear
SUBDOMAINS=(
"@"
"www"
"app"
"admin"
"console"
"mobile"
"campo"
"api"
"build"
)
create_dns_record() {
local name="$1" content="$2" type="${3:-A}" proxied="${4:-true}"
echo " DNS $type: $name.$DOMAIN -> $content (proxy=$proxied)"
curl -s -X POST "$CLOUDFLARE_API/zones/$CF_ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"type\":\"$type\",\"name\":\"$name\",\"content\":\"$content\",\"proxied\":$proxied}" > /dev/null
}
echo ""
echo "1. Creando registros DNS apuntando al Load Balancer..."
# Los subdominios principales apuntan a Server A (el LB decidirá)
for sub in "${SUBDOMAINS[@]}"; do
create_dns_record "$sub" "$IP_A" "A" true
done
echo ""
echo "2. Creando Load Balancer..."
# Crear Pool A (primary)
POOL_A=$(curl -s -X POST "$CLOUDFLARE_API/accounts/$CF_ACCOUNT_ID/load_balancers/pools" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
--data "{
\"name\": \"$DOMAIN-pool-primary\",
\"description\": \"Primary server A\",
\"enabled\": true,
\"minimum_origin_health\": 1,
\"notification_email\": \"$CF_NOTIFY_EMAIL\",
\"origins\": [
{
\"name\": \"server-a\",
\"address\": \"$IP_A\",
\"enabled\": true
}
],
\"check_regions\": [\"WEU\", \"EEU\", \"ENAM\"],
\"origin_steering\": {
\"policy\": \"random\"
}
}" | jq -r '.result.id')
# Crear Pool B (standby)
POOL_B=$(curl -s -X POST "$CLOUDFLARE_API/accounts/$CF_ACCOUNT_ID/load_balancers/pools" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
--data "{
\"name\": \"$DOMAIN-pool-standby\",
\"description\": \"Standby server B\",
\"enabled\": true,
\"minimum_origin_health\": 1,
\"notification_email\": \"$CF_NOTIFY_EMAIL\",
\"origins\": [
{
\"name\": \"server-b\",
\"address\": \"$IP_B\",
\"enabled\": true
}
],
\"check_regions\": [\"WEU\", \"EEU\", \"ENAM\"],
\"origin_steering\": {
\"policy\": \"random\"
}
}" | jq -r '.result.id')
# Crear health check monitor
MONITOR_ID=$(curl -s -X POST "$CLOUDFLARE_API/accounts/$CF_ACCOUNT_ID/load_balancers/monitors" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
--data "{
\"type\": \"http\",
\"description\": \"HTTP health check on port 8080\",
\"method\": \"GET\",
\"path\": \"/health\",
\"port\": 8080,
\"expected_codes\": \"200\",
\"interval\": 30,
\"retries\": 3,
\"timeout\": 10,
\"probe_zone\": \"$DOMAIN\"
}" | jq -r '.result.id')
# Asignar monitor a pools
curl -s -X PATCH "$CLOUDFLARE_API/accounts/$CF_ACCOUNT_ID/load_balancers/pools/$POOL_A" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"monitor\": \"$MONITOR_ID\"}" > /dev/null
curl -s -X PATCH "$CLOUDFLARE_API/accounts/$CF_ACCOUNT_ID/load_balancers/pools/$POOL_B" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"monitor\": \"$MONITOR_ID\"}" > /dev/null
# Crear el Load Balancer
curl -s -X POST "$CLOUDFLARE_API/accounts/$CF_ACCOUNT_ID/load_balancers" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
--data "{
\"name\": \"$DOMAIN-lb\",
\"description\": \"Load Balancer for $DOMAIN\",
\"enabled\": true,
\"ttl\": 30,
\"proxied\": true,
\"fallback_pool\": \"$POOL_B\",
\"default_pools\": [\"$POOL_A\", \"$POOL_B\"],
\"pop_steering_policy\": \"dynamic_latency\",
\"region_pools\": {
\"EEU\": [\"$POOL_A\"],
\"WEU\": [\"$POOL_A\"],
\"ENAM\": [\"$POOL_A\"]
}
}" > /dev/null
echo ""
echo "=== Configuración completada ==="
echo "Load Balancer activo con health checks cada 30s"
echo "Failover automático si Server A falla 3 checks (~90s)"

47
scripts/deploy.sh Normal file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# =============================================================================
# deploy.sh — Despliegue automático (usado por Gitea Actions y Dokploy)
# =============================================================================
# Uso: ./deploy.sh <server-host> <gitea-registry>
# =============================================================================
set -euo pipefail
SERVER_HOST="${1:?Uso: $0 <server-host> <gitea-registry>}"
GITEA_REGISTRY="${2:?Uso: $0 <server-host> <gitea-registry>}"
COMPOSE_DIR="/opt/omnioil"
echo "=== Deploy en $SERVER_HOST ==="
# 1. Autenticar en registry
echo "[1/4] Autenticando en $GITEA_REGISTRY..."
docker login "$GITEA_REGISTRY" -u "$GITEA_DEPLOY_USER" -p "$GITEA_DEPLOY_TOKEN"
# 2. Pull de imágenes
echo "[2/4] Descargando imágenes..."
cd "$COMPOSE_DIR"
docker compose pull
# 3. Desplegar
echo "[3/4] Desplegando servicios..."
docker compose up -d --remove-orphans
# 4. Verificar health
echo "[4/4] Verificando health..."
for i in $(seq 1 12); do
if curl -sf http://localhost:8000/health > /dev/null 2>&1; then
echo " ✅ Backend API healthy"
break
fi
if [ "$i" -eq 12 ]; then
echo " ❌ Backend API no responde después de 60s"
exit 1
fi
sleep 5
done
curl -sf http://localhost:8080/health > /dev/null 2>&1 && \
echo " ✅ Cloudflare health endpoint OK"
echo "=== Deploy completado ==="

113
scripts/failback.sh Normal file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# =============================================================================
# failback.sh — Reversa de failover: Server A vuelve a ser PRIMARY
# =============================================================================
# Uso: ./failback.sh <ip-server-a> <ip-server-b>
# EJECUTAR EN SERVER B (STANDBY) después de que Server A se haya recuperado
# =============================================================================
set -euo pipefail
SERVER_A="${1:?Uso: $0 <ip-server-a> <ip-server-b>}"
SERVER_B="${2:?Uso: $0 <ip-server-a> <ip-server-b>}"
COMPOSE_DIR="/opt/omnioil"
echo "=== FAILBACK: Reversando failover ==="
echo "Server A: $SERVER_A"
echo "Server B: $SERVER_B (servidor actual)"
# Verificar que Server A está online
echo ""
echo "[1/7] Verificando que Server A responde..."
if ! ping -c 3 "$SERVER_A" &> /dev/null; then
echo " ❌ Server A no responde ping. Abortando."
exit 1
fi
if ! curl -sf "http://$SERVER_A:8080/health" &> /dev/null; then
echo " ⚠️ Server A responde ping pero no health endpoint."
echo " ¿Está Docker funcionando en Server A?"
read -rp " ¿Continuar de todas formas? (s/N): " confirm
if [ "$confirm" != "s" ]; then
exit 1
fi
fi
# 1. Detener servicios de aplicación en Server B (excepto datos)
echo "[2/7] Deteniendo servicios de aplicación en Server B..."
cd "$COMPOSE_DIR"
docker compose stop backend-api events-relay telemetry-ingestor \
json-generator landing frontend-pwa frontend-admin frontend-console \
mosquitto build-orchestrator prometheus grafana loki promtail \
alertmanager node-exporter 2>/dev/null || true
# 2. Sincronizar datos de TimescaleDB de vuelta a Server A
echo "[3/7] Sincronizando TimescaleDB con Server A..."
# En Server B: hacer un pg_dump y transferir a Server A
docker exec timescaledb pg_dump -U "${DB_USER}" -d "${DB_NAME}" \
-F c -f /tmp/backup.dump 2>/dev/null || true
# Tranferir backup a Server A
scp /tmp/backup.dump "root@$SERVER_A:/tmp/backup.dump" 2>/dev/null || {
echo " ⚠️ No se pudo copiar backup a Server A"
echo " ¿Quieres continuar con failback? (se perderán datos recientes)"
read -rp " ¿Continuar? (s/N): " confirm
if [ "$confirm" != "s" ]; then
exit 1
fi
}
# 3. Restaurar backup en Server A
echo "[4/7] Restaurando backup en Server A..."
ssh "root@$SERVER_A" "
cd /opt/omnioil
docker compose up -d timescaledb
sleep 10
pg_restore -U ${DB_USER} -d ${DB_NAME} -F c /tmp/backup.dump 2>/dev/null || true
rm -f /tmp/backup.dump
" || echo " ⚠️ Restauración con errores"
# 4. Reconfigurar TimescaleDB en Server A como primary
echo "[5/7] Reconfigurando Server A como primary..."
ssh "root@$SERVER_A" "
cd /opt/omnioil
docker compose down
rm -f /var/lib/postgresql/data/standby.signal
sed -i '/primary_conninfo/d' /var/lib/postgresql/data/postgresql.conf
docker compose up -d
" || {
echo " ❌ Error reconfigurando Server A"
exit 1
}
# 5. Reconfigurar Server B como replica
echo "[6/7] Reconfigurando Server B como replica..."
cd "$COMPOSE_DIR"
docker compose down timescaledb 2>/dev/null || true
rm -rf /var/lib/postgresql/data.bak
mv /var/lib/postgresql/data /var/lib/postgresql/data.bak 2>/dev/null || true
# pg_basebackup desde Server A
docker run --rm \
-e PGPASSWORD="${DB_PASSWORD}" \
timescale/timescaledb:2.26.1-pg16 \
pg_basebackup -h "$SERVER_A" \
-U "${DB_USER}" \
-D /var/lib/postgresql/data \
-P -R --wal-method=stream
# Iniciar replica
docker compose up -d timescaledb
# 6. Limpiar y reiniciar failover-agent
echo "[7/7] Reiniciando failover-agent..."
docker compose down failover-agent 2>/dev/null || true
docker compose up -d failover-agent health-endpoint
echo ""
echo "=== FAILBACK COMPLETADO ==="
echo "Server A ahora es PRIMARY"
echo "Server B ahora es STANDBY"
echo ""
echo "Cloudflare debería re-rutear tráfico a Server A automáticamente"
echo "Verifica: curl http://$SERVER_A:8080/health"

84
scripts/vps-bootstrap.sh Normal file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# =============================================================================
# vps-bootstrap.sh — Configura Server A (PRIMARY)
# =============================================================================
# Uso: ./vps-bootstrap.sh <env> <gitea-url> <gitea-token>
# Ejemplo: ./vps-bootstrap.sh production https://build.omnioil.app gitea_token_xxx
# =============================================================================
set -euo pipefail
ENV="${1:?Uso: $0 <env> <gitea-url> <gitea-token>}"
GITEA_URL="${2:?Uso: $0 <env> <gitea-url> <gitea-token>}"
GITEA_TOKEN="${3:?Uso: $0 <env> <gitea-url> <gitea-token>}"
REPO_URL="$GITEA_URL/omnioil/omnioil-scada.git"
COMPOSE_DIR="/opt/omnioil"
echo "=== Bootstrap Server A (PRIMARY) ==="
# 1. Instalar Docker
echo "[1/6] Instalando Docker..."
if ! command -v docker &> /dev/null; then
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
fi
# 2. Instalar Dokploy
echo "[2/6] Instalando Dokploy..."
docker rm -f dokploy 2>/dev/null || true
docker run -d \
--name dokploy \
--restart always \
-p 3000:3000 \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /data/dokploy:/data \
dokploy/dokploy:latest
# 3. Configurar firewall
echo "[3/6] Configurando firewall..."
ufw --force reset
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp # SSH
ufw allow 80/tcp # HTTP
ufw allow 443/tcp # HTTPS
ufw allow 1883/tcp # MQTT
ufw allow 9883/tcp # MQTT+WSS
ufw allow 3000/tcp # Dokploy UI
ufw --force enable
# 4. Clonar repositorio
echo "[4/6] Clonando repositorio..."
mkdir -p "$COMPOSE_DIR"
if [ -d "$COMPOSE_DIR/.git" ]; then
cd "$COMPOSE_DIR" && git pull
else
git clone "$REPO_URL" "$COMPOSE_DIR"
fi
# 5. Configurar .env
echo "[5/6] Configurando variables de entorno..."
cd "$COMPOSE_DIR"
if [ ! -f ".env" ]; then
cp ".env.example.docker" ".env"
echo " >> Edita .env con los valores de producción antes de continuar"
echo " >> Luego ejecuta: docker compose up -d"
fi
# 6. Desplegar servicios
echo "[6/6] Desplegando servicios..."
cd "$COMPOSE_DIR"
docker login "$GITEA_URL" -u deploy -p "$GITEA_TOKEN"
docker compose pull
docker compose up -d --remove-orphans
echo ""
echo "=== Bootstrap completado ==="
echo "Dokploy UI: http://<server-ip>:3000"
echo "Health endpoint: http://<server-ip>:8080/health"
echo ""
echo "IMPORTANTE: Edita el archivo .env con los valores reales de producción:"
echo " nano $COMPOSE_DIR/.env"
echo " docker compose up -d"

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env bash
# =============================================================================
# vps-standby-bootstrap.sh — Configura Server B (STANDBY)
# =============================================================================
# Uso: ./vps-standby-bootstrap.sh <env> <gitea-url> <gitea-token> <ip-server-a>
# =============================================================================
set -euo pipefail
ENV="${1:?Uso: $0 <env> <gitea-url> <gitea-token> <ip-server-a>}"
GITEA_URL="${2:?Uso: $0 <env> <gitea-url> <gitea-token> <ip-server-a>}"
GITEA_TOKEN="${3:?Uso: $0 <env> <gitea-url> <gitea-token> <ip-server-a>}"
SERVER_A="${4:?Uso: $0 <env> <gitea-url> <gitea-token> <ip-server-a>}"
REPO_URL="$GITEA_URL/omnioil/omnioil-scada.git"
COMPOSE_DIR="/opt/omnioil"
echo "=== Bootstrap Server B (STANDBY) ==="
# 1. Instalar Docker
echo "[1/9] Instalando Docker..."
if ! command -v docker &> /dev/null; then
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
fi
# 2. Instalar Dokploy
echo "[2/9] Instalando Dokploy..."
docker rm -f dokploy 2>/dev/null || true
docker run -d \
--name dokploy \
--restart always \
-p 3000:3000 \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /data/dokploy:/data \
dokploy/dokploy:latest
# 3. Configurar firewall
echo "[3/9] Configurando firewall..."
ufw --force reset
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 1883/tcp
ufw allow 9883/tcp
ufw allow 3000/tcp
ufw --force enable
# 4. Clonar repositorio
echo "[4/9] Clonando repositorio..."
mkdir -p "$COMPOSE_DIR"
if [ -d "$COMPOSE_DIR/.git" ]; then
cd "$COMPOSE_DIR" && git pull
else
git clone "$REPO_URL" "$COMPOSE_DIR"
fi
# 5. Configurar .env
echo "[5/9] Configurando variables de entorno..."
cd "$COMPOSE_DIR"
if [ ! -f ".env" ]; then
cp ".env.example.docker" ".env"
echo " >> Edita .env con los valores de producción (mismos que Server A)"
fi
# 6. Configurar TimescaleDB replica
echo "[6/9] Configurando TimescaleDB streaming replica..."
docker pull timescale/timescaledb:2.26.1-pg16
# Detener y limpiar datos existentes
docker rm -f timescaledb 2>/dev/null || true
rm -rf /var/lib/postgresql/data.bak
mv /var/lib/postgresql/data /var/lib/postgresql/data.bak 2>/dev/null || true
# Realizar pg_basebackup desde Server A
docker run --rm \
-e PGPASSWORD="${DB_PASSWORD}" \
timescale/timescaledb:2.26.1-pg16 \
pg_basebackup -h "$SERVER_A" \
-U "${DB_USER}" \
-D /var/lib/postgresql/data \
-P -R \
--wal-method=stream
# Configurar replica
cat >> /var/lib/postgresql/data/postgresql.conf << 'EOF'
primary_conninfo = 'host=$SERVER_A port=5432 user=${DB_USER} password=${DB_PASSWORD}'
primary_slot_name = 'standby_b'
EOF
touch /var/lib/postgresql/data/standby.signal
echo " >> TimescaleDB configurada como replica streaming de Server A"
# 7. Configurar MinIO replication
echo "[7/9] Configurando MinIO replication..."
docker run --rm \
-e MINIO_ROOT_USER="${MINIO_USER}" \
-e MINIO_ROOT_PASSWORD="${MINIO_PASSWORD}" \
minio/mc:latest \
alias set primary "http://$SERVER_A:9000" "${MINIO_USER}" "${MINIO_PASSWORD}"
docker run --rm \
-e MINIO_ROOT_USER="${MINIO_USER}" \
-e MINIO_ROOT_PASSWORD="${MINIO_PASSWORD}" \
minio/mc:latest \
alias set local "http://localhost:9000" "${MINIO_USER}" "${MINIO_PASSWORD}"
# Configurar replicación de buckets
for bucket in anh-reports omnioil-releases; do
docker run --rm \
minio/mc:latest \
replicate add primary/"$bucket" \
--remote-bucket "http://localhost:9000/$bucket" \
--replicate "delete,delete-marker,existing-objects"
done
echo " >> MinIO replication configurada"
# 8. Construir y levantar failover-agent
echo "[8/9] Construyendo failover-agent..."
docker compose -f "$COMPOSE_DIR/docker-compose.yml" build failover-agent 2>/dev/null || true
# Levantar servicios mínimos en standby
docker compose -f "$COMPOSE_DIR/docker-compose.yml" \
up -d health-endpoint
# 9. Construir failover-agent manualmente (usando Dockerfile directo)
echo "[9/9] Construyendo failover-agent desde el crate Rust..."
docker build -t failover-agent:latest "$COMPOSE_DIR/services/failover-agent"
docker run -d \
--name failover-agent \
--restart always \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$COMPOSE_DIR/.env:/opt/omnioil/.env:ro" \
-e PRIMARY_HOST="$SERVER_A" \
-e PRIMARY_HEALTH_PORT=8080 \
-e PG_HOST=localhost \
-e PG_PORT=5432 \
-e PG_USER="${DB_USER}" \
-e PG_PASSWORD="${DB_PASSWORD}" \
failover-agent:latest
echo ""
echo "=== Bootstrap Standby completado ==="
echo "Servicios activos en STANDBY:"
echo " - TimescaleDB (replica)"
echo " - MinIO (replication target)"
echo " - Health endpoint"
echo " - Failover-agent (monitoreando Server A)"
echo ""
echo "FAILOVER AUTOMÁTICO: Si Server A falla, el failover-agent"
echo "promoverá este servidor y desplegará todos los servicios."

View File

@@ -0,0 +1,109 @@
# =============================================================================
# windows-runner-setup.ps1 — Configura Gitea Actions Runner en Windows
# =============================================================================
# Requiere: Windows 10/11 o Windows Server 2019+
# Ejecutar como Administrador
# =============================================================================
$ErrorActionPreference = "Stop"
$GITEA_URL = "https://build.omnioil.app"
$RUNNER_TOKEN = Read-Host -Prompt "Ingresa el token del runner desde Gitea Admin > Actions > Runners"
Write-Host "=== Setup Windows Runner para Gitea Actions ===" -ForegroundColor Cyan
# 1. Instalar Gitea Actions Runner
Write-Host "[1/6] Instalando Gitea Actions Runner..." -ForegroundColor Yellow
$runnerDir = "$env:ProgramData\gitea-runner"
New-Item -ItemType Directory -Path $runnerDir -Force | Out-Null
Set-Location $runnerDir
# Descargar última versión del runner
$repo = "gitea/act_runner"
$release = Invoke-RestMethod "https://api.github.com/repos/$repo/releases/latest"
$asset = $release.assets | Where-Object { $_.name -like "*windows-amd64*" }
Invoke-WebRequest -Uri $asset.browser_download_url -OutFile "act_runner.exe"
# Configurar e instalar como servicio
.\act_runner.exe register `
--instance "$GITEA_URL" `
--token "$RUNNER_TOKEN" `
--name "windows-msi-builder" `
--labels "windows" `
--no-interactive
# Instalar como servicio de Windows
New-Service -Name "gitea-runner" `
-BinaryPathName "`"$runnerDir\act_runner.exe`" daemon --config `"$runnerDir\.runner`"" `
-DisplayName "Gitea Actions Runner" `
-StartupType Automatic
Start-Service -Name "gitea-runner"
# 2. Instalar Rust toolchain
Write-Host "[2/6] Instalando Rust toolchain..." -ForegroundColor Yellow
Invoke-WebRequest -Uri "https://static.rust-lang.org/rustup/dist/i686-pc-windows-gnu/rustup-init.exe" `
-OutFile "$env:TEMP\rustup-init.exe"
& "$env:TEMP\rustup-init.exe" -y --default-host x86_64-pc-windows-gnu
Remove-Item "$env:TEMP\rustup-init.exe"
# Agregar target Windows GNU
& "$env:USERPROFILE\.cargo\bin\rustup.exe" target add x86_64-pc-windows-gnu
# 3. Instalar WiX Toolset v4
Write-Host "[3/6] Instalando WiX Toolset v4..." -ForegroundColor Yellow
Invoke-WebRequest -Uri "https://github.com/wixtoolset/wix4/releases/latest/download/wix314.exe" `
-OutFile "$env:TEMP\wix.exe"
& "$env:TEMP\wix.exe" /install /quiet /norestart
Remove-Item "$env:TEMP\wix.exe"
# 4. Instalar AWS CLI (para MinIO S3 compat)
Write-Host "[4/6] Instalando AWS CLI..." -ForegroundColor Yellow
Invoke-WebRequest -Uri "https://awscli.amazonaws.com/AWSCLIV2.msi" `
-OutFile "$env:TEMP\AWSCLIV2.msi"
& msiexec.exe /passive /i "$env:TEMP\AWSCLIV2.msi"
Remove-Item "$env:TEMP\AWSCLIV2.msi"
# 5. Configurar credenciales MinIO
Write-Host "[5/6] Configurando credenciales MinIO..." -ForegroundColor Yellow
$minioEndpoint = Read-Host -Prompt "MinIO endpoint URL (ej: https://minio.omnioil.app)"
$minioKey = Read-Host -Prompt "MinIO Access Key"
$minioSecret = Read-Host -Prompt "MinIO Secret Key" -AsSecureString
$minioBstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($minioSecret)
$minioPlain = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($minioBstr)
& aws configure set aws_access_key_id "$minioKey"
& aws configure set aws_secret_access_key "$minioPlain"
& aws configure set endpoint_url "$minioEndpoint"
& aws configure set region us-east-1
# 6. Verificar instalación
Write-Host "[6/6] Verificando instalación..." -ForegroundColor Yellow
$checks = @{
"Gitea Runner" = { Get-Service "gitea-runner" -ErrorAction SilentlyContinue }
"Rust" = { Get-Command "rustc.exe" -ErrorAction SilentlyContinue }
"Cargo" = { Get-Command "cargo.exe" -ErrorAction SilentlyContinue }
"WiX (candle)" = { Get-Command "candle.exe" -ErrorAction SilentlyContinue }
"AWS CLI" = { Get-Command "aws.exe" -ErrorAction SilentlyContinue }
}
$allOk = $true
foreach ($check in $checks.Keys) {
$result = & $checks[$check]
if ($result) {
Write-Host "$check" -ForegroundColor Green
} else {
Write-Host "$check" -ForegroundColor Red
$allOk = $false
}
}
Write-Host ""
if ($allOk) {
Write-Host "=== Setup completado exitosamente ===" -ForegroundColor Cyan
Write-Host "Runner registrado: windows-msi-builder"
Write-Host "Tags disponibles en CI: [windows]"
} else {
Write-Host "⚠️ Algunos componentes no se instalaron correctamente" -ForegroundColor Yellow
Write-Host "Revisa los errores e instala manualmente los faltantes"
}

View File

@@ -0,0 +1,40 @@
# =============================================================================
# Failover-Agent Configuration
# =============================================================================
# Primary server (the one we monitor)
PRIMARY_HOST=server-a.internal
PRIMARY_HEALTH_PORT=8080
# PostgreSQL credentials (for pg_promote)
PG_HOST=localhost
PG_PORT=5432
PG_USER=adminomnioil
PG_PASSWORD=admin123
PG_DB=omnioil
# Docker Compose project
COMPOSE_DIR=/opt/omnioil
COMPOSE_FILE=docker-compose.yml
# Telegram notifications
TELEGRAM_BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
TELEGRAM_CHAT_ID=-1001234567890
# Email (SMTP) notifications
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=alerts@omnioil.app
SMTP_PASS=app_password_here
NOTIFY_EMAIL=admin@omnioil.com
# MinIO lock (split-brain prevention)
MINIO_ENDPOINT=http://minio:9000
MINIO_ACCESS_KEY=admin_s3
MINIO_SECRET_KEY=minio_secret_here
LOCK_BUCKET=anh-reports
# Behavior
CHECK_INTERVAL=10
FAIL_THRESHOLD=12
HEALTH_TIMEOUT=5

View File

@@ -0,0 +1,21 @@
[package]
name = "failover-agent"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "failover-agent"
path = "src/main.rs"
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bollard = "0.18"
tokio-postgres = "0.7"
lettre = { version = "0.11", features = ["builder", "smtp-transport", "tokio1-rustls-tls"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
chrono = "0.4"
uuid = { version = "1", features = ["v4"] }

View File

@@ -0,0 +1,13 @@
FROM rust:1.78-slim-bookworm AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock* ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release 2>/dev/null || true
COPY src/ src/
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/failover-agent /usr/local/bin/failover-agent
ENTRYPOINT ["/usr/local/bin/failover-agent"]

View File

@@ -0,0 +1,76 @@
use std::env;
#[derive(Debug, Clone)]
pub struct Config {
pub primary_host: String,
pub primary_health_port: u16,
pub pg_host: String,
pub pg_port: u16,
pub pg_user: String,
pub pg_password: String,
pub pg_db: String,
pub compose_dir: String,
pub compose_file: String,
pub telegram_bot_token: String,
pub telegram_chat_id: String,
pub smtp_host: String,
pub smtp_port: u16,
pub smtp_user: String,
pub smtp_pass: String,
pub notify_email: String,
pub minio_endpoint: String,
pub minio_access_key: String,
pub minio_secret_key: String,
pub lock_bucket: String,
pub check_interval: u64,
pub fail_threshold: u32,
pub health_timeout: u64,
pub instance_id: String,
}
impl Config {
pub fn from_env() -> Self {
Config {
primary_host: env::var("PRIMARY_HOST").unwrap_or_else(|_| "server-a".into()),
primary_health_port: env::var("PRIMARY_HEALTH_PORT")
.unwrap_or_else(|_| "8080".into())
.parse()
.unwrap_or(8080),
pg_host: env::var("PG_HOST").unwrap_or_else(|_| "localhost".into()),
pg_port: env::var("PG_PORT").unwrap_or_else(|_| "5432".into())
.parse()
.unwrap_or(5432),
pg_user: env::var("PG_USER").expect("PG_USER required"),
pg_password: env::var("PG_PASSWORD").expect("PG_PASSWORD required"),
pg_db: env::var("PG_DB").unwrap_or_else(|_| "omnioil".into()),
compose_dir: env::var("COMPOSE_DIR").unwrap_or_else(|_| "/opt/omnioil".into()),
compose_file: env::var("COMPOSE_FILE").unwrap_or_else(|_| "docker-compose.yml".into()),
telegram_bot_token: env::var("TELEGRAM_BOT_TOKEN")
.unwrap_or_else(|_| String::new()),
telegram_chat_id: env::var("TELEGRAM_CHAT_ID")
.unwrap_or_else(|_| String::new()),
smtp_host: env::var("SMTP_HOST").unwrap_or_else(|_| String::new()),
smtp_port: env::var("SMTP_PORT").unwrap_or_else(|_| "587".into())
.parse()
.unwrap_or(587),
smtp_user: env::var("SMTP_USER").unwrap_or_else(|_| String::new()),
smtp_pass: env::var("SMTP_PASS").unwrap_or_else(|_| String::new()),
notify_email: env::var("NOTIFY_EMAIL").unwrap_or_else(|_| String::new()),
minio_endpoint: env::var("MINIO_ENDPOINT")
.unwrap_or_else(|_| "http://minio:9000".into()),
minio_access_key: env::var("MINIO_ACCESS_KEY").unwrap_or_else(|_| String::new()),
minio_secret_key: env::var("MINIO_SECRET_KEY").unwrap_or_else(|_| String::new()),
lock_bucket: env::var("LOCK_BUCKET").unwrap_or_else(|_| "anh-reports".into()),
check_interval: env::var("CHECK_INTERVAL").unwrap_or_else(|_| "10".into())
.parse()
.unwrap_or(10),
fail_threshold: env::var("FAIL_THRESHOLD").unwrap_or_else(|_| "12".into())
.parse()
.unwrap_or(12),
health_timeout: env::var("HEALTH_TIMEOUT").unwrap_or_else(|_| "5".into())
.parse()
.unwrap_or(5),
instance_id: uuid::Uuid::new_v4().to_string(),
}
}
}

View File

@@ -0,0 +1,124 @@
use std::time::{SystemTime, UNIX_EPOCH};
use reqwest::Client;
use tracing::{info, warn, error};
pub struct Lock {
client: Client,
endpoint: String,
access_key: String,
secret_key: String,
bucket: String,
lock_key: String,
instance_id: String,
ttl_secs: u64,
}
impl Lock {
pub fn new(
endpoint: &str,
access_key: &str,
secret_key: &str,
bucket: &str,
instance_id: &str,
) -> Self {
let client = Client::builder()
.build()
.expect("Failed to create HTTP client");
Lock {
client,
endpoint: endpoint.trim_end_matches('/').to_string(),
access_key: access_key.to_string(),
secret_key: secret_key.to_string(),
bucket: bucket.to_string(),
lock_key: "failover/failover.lock".to_string(),
instance_id: instance_id.to_string(),
ttl_secs: 3600,
}
}
pub async fn acquire(&self) -> bool {
let url = format!(
"{}/{}/{}",
self.endpoint, self.bucket, self.lock_key
);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let body = serde_json::json!({
"instance_id": self.instance_id,
"timestamp": now,
"ttl": self.ttl_secs,
"expires_at": now + self.ttl_secs,
});
let resp = self.client
.put(&url)
.header("x-amz-acl", "bucket-owner-full-control")
.body(serde_json::to_string(&body).unwrap())
.send()
.await;
match resp {
Ok(r) if r.status().is_success() => {
info!("Lock acquired successfully");
true
}
Ok(r) => {
warn!("Failed to acquire lock: HTTP {}", r.status());
false
}
Err(e) => {
error!("Lock acquisition error: {}", e);
false
}
}
}
pub async fn check(&self) -> Option<String> {
let url = format!(
"{}/{}/{}",
self.endpoint, self.bucket, self.lock_key
);
let resp = self.client.get(&url).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
let instance = body.get("instance_id")?.as_str()?;
let expires = body.get("expires_at")?.as_u64()?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
if expires < now {
info!("Lock expired");
return None;
}
Some(instance.to_string())
}
pub async fn release(&self) {
let url = format!(
"{}/{}/{}",
self.endpoint, self.bucket, self.lock_key
);
let resp = self.client.delete(&url).send().await;
match resp {
Ok(r) if r.status().is_success() || r.status().as_u16() == 204 => {
info!("Lock released");
}
Ok(r) => warn!("Lock release returned HTTP {}", r.status()),
Err(e) => error!("Lock release error: {}", e),
}
}
}

View File

@@ -0,0 +1,230 @@
mod config;
mod monitor;
mod promoter;
mod notifier;
mod lock;
use std::process;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tracing::{info, warn, error};
use tracing_subscriber::EnvFilter;
#[derive(Debug, PartialEq)]
enum AgentState {
Monitoring,
Promoting,
FailedOver,
Error,
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
info!("=== OmniOil Failover Agent Starting ===");
let cfg = config::Config::from_env();
let healthy = Arc::new(AtomicBool::new(true));
let monitor = monitor::Monitor::new(
&cfg.primary_host,
cfg.primary_health_port,
cfg.health_timeout,
);
let promoter = promoter::Promoter::new(
&cfg.pg_host,
cfg.pg_port,
&cfg.pg_user,
&cfg.pg_password,
&cfg.pg_db,
&cfg.compose_dir,
&cfg.compose_file,
);
let notifier = notifier::Notifier::new(
&cfg.telegram_bot_token,
&cfg.telegram_chat_id,
&cfg.smtp_host,
cfg.smtp_port,
&cfg.smtp_user,
&cfg.smtp_pass,
&cfg.notify_email,
);
let lock = lock::Lock::new(
&cfg.minio_endpoint,
&cfg.minio_access_key,
&cfg.minio_secret_key,
&cfg.lock_bucket,
&cfg.instance_id,
);
let mut state = AgentState::Monitoring;
let mut fail_count: u32 = 0;
let mut notified = false;
info!("Agent instance ID: {}", cfg.instance_id);
info!("Monitoring {}:{} every {}s", cfg.primary_host, cfg.primary_health_port, cfg.check_interval);
info!("Fail threshold: {} checks (~{}s)", cfg.fail_threshold, cfg.fail_threshold * cfg.check_interval as u32);
loop {
match state {
AgentState::Monitoring => {
// Check if another failover is already in progress
if let Some(lock_holder) = lock.check().await {
info!("Failover lock held by instance: {}", lock_holder);
if lock_holder != cfg.instance_id {
warn!("Another instance has the failover lock, waiting...");
tokio::time::sleep(tokio::time::Duration::from_secs(cfg.check_interval)).await;
continue;
}
}
let health = monitor.check_health().await;
let ping_ok = monitor.check_ping(&cfg.primary_host).await;
match health {
monitor::HealthStatus::Healthy => {
if fail_count > 0 {
info!("Primary recovered (fail count resets)");
}
fail_count = 0;
healthy.store(true, Ordering::SeqCst);
}
monitor::HealthStatus::Unhealthy(reason) => {
fail_count += 1;
warn!(
"Health check failed ({}/{}): {} | ping={}",
fail_count, cfg.fail_threshold, reason, ping_ok
);
if fail_count >= cfg.fail_threshold {
// Double-check with ping before declaring failure
if !ping_ok {
info!("Primary confirmed down (health + ping both failed)");
state = AgentState::Promoting;
} else {
warn!("Health fails but ping succeeds — might be application issue, not server failure");
// Still promote if health keeps failing (app is down)
if fail_count >= cfg.fail_threshold + 6 {
info!("Health has been failing for extended period, promoting...");
state = AgentState::Promoting;
}
}
}
}
}
tokio::time::sleep(tokio::time::Duration::from_secs(cfg.check_interval)).await;
}
AgentState::Promoting => {
info!("=== STARTING FAILOVER ===");
// Acquire lock to prevent split-brain
if !lock.acquire().await {
error!("Failed to acquire failover lock! Aborting promotion.");
notifier.notify(
"FAILOVER ABORTED",
"Could not acquire failover lock. Another instance may be promoting.",
).await;
state = AgentState::Error;
continue;
}
// 1. Promote TimescaleDB
let pg_ok = promoter.promote_timescaledb().await;
if !pg_ok {
warn!("TimescaleDB promotion had issues, continuing anyway...");
}
// 2. Start all services
let compose_ok = promoter.start_compose_services().await;
if !compose_ok {
error!("Failed to start Docker Compose services");
notifier.notify(
"FAILOVER PARTIAL",
"TimescaleDB promoted but services failed to start. Manual intervention required.",
).await;
state = AgentState::Error;
continue;
}
// 3. Wait for health
let health_url = format!("http://localhost:{}", cfg.primary_health_port);
let healthy = promoter.wait_for_health(&health_url, 24).await;
if healthy {
info!("All services running and healthy");
notifier.notify(
"🟢 FAILOVER COMPLETED",
&format!(
"Server B is now ACTIVE.\n\
TimescaleDB promoted.\n\
All services started.\n\
Failover lock: {}\n\n\
Server A is DOWN. Do NOT failback manually until Server A fully recovers.",
cfg.instance_id
),
).await;
} else {
warn!("Services started but health check did not pass within timeout");
notifier.notify(
"⚠️ FAILOVER WARNING",
"Services started but health checks not passing. Check manually.",
).await;
}
state = AgentState::FailedOver;
notified = false;
}
AgentState::FailedOver => {
// Monitor if primary has recovered
let health = monitor.check_health().await;
let ping_ok = monitor.check_ping(&cfg.primary_host).await;
match health {
monitor::HealthStatus::Healthy => {
if !notified {
info!("Primary server {} has recovered", cfg.primary_host);
notifier.notify(
"✅ SERVER A RECOVERED",
&format!(
"Primary server {} is back online.\n\n\
Current state: Server B is still active.\n\
**Manual failback required** to restore original topology.\n\n\
Run: ./scripts/failback.sh {} <this-server-ip>",
cfg.primary_host, cfg.primary_host
),
).await;
notified = true;
}
}
monitor::HealthStatus::Unhealthy(_) => {
if ping_ok && !notified {
info!("Primary responds to ping but health endpoint is down");
}
notified = false;
}
}
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
}
AgentState::Error => {
error!("Agent in error state. Restarting monitoring in 30s...");
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
state = AgentState::Monitoring;
fail_count = 0;
}
}
}
}

View File

@@ -0,0 +1,73 @@
use std::time::Duration;
use reqwest::Client;
use tracing::{info, warn, error};
pub struct Monitor {
client: Client,
health_url: String,
timeout: Duration,
}
#[derive(Debug, PartialEq)]
pub enum HealthStatus {
Healthy,
Unhealthy(String),
}
impl Monitor {
pub fn new(host: &str, port: u16, timeout_secs: u64) -> Self {
let health_url = format!("http://{}:{}/health", host, port);
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.expect("Failed to create HTTP client");
Monitor {
client,
health_url,
timeout: Duration::from_secs(timeout_secs),
}
}
pub async fn check_health(&self) -> HealthStatus {
match self.client.get(&self.health_url).send().await {
Ok(resp) => {
if resp.status().is_success() {
info!("Primary health check: OK");
HealthStatus::Healthy
} else {
let status = resp.status();
warn!("Primary returned status: {}", status);
HealthStatus::Unhealthy(format!("HTTP {}", status))
}
}
Err(e) => {
error!("Health check failed: {}", e);
if e.is_timeout() {
HealthStatus::Unhealthy("timeout".into())
} else if e.is_connect() {
HealthStatus::Unhealthy("connection refused".into())
} else {
HealthStatus::Unhealthy(e.to_string())
}
}
}
}
pub async fn check_ping(&self, host: &str) -> bool {
// ICMP ping via tokio::process
let output = tokio::process::Command::new("ping")
.arg("-c")
.arg("1")
.arg("-W")
.arg("3")
.arg(host)
.output()
.await;
match output {
Ok(out) => out.status.success(),
Err(_) => false,
}
}
}

View File

@@ -0,0 +1,112 @@
use std::time::Duration;
use lettre::{
transport::smtp::authentication::Credentials,
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
};
use reqwest::Client;
use tracing::{info, warn, error};
pub struct Notifier {
telegram_token: String,
telegram_chat_id: String,
smtp_host: String,
smtp_port: u16,
smtp_user: String,
smtp_pass: String,
notify_email: String,
http_client: Client,
}
impl Notifier {
pub fn new(
telegram_token: &str,
telegram_chat_id: &str,
smtp_host: &str,
smtp_port: u16,
smtp_user: &str,
smtp_pass: &str,
notify_email: &str,
) -> Self {
Notifier {
telegram_token: telegram_token.to_string(),
telegram_chat_id: telegram_chat_id.to_string(),
smtp_host: smtp_host.to_string(),
smtp_port,
smtp_user: smtp_user.to_string(),
smtp_pass: smtp_pass.to_string(),
notify_email: notify_email.to_string(),
http_client: Client::builder()
.timeout(Duration::from_secs(15))
.build()
.expect("HTTP client"),
}
}
pub async fn notify(&self, title: &str, message: &str) {
let telegram = self.send_telegram(title, message);
let email = self.send_email(title, message);
tokio::join!(telegram, email);
}
async fn send_telegram(&self, title: &str, message: &str) {
if self.telegram_token.is_empty() || self.telegram_chat_id.is_empty() {
warn!("Telegram not configured, skipping");
return;
}
let url = format!(
"https://api.telegram.org/bot{}/sendMessage",
self.telegram_token
);
let body = serde_json::json!({
"chat_id": self.telegram_chat_id,
"text": format!("*{}*\n\n{}", title, message),
"parse_mode": "Markdown",
});
match self.http_client.post(&url).json(&body).send().await {
Ok(r) if r.status().is_success() => info!("Telegram notification sent"),
Ok(r) => warn!("Telegram API error: HTTP {}", r.status()),
Err(e) => error!("Telegram send error: {}", e),
}
}
async fn send_email(&self, title: &str, message: &str) {
if self.smtp_host.is_empty() || self.notify_email.is_empty() {
warn!("Email not configured, skipping");
return;
}
let email = Message::builder()
.from("OmniOil Failover Agent <noreply@omnioil.app>"
.parse()
.unwrap())
.to(self.notify_email.parse().unwrap())
.subject(format!("[OmniOil] {}", title))
.body(format!("{}\n\n---\nFailover Agent", message))
.unwrap();
let creds = Credentials::new(
self.smtp_user.clone(),
self.smtp_pass.clone(),
);
let mailer = match AsyncSmtpTransport::<Tokio1Executor>::relay(&self.smtp_host) {
Ok(relay) => relay
.port(self.smtp_port)
.credentials(creds)
.build(),
Err(e) => {
error!("Email relay error: {}", e);
return;
}
};
match mailer.send(email).await {
Ok(_) => info!("Email notification sent"),
Err(e) => error!("Email send error: {}", e),
}
}
}

View File

@@ -0,0 +1,154 @@
use std::time::Duration;
use tokio::process::Command;
use tokio_postgres::{NoTls, Client};
use tracing::{info, warn, error};
pub struct Promoter {
pg_host: String,
pg_port: u16,
pg_user: String,
pg_password: String,
pg_db: String,
compose_dir: String,
compose_file: String,
}
impl Promoter {
pub fn new(
pg_host: &str,
pg_port: u16,
pg_user: &str,
pg_password: &str,
pg_db: &str,
compose_dir: &str,
compose_file: &str,
) -> Self {
Promoter {
pg_host: pg_host.to_string(),
pg_port,
pg_user: pg_user.to_string(),
pg_password: pg_password.to_string(),
pg_db: pg_db.to_string(),
compose_dir: compose_dir.to_string(),
compose_file: compose_file.to_string(),
}
}
async fn connect_pg(&self) -> Result<Client, tokio_postgres::Error> {
let conn_str = format!(
"host={} port={} user={} password={} dbname={}",
self.pg_host, self.pg_port, self.pg_user, self.pg_password, self.pg_db
);
let (client, connection) = tokio_postgres::connect(&conn_str, NoTls).await?;
tokio::spawn(async move {
if let Err(e) = connection.await {
error!("PostgreSQL connection error: {}", e);
}
});
Ok(client)
}
pub async fn promote_timescaledb(&self) -> bool {
info!("Promoting TimescaleDB to primary...");
match self.connect_pg().await {
Ok(client) => {
let result = client
.query_one("SELECT pg_is_in_recovery()", &[])
.await;
match result {
Ok(row) => {
let in_recovery: bool = row.get(0);
if !in_recovery {
info!("TimescaleDB is already primary, no promotion needed");
return true;
}
}
Err(e) => {
warn!("Failed to check recovery status: {}", e);
}
}
// Promote the standby to primary
let promote = client.simple_query("SELECT pg_promote()").await;
match promote {
Ok(_) => {
info!("TimescaleDB promoted successfully");
true
}
Err(e) => {
error!("Failed to promote TimescaleDB: {}", e);
false
}
}
}
Err(e) => {
error!("Failed to connect to PostgreSQL: {}", e);
false
}
}
}
pub async fn start_compose_services(&self) -> bool {
info!("Starting all Docker Compose services...");
let output = Command::new("docker")
.args([
"compose",
"-f", &self.compose_file,
"-p", "omnioil",
"up",
"-d",
"--remove-orphans",
])
.current_dir(&self.compose_dir)
.output()
.await;
match output {
Ok(out) => {
if out.status.success() {
info!("Docker Compose services started");
true
} else {
let stderr = String::from_utf8_lossy(&out.stderr);
error!("Docker Compose failed: {}", stderr);
false
}
}
Err(e) => {
error!("Failed to run docker compose: {}", e);
false
}
}
}
pub async fn wait_for_health(&self, health_url: &str, max_retries: u32) -> bool {
info!("Waiting for services to become healthy...");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
for i in 0..max_retries {
tokio::time::sleep(Duration::from_secs(5)).await;
match client.get(health_url).send().await {
Ok(resp) if resp.status().is_success() => {
info!("Services healthy after ~{}s", (i + 1) * 5);
return true;
}
_ => {
if i % 6 == 0 {
info!("Waiting for health... ({}/{})", i + 1, max_retries);
}
}
}
}
warn!("Services did not become healthy within timeout");
false
}
}