dev #44
113
.gitea/workflows/deploy.yml
Normal file
113
.gitea/workflows/deploy.yml
Normal 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 }}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
197
docs/INFRAESTRUCTURA.md
Normal 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
|
||||||
3
infrastructure/health/Dockerfile
Normal file
3
infrastructure/health/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 8080
|
||||||
14
infrastructure/health/nginx.conf
Normal file
14
infrastructure/health/nginx.conf
Normal 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
151
scripts/cloudflare-setup.sh
Normal 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
47
scripts/deploy.sh
Normal 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
113
scripts/failback.sh
Normal 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
84
scripts/vps-bootstrap.sh
Normal 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"
|
||||||
157
scripts/vps-standby-bootstrap.sh
Normal file
157
scripts/vps-standby-bootstrap.sh
Normal 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."
|
||||||
109
scripts/windows-runner-setup.ps1
Normal file
109
scripts/windows-runner-setup.ps1
Normal 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"
|
||||||
|
}
|
||||||
40
services/failover-agent/.env.example
Normal file
40
services/failover-agent/.env.example
Normal 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
|
||||||
21
services/failover-agent/Cargo.toml
Normal file
21
services/failover-agent/Cargo.toml
Normal 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"] }
|
||||||
13
services/failover-agent/Dockerfile
Normal file
13
services/failover-agent/Dockerfile
Normal 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"]
|
||||||
76
services/failover-agent/src/config.rs
Normal file
76
services/failover-agent/src/config.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
services/failover-agent/src/lock.rs
Normal file
124
services/failover-agent/src/lock.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
230
services/failover-agent/src/main.rs
Normal file
230
services/failover-agent/src/main.rs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
services/failover-agent/src/monitor.rs
Normal file
73
services/failover-agent/src/monitor.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
services/failover-agent/src/notifier.rs
Normal file
112
services/failover-agent/src/notifier.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
154
services/failover-agent/src/promoter.rs
Normal file
154
services/failover-agent/src/promoter.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user