Compare commits
104 Commits
351fd7dddd
...
chore/repo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acbf3cc56a | ||
|
|
e2c97bce74 | ||
|
|
8bb323b2aa | ||
|
|
9ab9579cad | ||
|
|
82777aa132 | ||
|
|
45e945f82b | ||
|
|
5d190f00d2 | ||
|
|
64914f98cd | ||
|
|
967e306e44 | ||
|
|
4b9fc196e5 | ||
|
|
f679bceca1 | ||
|
|
9070b5cacd | ||
|
|
a3401cfd95 | ||
|
|
9cccb8ba0e | ||
|
|
28301e68fa | ||
|
|
1511f13970 | ||
|
|
139dbaa9a9 | ||
|
|
0e66f4a04c | ||
|
|
1448f6f530 | ||
|
|
9bdb804dfd | ||
|
|
b923e75103 | ||
|
|
a02a10556e | ||
|
|
5c2ddac702 | ||
|
|
55f189f000 | ||
|
|
5ebd31b049 | ||
|
|
bc021bd80f | ||
|
|
7f717dd84d | ||
|
|
7cbae7d3cc | ||
|
|
00d130d776 | ||
|
|
60764cce50 | ||
|
|
0e6ed3b5db | ||
|
|
1617c89f8f | ||
|
|
875a5a1106 | ||
|
|
b077adf0f8 | ||
|
|
ea1cd59f33 | ||
|
|
161d330801 | ||
|
|
9635ec379b | ||
|
|
739ee9d413 | ||
|
|
43307fea12 | ||
|
|
3046823bb4 | ||
|
|
f6ea571613 | ||
|
|
11f29f9248 | ||
|
|
b2eb75d824 | ||
|
|
bb27ad14aa | ||
|
|
88fe56b11e | ||
|
|
8f1fd2d098 | ||
|
|
192b22b247 | ||
|
|
0bae416111 | ||
|
|
42cda1ce70 | ||
|
|
d709629e66 | ||
|
|
b799cb3109 | ||
| 0f6305e404 | |||
|
|
1571d2fa69 | ||
|
|
0179e593e1 | ||
|
|
3013b8ca66 | ||
|
|
8d2b5ef72a | ||
|
|
09c5451876 | ||
|
|
7bb5cd658a | ||
|
|
87a4d0427e | ||
|
|
99e8dec729 | ||
|
|
d3ec5f8645 | ||
|
|
a166e8ea0e | ||
|
|
9953a49eb3 | ||
|
|
f2ed516748 | ||
|
|
b1d60a5f9a | ||
|
|
99ed6a88a1 | ||
|
|
257976167a | ||
|
|
74e7832aef | ||
|
|
80b0457396 | ||
|
|
073d80dc79 | ||
|
|
cc5b308dfb | ||
|
|
8ce5904cf7 | ||
|
|
5bce7cb5fc | ||
|
|
22962fdab8 | ||
|
|
41c4a2cdfb | ||
|
|
a6fc71bcc9 | ||
|
|
4a7aa94cb9 | ||
|
|
f95575a832 | ||
|
|
12adbc2e8c | ||
|
|
b3fe40637d | ||
|
|
fceb8fe1a5 | ||
|
|
e1c10f58f8 | ||
|
|
1d1d32c4d4 | ||
|
|
a4627c2b4c | ||
|
|
a92b731110 | ||
|
|
4801101d4f | ||
|
|
1b739e2b14 | ||
|
|
95b9d76f3f | ||
|
|
7ca59a43cc | ||
|
|
0093839b5e | ||
|
|
be7f0a8da4 | ||
|
|
44acb87203 | ||
|
|
e9e8d85092 | ||
|
|
930461e7ce | ||
|
|
de661f42e5 | ||
|
|
983c800583 | ||
|
|
92ec74dd92 | ||
|
|
abb95e1501 | ||
|
|
aa7cbbce7d | ||
|
|
910c2ab964 | ||
|
|
0beebb04c0 | ||
|
|
44d3b2cf26 | ||
|
|
21bb2bbea8 | ||
|
|
bec2580e2f |
@@ -1,30 +1,55 @@
|
|||||||
# Rust build artifacts
|
# === Ignorar casi todo por defecto ===
|
||||||
target/
|
**
|
||||||
services/**/target/
|
|
||||||
|
|
||||||
# Node.js
|
# === Archivos y carpetas que SÍ queremos incluir ===
|
||||||
node_modules/
|
!Dockerfile*
|
||||||
**/node_modules/
|
!docker-compose.yml
|
||||||
|
|
||||||
# Tests & Data
|
|
||||||
tests/
|
|
||||||
.docker/
|
|
||||||
infrastructure/postgres/data/
|
|
||||||
|
|
||||||
# Git
|
|
||||||
.git/
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!Cargo.toml
|
||||||
|
!Cargo.lock
|
||||||
|
!package.json
|
||||||
|
!pnpm-lock.yaml
|
||||||
|
!pnpm-workspace.yaml
|
||||||
|
!.sqlx/
|
||||||
|
# Servicios Rust esenciales
|
||||||
|
!services/shared-lib/
|
||||||
|
!services/backend-api/
|
||||||
|
!services/data-collector/
|
||||||
|
!services/json-generator/
|
||||||
|
!services/events-relay/
|
||||||
|
!services/build-orchestrator/
|
||||||
|
!services/telemetry-ingestor/
|
||||||
|
!services/edge-agent/Cargo.toml
|
||||||
|
!services/edge-agent/src/
|
||||||
|
!services/edge-agent/Cargo.lock
|
||||||
|
!services/edge-agent/build.rs
|
||||||
|
!services/edge-agent/embedded_config.toml
|
||||||
|
!services/edge-agent/Dockerfile.builder
|
||||||
|
!services/edge-agent/wix/
|
||||||
|
!services/edge-agent/*.ico
|
||||||
|
!services/edge-agent/*.sh
|
||||||
|
!services/edge-agent/msi_worker.sh
|
||||||
|
|
||||||
# IDE
|
!infrastructure/
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
|
|
||||||
# Docs & Meta
|
# Excepciones dentro de frontend (Node.js)
|
||||||
docs/
|
!services/landing/
|
||||||
LICENSE
|
!services/frontend-pwa/
|
||||||
!README.md
|
!services/frontend-admin/
|
||||||
|
!services/frontend-console/
|
||||||
|
|
||||||
|
# Ignorar dentro de las carpetas permitidas
|
||||||
|
**/target
|
||||||
|
**/node_modules
|
||||||
|
**/.git
|
||||||
|
**/.github
|
||||||
|
**/.vscode
|
||||||
|
**/.idea
|
||||||
|
**/*.log
|
||||||
|
**/.DS_Store
|
||||||
|
.env*
|
||||||
|
**/__pycache__
|
||||||
|
**/dist
|
||||||
|
**/build
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
# OMNIOIL SCADA - CONFIGURACIÓN PARA AMBIENTE LOCAL (DOCUMENTADA)
|
# OMNIOIL SCADA - CONFIGURACIÓN PARA AMBIENTE LOCAL (DOCUMENTADA)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
AGENT_LATEST_VERSION=0.3.1
|
||||||
# --- ORQUESTACIÓN DE COMPOSE ---
|
# --- ORQUESTACIÓN DE COMPOSE ---
|
||||||
COMPOSE_PATH_SEPARATOR=; # Separador de archivos compose para Windows
|
COMPOSE_PATH_SEPARATOR=; # Separador de archivos compose para Windows
|
||||||
COMPOSE_FILE=docker-compose.yml;docker-compose.dev.yml # Archivos compose activos por defecto
|
COMPOSE_FILE=docker-compose.yml;docker-compose.dev.yml # Archivos compose activos por defecto
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -47,3 +47,11 @@ CLAUDE.md
|
|||||||
__pycache__
|
__pycache__
|
||||||
build_test/
|
build_test/
|
||||||
venv
|
venv
|
||||||
|
|
||||||
|
# Local generated artifacts
|
||||||
|
.venv/
|
||||||
|
**/.venv/
|
||||||
|
/services/edge-agent/logs/
|
||||||
|
/services/edge-agent/edge-agent-revision-local*.exe
|
||||||
|
/tests/*.log
|
||||||
|
/tests/simulators/variables_import.csv
|
||||||
|
|||||||
1389
Cargo.lock
generated
1389
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
12
Cargo.toml
@@ -20,6 +20,7 @@ tokio = { version = "1.50.0", features = ["full"] }
|
|||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
chrono = { version = "0.4.44", features = ["serde"] }
|
chrono = { version = "0.4.44", features = ["serde"] }
|
||||||
|
chrono-tz = "0.10.4"
|
||||||
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
@@ -63,3 +64,14 @@ lto = "thin"
|
|||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
# Abort on panic — removes unwinding tables (~5-10% smaller binary).
|
# Abort on panic — removes unwinding tables (~5-10% smaller binary).
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|
||||||
|
# ── CI/Docker profile: fast builds, still production-grade ─────────────────────
|
||||||
|
# Use with `cargo build --profile ci`. Output goes to target/ci/.
|
||||||
|
# Skips LTO (~3-5 min saved) and uses parallel codegen units.
|
||||||
|
# Binaries are ~5-10% larger but functionally identical.
|
||||||
|
[profile.ci]
|
||||||
|
inherits = "release"
|
||||||
|
lto = false
|
||||||
|
strip = true
|
||||||
|
codegen-units = 8
|
||||||
|
|
||||||
|
|||||||
111
Dockerfile.frontend
Normal file
111
Dockerfile.frontend
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# =============================================================================
|
||||||
|
# OmniOil Workspace - Unified Frontend Build Pipeline
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Stage 1: Install workspace dependencies ---
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy workspace configurations and manifests
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
COPY services/landing/package.json ./services/landing/
|
||||||
|
COPY services/frontend-admin/package.json ./services/frontend-admin/
|
||||||
|
COPY services/frontend-console/package.json ./services/frontend-console/
|
||||||
|
COPY services/frontend-pwa/package.json ./services/frontend-pwa/
|
||||||
|
|
||||||
|
# Run installation utilizing pnpm cache mount
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile --store-dir /pnpm/store
|
||||||
|
|
||||||
|
# --- Stage 2: Base builder ---
|
||||||
|
FROM deps AS builder
|
||||||
|
# Copy the rest of the source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# --- Stage 3: Landing build ---
|
||||||
|
FROM builder AS builder-landing
|
||||||
|
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||||
|
RUN pnpm --filter landing build
|
||||||
|
|
||||||
|
# --- Stage 4: Admin build ---
|
||||||
|
FROM builder AS builder-admin
|
||||||
|
ARG VITE_API_BASE
|
||||||
|
ARG VITE_ADMIN_APP_ORIGIN
|
||||||
|
ARG VITE_MOBILE_APP_ORIGIN
|
||||||
|
ARG VITE_API_URL
|
||||||
|
ARG VITE_TURNSTILE_SITE_KEY
|
||||||
|
ARG VITE_LANDING_APP_ORIGIN
|
||||||
|
ENV VITE_API_BASE=$VITE_API_BASE
|
||||||
|
ENV VITE_ADMIN_APP_ORIGIN=$VITE_ADMIN_APP_ORIGIN
|
||||||
|
ENV VITE_MOBILE_APP_ORIGIN=$VITE_MOBILE_APP_ORIGIN
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
ENV VITE_TURNSTILE_SITE_KEY=$VITE_TURNSTILE_SITE_KEY
|
||||||
|
ENV VITE_LANDING_APP_ORIGIN=$VITE_LANDING_APP_ORIGIN
|
||||||
|
RUN pnpm --filter frontend-admin build
|
||||||
|
|
||||||
|
# --- Stage 5: Console build ---
|
||||||
|
FROM builder AS builder-console
|
||||||
|
ARG VITE_API_BASE
|
||||||
|
ARG VITE_CONSOLE_APP_ORIGIN
|
||||||
|
ARG VITE_ACCESS_REQUEST_REVIEWER_EMAILS
|
||||||
|
ENV VITE_API_BASE=$VITE_API_BASE
|
||||||
|
ENV VITE_CONSOLE_APP_ORIGIN=$VITE_CONSOLE_APP_ORIGIN
|
||||||
|
ENV VITE_ACCESS_REQUEST_REVIEWER_EMAILS=$VITE_ACCESS_REQUEST_REVIEWER_EMAILS
|
||||||
|
RUN pnpm --filter frontend-console build
|
||||||
|
|
||||||
|
# --- Stage 6: PWA build ---
|
||||||
|
FROM builder AS builder-pwa
|
||||||
|
ARG VITE_API_BASE
|
||||||
|
ARG VITE_ADMIN_APP_ORIGIN
|
||||||
|
ARG VITE_MOBILE_APP_ORIGIN
|
||||||
|
ARG VITE_API_URL
|
||||||
|
ENV VITE_API_BASE=$VITE_API_BASE
|
||||||
|
ENV VITE_ADMIN_APP_ORIGIN=$VITE_ADMIN_APP_ORIGIN
|
||||||
|
ENV VITE_MOBILE_APP_ORIGIN=$VITE_MOBILE_APP_ORIGIN
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
RUN pnpm --filter frontend-pwa build
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RUNTIME IMAGES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- LANDING RUNTIME (Next.js Standalone Mode in Monorepo) ---
|
||||||
|
FROM node:22-alpine AS landing
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
ENV PORT=80
|
||||||
|
ENV LANDING_API_PROXY_TARGET=http://backend-api:8000
|
||||||
|
|
||||||
|
COPY --from=builder-landing /app/services/landing/public ./services/landing/public
|
||||||
|
COPY --from=builder-landing /app/services/landing/.next/standalone ./
|
||||||
|
COPY --from=builder-landing /app/services/landing/.next/static ./services/landing/.next/static
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["node", "services/landing/server.js"]
|
||||||
|
|
||||||
|
# --- ADMIN RUNTIME (Nginx SPA) ---
|
||||||
|
FROM nginx:alpine AS frontend-admin
|
||||||
|
COPY --from=builder-admin /app/services/frontend-admin/dist /usr/share/nginx/html
|
||||||
|
COPY services/frontend-admin/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
||||||
|
# --- CONSOLE RUNTIME (Nginx SPA) ---
|
||||||
|
FROM nginx:alpine AS frontend-console
|
||||||
|
COPY --from=builder-console /app/services/frontend-console/dist /usr/share/nginx/html
|
||||||
|
COPY services/frontend-console/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
||||||
|
# --- PWA RUNTIME (Nginx SPA) ---
|
||||||
|
FROM nginx:alpine AS frontend-pwa
|
||||||
|
COPY --from=builder-pwa /app/services/frontend-pwa/dist /usr/share/nginx/html
|
||||||
|
COPY services/frontend-pwa/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -7,94 +7,112 @@ FROM lukemathwalker/cargo-chef:latest-rust-1.93-slim-bookworm AS base
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
cmake nasm pkg-config libssl-dev libglib2.0-dev libatk1.0-dev libgtk-3-dev build-essential ca-certificates lld \
|
pkg-config libssl-dev build-essential ca-certificates lld \
|
||||||
|
gcc-mingw-w64-x86-64 musl-tools \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN rustup target add x86_64-pc-windows-gnu && \
|
||||||
|
rustup target add x86_64-unknown-linux-musl
|
||||||
|
|
||||||
|
# Variables de entorno para cross-compilation
|
||||||
|
ENV CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
|
||||||
|
ENV CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++
|
||||||
|
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
|
||||||
|
ENV OPENSSL_NO_ASM=1
|
||||||
|
|
||||||
ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld"
|
ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld"
|
||||||
ENV CARGO_INCREMENTAL=0
|
ENV CARGO_INCREMENTAL=0
|
||||||
|
|
||||||
# 2. PLANNER: Solo analiza dependencias (cambia poco)
|
# 2. PLANNER: Solo analiza dependencias (cambia poco)
|
||||||
|
# Copiamos solo los Cargo.toml y Cargo.lock para máxima reutilización de caché
|
||||||
FROM base AS planner
|
FROM base AS planner
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY services/ services/
|
COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
|
||||||
|
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
|
||||||
|
COPY services/data-collector/Cargo.toml services/data-collector/Cargo.toml
|
||||||
|
COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
|
||||||
|
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
|
||||||
|
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/Cargo.toml
|
||||||
|
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
|
||||||
|
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
|
||||||
|
|
||||||
|
# Creamos archivos stub para cada crate (evita "file not found" en cargo metadata)
|
||||||
|
RUN mkdir -p services/shared-lib/src && echo "// stub" > services/shared-lib/src/lib.rs && \
|
||||||
|
mkdir -p services/backend-api/src && echo "// stub" > services/backend-api/src/lib.rs && echo "fn main() {}" > services/backend-api/src/main.rs && \
|
||||||
|
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
|
||||||
|
mkdir -p services/json-generator/src && echo "// stub" > services/json-generator/src/lib.rs && echo "fn main() {}" > services/json-generator/src/main.rs && \
|
||||||
|
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
|
||||||
|
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \
|
||||||
|
mkdir -p services/edge-agent/src/bin && echo "fn main() {}" > services/edge-agent/src/bin/edge-tray.rs && echo "fn main() {}" > services/edge-agent/src/main.rs && \
|
||||||
|
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
|
||||||
|
|
||||||
|
# Generamos recipe.json para TODAS las dependencias del workspace
|
||||||
|
# Esto permite que todas las dependencias comunes se cacheen juntas
|
||||||
RUN cargo chef prepare --recipe-path recipe.json
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
# 3. CACHER: Compila TODAS las dependencias del workspace UNA vez
|
# 3. CACHER: Compila TODAS las dependencias del workspace UNA vez
|
||||||
FROM base AS cacher
|
FROM base AS cacher
|
||||||
COPY --from=planner /app/recipe.json recipe.json
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
|
||||||
--mount=type=cache,target=/usr/local/cargo/git \
|
# 3a. Cook default workspace dependencies (Linux GNU)
|
||||||
--mount=type=cache,target=/app/target \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
cargo chef cook --release --recipe-path recipe.json
|
cargo chef cook --release --recipe-path recipe.json
|
||||||
|
|
||||||
# 4. BUILDER-LINUX: Base para servicios Linux (rápida)
|
# 3b. Cook edge-agent dependencies for Windows GNU (con RUSTFLAGS idénticos)
|
||||||
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
|
RUSTFLAGS="-C target-feature=+crt-static -C link-args=-static" \
|
||||||
|
cargo chef cook --release --target x86_64-pc-windows-gnu --features edge-agent/opcua-support --recipe-path recipe.json
|
||||||
|
|
||||||
|
# 3c. Cook edge-agent dependencies for Linux musl
|
||||||
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
|
cargo chef cook --release --target x86_64-unknown-linux-musl --features edge-agent/opcua-support --recipe-path recipe.json
|
||||||
|
|
||||||
|
# 4. BUILDER-LINUX: Base para servicios Linux (reutiliza caché de deps)
|
||||||
FROM base AS builder-linux
|
FROM base AS builder-linux
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY --from=cacher /app /app
|
||||||
# Crear estructura de carpetas y archivos dummy
|
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
||||||
RUN mkdir -p services/backend-api/src && echo "fn main() {}" > services/backend-api/src/main.rs && \
|
# Ahora copiar el código real de shared-lib (debe estar DESPUÉS de usar cacher)
|
||||||
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
|
|
||||||
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
|
|
||||||
mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
|
|
||||||
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \
|
|
||||||
mkdir -p services/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \
|
|
||||||
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
|
|
||||||
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
|
|
||||||
|
|
||||||
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
|
|
||||||
COPY services/data-collector/Cargo.toml services/data-collector/Cargo.toml
|
|
||||||
COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
|
|
||||||
COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
|
|
||||||
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/Cargo.toml
|
|
||||||
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
|
|
||||||
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
|
|
||||||
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
|
|
||||||
|
|
||||||
# Pre-descargar todas las dependencias de forma secuencial para evitar colisiones
|
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
|
||||||
cargo fetch
|
|
||||||
|
|
||||||
# Compilar shared-lib primero
|
|
||||||
COPY services/shared-lib services/shared-lib
|
COPY services/shared-lib services/shared-lib
|
||||||
|
# Compilar shared-lib (será rápido debido al caché de deps)
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,target=/app/target \
|
||||||
cargo build --release -p shared-lib
|
cargo build --release -p shared-lib
|
||||||
|
|
||||||
# 5. BUILDER-WINDOWS: Especializada en el agente
|
# 5. BUILDER-WINDOWS: Especializada en el agente
|
||||||
FROM base AS builder-windows
|
FROM base AS builder-windows
|
||||||
RUN apt-get update && apt-get install -y gcc-mingw-w64-x86-64 && rm -rf /var/lib/apt/lists/*
|
COPY --from=cacher /app /app
|
||||||
RUN rustup target add x86_64-pc-windows-gnu
|
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
||||||
COPY Cargo.toml Cargo.lock ./
|
|
||||||
RUN mkdir -p services/backend-api/src && echo "fn main() {}" > services/backend-api/src/main.rs && \
|
|
||||||
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
|
|
||||||
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
|
|
||||||
mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
|
|
||||||
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \
|
|
||||||
mkdir -p services/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \
|
|
||||||
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
|
|
||||||
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
|
|
||||||
|
|
||||||
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
|
|
||||||
COPY services/data-collector/Cargo.toml services/data-collector/Cargo.toml
|
|
||||||
COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
|
|
||||||
COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
|
|
||||||
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/Cargo.toml
|
|
||||||
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
|
|
||||||
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
|
|
||||||
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
|
|
||||||
|
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
|
||||||
cargo fetch
|
|
||||||
|
|
||||||
COPY services/shared-lib services/shared-lib
|
COPY services/shared-lib services/shared-lib
|
||||||
COPY services/edge-agent services/edge-agent
|
COPY services/edge-agent services/edge-agent
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
|
||||||
|
# Parchear la versión del agente en Cargo.toml antes de compilar
|
||||||
|
ARG BUILD_VERSION=0.3.0
|
||||||
|
RUN CURRENT_VERSION=$(grep -m 1 "^version =" services/edge-agent/Cargo.toml | cut -d '"' -f 2) && \
|
||||||
|
if [ "$CURRENT_VERSION" != "$BUILD_VERSION" ]; then \
|
||||||
|
sed -i "0,/^version = /s/^version = \"[^\"]*\"/version = \"${BUILD_VERSION}\"/" services/edge-agent/Cargo.toml; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/app/target \
|
||||||
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
RUSTFLAGS="-C target-feature=+crt-static -C link-args=-static" \
|
||||||
cargo build --release -p edge-agent --target x86_64-pc-windows-gnu && \
|
cargo build --release -p edge-agent --target x86_64-pc-windows-gnu && \
|
||||||
mkdir -p /app/bin/windows && cp target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/bin/windows/
|
cargo build --release -p edge-agent --bin edge-agent --target x86_64-unknown-linux-musl && \
|
||||||
|
mkdir -p /app/bin/windows && cp target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/bin/windows/ && \
|
||||||
|
mkdir -p /app/bin/linux && cp target/x86_64-unknown-linux-musl/release/edge-agent /app/bin/linux/
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RUNTIME BASE: Imagen base optimizada para todos los servicios
|
||||||
|
# =============================================================================
|
||||||
|
# Un ÚNICO apt-get update para todos los paquetes de runtime
|
||||||
|
FROM debian:bookworm-slim AS runtime-base
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libssl3 ca-certificates curl mosquitto-clients \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# INDIVIDUAL SERVICE TARGETS
|
# INDIVIDUAL SERVICE TARGETS
|
||||||
@@ -103,18 +121,17 @@ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
|||||||
# --- BACKEND-API ---
|
# --- BACKEND-API ---
|
||||||
FROM builder-linux AS builder-backend-api
|
FROM builder-linux AS builder-backend-api
|
||||||
COPY services/backend-api services/backend-api
|
COPY services/backend-api services/backend-api
|
||||||
COPY services/shared-lib services/shared-lib
|
COPY services/json-generator services/json-generator
|
||||||
COPY .sqlx .sqlx
|
COPY .sqlx .sqlx
|
||||||
ENV SQLX_OFFLINE=true
|
ENV SQLX_OFFLINE=true
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,target=/app/target \
|
||||||
cargo build --release -p backend-api && \
|
cargo build --release -p backend-api && \
|
||||||
cp target/release/backend-api /app/backend-api
|
cp target/release/backend-api /app/backend-api
|
||||||
|
|
||||||
FROM debian:bookworm-slim AS backend-api
|
FROM runtime-base AS backend-api
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y libssl3 ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY --from=builder-backend-api /app/backend-api .
|
COPY --from=builder-backend-api /app/backend-api .
|
||||||
ENV RUST_LOG=info
|
ENV RUST_LOG=info
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
@@ -125,13 +142,12 @@ FROM builder-linux AS builder-data-collector
|
|||||||
COPY services/data-collector services/data-collector
|
COPY services/data-collector services/data-collector
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,target=/app/target \
|
||||||
cargo build --release -p data-collector && \
|
cargo build --release -p data-collector && \
|
||||||
cp target/release/data-collector /app/data-collector
|
cp target/release/data-collector /app/data-collector
|
||||||
|
|
||||||
FROM debian:bookworm-slim AS data-collector
|
FROM runtime-base AS data-collector
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY --from=builder-data-collector /app/data-collector .
|
COPY --from=builder-data-collector /app/data-collector .
|
||||||
ENV RUST_LOG=info
|
ENV RUST_LOG=info
|
||||||
CMD ["./data-collector"]
|
CMD ["./data-collector"]
|
||||||
@@ -141,13 +157,12 @@ FROM builder-linux AS builder-json-generator
|
|||||||
COPY services/json-generator services/json-generator
|
COPY services/json-generator services/json-generator
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,target=/app/target \
|
||||||
cargo build --release -p json-generator && \
|
cargo build --release -p json-generator && \
|
||||||
cp target/release/json-generator /app/json-generator
|
cp target/release/json-generator /app/json-generator
|
||||||
|
|
||||||
FROM debian:bookworm-slim AS json-generator
|
FROM runtime-base AS json-generator
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY --from=builder-json-generator /app/json-generator .
|
COPY --from=builder-json-generator /app/json-generator .
|
||||||
ENV RUST_LOG=info
|
ENV RUST_LOG=info
|
||||||
CMD ["./json-generator"]
|
CMD ["./json-generator"]
|
||||||
@@ -157,13 +172,12 @@ FROM builder-linux AS builder-telemetry-ingestor
|
|||||||
COPY services/telemetry-ingestor services/telemetry-ingestor
|
COPY services/telemetry-ingestor services/telemetry-ingestor
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,target=/app/target \
|
||||||
cargo build --release -p telemetry-ingestor && \
|
cargo build --release -p telemetry-ingestor && \
|
||||||
cp target/release/telemetry-ingestor /app/telemetry-ingestor
|
cp target/release/telemetry-ingestor /app/telemetry-ingestor
|
||||||
|
|
||||||
FROM debian:bookworm-slim AS telemetry-ingestor
|
FROM runtime-base AS telemetry-ingestor
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY --from=builder-telemetry-ingestor /app/telemetry-ingestor .
|
COPY --from=builder-telemetry-ingestor /app/telemetry-ingestor .
|
||||||
ENV RUST_LOG=info
|
ENV RUST_LOG=info
|
||||||
CMD ["./telemetry-ingestor"]
|
CMD ["./telemetry-ingestor"]
|
||||||
@@ -173,13 +187,12 @@ FROM builder-linux AS builder-events-relay
|
|||||||
COPY services/events-relay services/events-relay
|
COPY services/events-relay services/events-relay
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,target=/app/target \
|
||||||
cargo build --release -p events-relay && \
|
cargo build --release -p events-relay && \
|
||||||
cp target/release/events-relay /app/events-relay
|
cp target/release/events-relay /app/events-relay
|
||||||
|
|
||||||
FROM debian:bookworm-slim AS events-relay
|
FROM runtime-base AS events-relay
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY --from=builder-events-relay /app/events-relay .
|
COPY --from=builder-events-relay /app/events-relay .
|
||||||
ENV RUST_LOG=info
|
ENV RUST_LOG=info
|
||||||
CMD ["./events-relay"]
|
CMD ["./events-relay"]
|
||||||
@@ -189,21 +202,21 @@ FROM builder-linux AS builder-build-orchestrator
|
|||||||
COPY services/build-orchestrator services/build-orchestrator
|
COPY services/build-orchestrator services/build-orchestrator
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,target=/app/target \
|
||||||
cargo build --release -p build-orchestrator && \
|
cargo build --release -p build-orchestrator && \
|
||||||
cp target/release/build-orchestrator /app/build-orchestrator
|
cp target/release/build-orchestrator /app/build-orchestrator
|
||||||
|
|
||||||
FROM debian:bookworm-slim AS build-orchestrator
|
FROM runtime-base AS build-orchestrator
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y libssl3 ca-certificates mosquitto-clients && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Binario del orquestador
|
|
||||||
COPY --from=builder-build-orchestrator /app/build-orchestrator .
|
COPY --from=builder-build-orchestrator /app/build-orchestrator .
|
||||||
|
|
||||||
# Template del agente (Desde la etapa builder-windows)
|
# Template del agente (Desde la etapa builder-windows)
|
||||||
RUN mkdir -p target/x86_64-pc-windows-gnu/release
|
RUN mkdir -p target/x86_64-pc-windows-gnu/release
|
||||||
COPY --from=builder-windows /app/bin/windows/edge-agent.exe target/x86_64-pc-windows-gnu/release/
|
COPY --from=builder-windows /app/bin/windows/edge-agent.exe target/x86_64-pc-windows-gnu/release/
|
||||||
|
|
||||||
|
RUN mkdir -p target/x86_64-unknown-linux-musl/release
|
||||||
|
COPY --from=builder-windows /app/bin/linux/edge-agent target/x86_64-unknown-linux-musl/release/
|
||||||
|
|
||||||
# Cargo.toml del agente
|
# Cargo.toml del agente
|
||||||
RUN mkdir -p services/edge-agent
|
RUN mkdir -p services/edge-agent
|
||||||
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
|
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ services:
|
|||||||
- SMTP_TLS_MODE=none
|
- SMTP_TLS_MODE=none
|
||||||
- SMTP_USER=
|
- SMTP_USER=
|
||||||
- SMTP_PASS=
|
- SMTP_PASS=
|
||||||
- SMTP_FROM=notificaciones-local@omnioil.app
|
- SMTP_FROM=OmniOil <soporte@omnioil.app>
|
||||||
- INVITATION_BASE_URL=http://localhost:3000
|
- INVITATION_BASE_URL=http://localhost:3000
|
||||||
- PLATFORM_CONSOLE_URL=http://localhost:3002/login
|
- PLATFORM_CONSOLE_URL=http://localhost:3002/login
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ services:
|
|||||||
- MQTT_PUBLIC_PORT=${MQTT_PUBLIC_PORT:-1883}
|
- MQTT_PUBLIC_PORT=${MQTT_PUBLIC_PORT:-1883}
|
||||||
- MQTT_USE_WS=${MQTT_USE_WS:-false}
|
- MQTT_USE_WS=${MQTT_USE_WS:-false}
|
||||||
- APP_PUBLIC_URL=${APP_PUBLIC_URL}
|
- APP_PUBLIC_URL=${APP_PUBLIC_URL}
|
||||||
|
- AGENT_LATEST_VERSION=${AGENT_LATEST_VERSION:-0.3.0}
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -150,9 +151,12 @@ services:
|
|||||||
target: json-generator
|
target: json-generator
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
||||||
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
|
- MINIO_ENDPOINT_URL=http://minio:9000
|
||||||
|
- MINIO_ENDPOINT=http://minio:9000
|
||||||
|
- MINIO_BUCKET=${MINIO_BUCKET:-anh-reports}
|
||||||
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
||||||
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
||||||
|
- AWS_REGION=${AWS_REGION:-us-east-1}
|
||||||
depends_on:
|
depends_on:
|
||||||
backend-api:
|
backend-api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -165,7 +169,8 @@ services:
|
|||||||
container_name: anh_landing
|
container_name: anh_landing
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: services/landing/Dockerfile
|
dockerfile: Dockerfile.frontend
|
||||||
|
target: landing
|
||||||
args:
|
args:
|
||||||
- NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-${VITE_TURNSTILE_SITE_KEY}}
|
- NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-${VITE_TURNSTILE_SITE_KEY}}
|
||||||
environment:
|
environment:
|
||||||
@@ -180,7 +185,8 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: services/frontend-pwa/Dockerfile
|
dockerfile: Dockerfile.frontend
|
||||||
|
target: frontend-pwa
|
||||||
args:
|
args:
|
||||||
- VITE_API_BASE=/api
|
- VITE_API_BASE=/api
|
||||||
- VITE_ADMIN_APP_ORIGIN=${VITE_ADMIN_APP_ORIGIN}
|
- VITE_ADMIN_APP_ORIGIN=${VITE_ADMIN_APP_ORIGIN}
|
||||||
@@ -195,8 +201,9 @@ services:
|
|||||||
container_name: anh_frontend_admin
|
container_name: anh_frontend_admin
|
||||||
restart: always
|
restart: always
|
||||||
build:
|
build:
|
||||||
context: ./services/frontend-admin
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.frontend
|
||||||
|
target: frontend-admin
|
||||||
args:
|
args:
|
||||||
- VITE_API_BASE=/api
|
- VITE_API_BASE=/api
|
||||||
- VITE_ADMIN_APP_ORIGIN=${VITE_ADMIN_APP_ORIGIN}
|
- VITE_ADMIN_APP_ORIGIN=${VITE_ADMIN_APP_ORIGIN}
|
||||||
@@ -213,11 +220,13 @@ services:
|
|||||||
container_name: anh_frontend_console
|
container_name: anh_frontend_console
|
||||||
restart: always
|
restart: always
|
||||||
build:
|
build:
|
||||||
context: ./services/frontend-console
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.frontend
|
||||||
|
target: frontend-console
|
||||||
args:
|
args:
|
||||||
- VITE_API_BASE=/api
|
- VITE_API_BASE=/api
|
||||||
- VITE_CONSOLE_APP_ORIGIN=${VITE_CONSOLE_APP_ORIGIN:-https://console.omnioil.app}
|
- VITE_CONSOLE_APP_ORIGIN=${VITE_CONSOLE_APP_ORIGIN:-https://console.omnioil.app}
|
||||||
|
- VITE_ACCESS_REQUEST_REVIEWER_EMAILS=${ACCESS_REQUEST_REVIEWER_EMAILS:-w.farfan@omnioil.app,h.ortegon@omnioil.app}
|
||||||
expose:
|
expose:
|
||||||
- "80"
|
- "80"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -252,6 +261,8 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.unified
|
dockerfile: Dockerfile.unified
|
||||||
target: build-orchestrator
|
target: build-orchestrator
|
||||||
|
args:
|
||||||
|
- BUILD_VERSION=${AGENT_LATEST_VERSION:-0.3.0}
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
||||||
- MQTT_HOST=mosquitto
|
- MQTT_HOST=mosquitto
|
||||||
@@ -269,6 +280,7 @@ services:
|
|||||||
- MQTT_USE_WS=${MQTT_USE_WS:-true}
|
- MQTT_USE_WS=${MQTT_USE_WS:-true}
|
||||||
- OMNIOIL_MASTER_SECRET=${OMNIOIL_MASTER_SECRET}
|
- OMNIOIL_MASTER_SECRET=${OMNIOIL_MASTER_SECRET}
|
||||||
- S3_INSTALLERS_BUCKET=${S3_INSTALLERS_BUCKET:-omnioil-releases}
|
- S3_INSTALLERS_BUCKET=${S3_INSTALLERS_BUCKET:-omnioil-releases}
|
||||||
|
- AGENT_LATEST_VERSION=${AGENT_LATEST_VERSION:-0.3.0}
|
||||||
volumes:
|
volumes:
|
||||||
- ./infrastructure/mosquitto:/app/infrastructure/mosquitto
|
- ./infrastructure/mosquitto:/app/infrastructure/mosquitto
|
||||||
- ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming
|
- ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming
|
||||||
@@ -286,6 +298,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: services/edge-agent/Dockerfile.builder
|
dockerfile: services/edge-agent/Dockerfile.builder
|
||||||
|
args:
|
||||||
|
- BUILD_VERSION=${AGENT_LATEST_VERSION:-0.3.0}
|
||||||
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
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ La **Resolución 0651 de 2025** de la Agencia Nacional de Hidrocarburos (ANH) es
|
|||||||
- Instalación de sistemas de telemetría certificados en todos los campos de producción activos
|
- Instalación de sistemas de telemetría certificados en todos los campos de producción activos
|
||||||
- Reporte diario automático en formato JSON con identificador único de recurso
|
- Reporte diario automático en formato JSON con identificador único de recurso
|
||||||
- Almacenamiento de datos con frecuencia mínima de 5 minutos
|
- Almacenamiento de datos con frecuencia mínima de 5 minutos
|
||||||
- Mecanismos de integridad de datos (hash SHA-256) en cada reporte
|
- Mecanismos de integridad, inalterabilidad y trazabilidad del dato reportado
|
||||||
- Trazabilidad completa de ingresos y movimientos de crudo
|
- Trazabilidad completa de ingresos y movimientos de crudo
|
||||||
- Registro de paradas planificadas y no planificadas con justificación
|
- Registro de paradas planificadas y no planificadas con justificación
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ El incumplimiento de la Resolución puede resultar en:
|
|||||||
| **Art. 7** | Sistema de medición continua (mínimo cada 5 min) | Telemetría cada 10 segundos vía MQTT, almacenada en TimescaleDB | ✅ |
|
| **Art. 7** | Sistema de medición continua (mínimo cada 5 min) | Telemetría cada 10 segundos vía MQTT, almacenada en TimescaleDB | ✅ |
|
||||||
| **Art. 17** | Almacenamiento periódico mínimo 5 min | Hypertable con registros cada ≤5 minutos. Resolución real: 10 segundos | ✅ |
|
| **Art. 17** | Almacenamiento periódico mínimo 5 min | Hypertable con registros cada ≤5 minutos. Resolución real: 10 segundos | ✅ |
|
||||||
| **Art. 22** | Redundancia ante fallos de comunicación | Store & Forward en Edge Agent (SQLite AES-256 local) | ✅ |
|
| **Art. 22** | Redundancia ante fallos de comunicación | Store & Forward en Edge Agent (SQLite AES-256 local) | ✅ |
|
||||||
| **Art. 26** | Integridad e inalterabilidad de datos | TimescaleDB append-only + hash SHA-256 en cada reporte | ✅ |
|
| **Art. 26** | Integridad e inalterabilidad de datos | TimescaleDB append-only + artefactos JSON sellados en MinIO; cada corrección crea una nueva versión inmutable con hash SHA-384 propio | ✅ |
|
||||||
| **Art. 27** | Timestamps UTC obligatorios | `timestamptz` en todas las tablas, NTP sincronizado | ✅ |
|
| **Art. 27** | Timestamps UTC obligatorios | `timestamptz` en todas las tablas, NTP sincronizado | ✅ |
|
||||||
| **Art. 34** | Nomenclatura del archivo de reporte | `OPERADOR_CONTRATO_DD-MMM-YYYY.json` implementado | ✅ |
|
| **Art. 34** | Nomenclatura del archivo de reporte | `OPERADOR_CONTRATO_DD-MMM-YYYY.json` implementado | ✅ |
|
||||||
| **Art. 35** | ID de recurso coincidente con Forma 4CR | Campo `id_recurso` mapeado al código ANH del pozo | ✅ |
|
| **Art. 35** | ID de recurso coincidente con Forma 4CR | Campo `id_recurso` mapeado al código ANH del pozo | ✅ |
|
||||||
@@ -41,27 +41,19 @@ El incumplimiento de la Resolución puede resultar en:
|
|||||||
| **Art. 52** | Registro de paradas y excepciones | Sección `exceptions` en el reporte JSON con alarmas detectadas | ✅ |
|
| **Art. 52** | Registro de paradas y excepciones | Sección `exceptions` en el reporte JSON con alarmas detectadas | ✅ |
|
||||||
| **Art. 55** | Auditoría de datos ingresados manualmente | `audit_log` con usuario, IP, timestamp, valor anterior/nuevo | ✅ |
|
| **Art. 55** | Auditoría de datos ingresados manualmente | `audit_log` con usuario, IP, timestamp, valor anterior/nuevo | ✅ |
|
||||||
| **Art. 60** | Metrología y calibración | Campos de calibración en activos: última fecha, próxima fecha, certificado | ✅ |
|
| **Art. 60** | Metrología y calibración | Campos de calibración en activos: última fecha, próxima fecha, certificado | ✅ |
|
||||||
| **Art. 65** | Acceso del ente regulador | Rol `anh_reader` con acceso de solo lectura a reportes y datos históricos | ✅ |
|
| **Art. 65** | Acceso del ente regulador | Rol `anh_reader` con acceso de solo lectura a reportes, versiones corregidas y datos históricos dispuestos para consulta ANH | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Integridad de Datos
|
## 3. Integridad de Datos
|
||||||
|
|
||||||
### 3.1 Hash SHA-256
|
### 3.1 Artefacto JSON sellado e integridad interna
|
||||||
|
|
||||||
Cada reporte diario generado incluye un hash de integridad en el encabezado:
|
El JSON entregado a la ANH mantiene únicamente la estructura normativa del Anexo 4. OmniOil no agrega campos propios como `Hash Sello`, `INTEGRITY_HASH` o un `hash_sha256` en el cuerpo del JSON entregable.
|
||||||
|
|
||||||
```json
|
Para auditoría interna, cada reporte se sella como bytes finales en MinIO y el sistema calcula `hash_sha384` sobre esos bytes exactos. Ese hash se conserva como metadato de evidencia, separado del contenido normativo del archivo.
|
||||||
{
|
|
||||||
"header": {
|
|
||||||
"hash_sha256": "a3f5c8d9e2b1f4a7c6d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
|
|
||||||
"algoritmo": "SHA-256",
|
|
||||||
"fecha_generacion": "2026-05-04T06:30:00Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Cualquier alteración del contenido del archivo después de su generación invalidará el hash, permitiendo detectar manipulaciones.
|
Cualquier alteración del objeto sellado cambia el SHA-384 esperado y permite detectar manipulaciones sin modificar la forma del JSON que recibe la ANH.
|
||||||
|
|
||||||
### 3.2 Timestamps UTC
|
### 3.2 Timestamps UTC
|
||||||
|
|
||||||
@@ -208,18 +200,44 @@ CREATE TABLE audit_log (
|
|||||||
|
|
||||||
### 8.1 Proceso de Generación
|
### 8.1 Proceso de Generación
|
||||||
|
|
||||||
El microservicio `json-generator` ejecuta diariamente a las **06:30 AM UTC**:
|
El microservicio `json-generator` genera reportes ANH con una programación administrable desde el panel Admin. La configuración por defecto es **06:00** en zona horaria **America/Bogota** y puede activarse/desactivarse por unidad de generación ANH.
|
||||||
|
|
||||||
1. Consulta todos los proyectos activos con contrato ANH configurado
|
1. Consulta todos los proyectos activos con contrato ANH configurado
|
||||||
2. Para cada proyecto, agrega:
|
2. Para cada proyecto, agrega:
|
||||||
- Telemetría de las últimas 24h (con promedio, mínimo y máximo por variable)
|
- Telemetría de las últimas 24h (con promedio, mínimo y máximo por variable)
|
||||||
- Movimientos de producción del período
|
- Movimientos de producción del período
|
||||||
- Alarmas y excepciones del período
|
- Alarmas y excepciones del período
|
||||||
3. Calcula el hash SHA-256 del contenido
|
3. Serializa el JSON normativo final, sin campos de hash agregados por OmniOil
|
||||||
4. Guarda el archivo en MinIO (bucket `anh-reports`)
|
4. Guarda los bytes finales en MinIO (bucket `anh-reports`) y registra `hash_sha384` como metadato interno de auditoría
|
||||||
5. Envía notificación de reporte generado a los supervisores
|
5. Deja el reporte en estado generado/disponible para revisión; no lo aprueba automáticamente
|
||||||
|
|
||||||
### 8.2 Generación Manual
|
### 8.2 Estados del ciclo de vida
|
||||||
|
|
||||||
|
- `GENERATED`: reporte sellado y disponible para revisión manual.
|
||||||
|
- `APPROVED`: reporte aprobado manualmente por un administrador.
|
||||||
|
- `SENT` / `DISPOSED`: estados históricos legibles por compatibilidad; en la interfaz se interpretan como **disponible para ANH** o **dispuesto para consulta ANH**.
|
||||||
|
- `REJECTED`: ANH registró rechazo o inconsistencia sobre una versión disponible.
|
||||||
|
- `CORRECTION_DRAFT`: nueva versión de corrección en preparación; no modifica el artefacto sellado anterior.
|
||||||
|
- `CORRECTED_APPROVED`: versión corregida aprobada y sellada con hash propio.
|
||||||
|
- `AVAILABLE_FOR_ANH`: artefacto sellado disponible para consulta ANH.
|
||||||
|
- `FAILED`: generación fallida o error operativo.
|
||||||
|
|
||||||
|
### 8.3 Correcciones y re-disposición para consulta ANH
|
||||||
|
|
||||||
|
OmniOil **no envía reportes a la ANH** ni representa una retransmisión saliente por API. El sistema conserva artefactos sellados y los deja **disponibles para ANH** / **dispuestos para consulta ANH** cuando el regulador decida accederlos.
|
||||||
|
|
||||||
|
Flujo de corrección:
|
||||||
|
|
||||||
|
1. Un administrador autorizado registra el rechazo o inconsistencia con motivo obligatorio y fecha/hora del rechazo.
|
||||||
|
2. Desde esa fecha/hora corre un SLA de **1 hora** para preparar la corrección.
|
||||||
|
3. La corrección se crea como una nueva versión inmutable (`correction_version`) enlazada a la versión previa y a la raíz del reporte.
|
||||||
|
4. El artefacto anterior permanece sellado: no se sobrescriben `object_key`, `hash_sha384` ni `sealed_at`.
|
||||||
|
5. La versión corregida se aprueba y se sella con su propio `object_key`, `hash_sha384` y `sealed_at`.
|
||||||
|
6. El administrador marca la versión corregida como **disponible para ANH**; esto solo registra la disposición para consulta y no dispara integración saliente.
|
||||||
|
|
||||||
|
Cada evento queda en auditoría con actor, motivo, timestamp, versión previa, versión raíz y metadatos del artefacto sellado. La historia permite reconstruir la cadena completa: original → rechazo/inconsistencia → versión corregida → artefacto corregido sellado → disponible para ANH.
|
||||||
|
|
||||||
|
### 8.4 Generación Manual
|
||||||
|
|
||||||
Para generar el reporte de un día específico:
|
Para generar el reporte de un día específico:
|
||||||
```
|
```
|
||||||
@@ -244,7 +262,11 @@ Antes de la certificación ante la ANH, verificar:
|
|||||||
- [ ] Reglas de alerta configuradas para variables críticas
|
- [ ] Reglas de alerta configuradas para variables críticas
|
||||||
- [ ] Fechas de calibración actualizadas en todos los activos
|
- [ ] Fechas de calibración actualizadas en todos los activos
|
||||||
- [ ] Primer reporte ANH generado y verificado manualmente
|
- [ ] Primer reporte ANH generado y verificado manualmente
|
||||||
- [ ] Hash SHA-256 verificado correctamente
|
- [ ] Hash interno SHA-384 verificado contra los bytes sellados descargados desde MinIO
|
||||||
|
- [ ] Correcciones ANH verificadas como versiones inmutables, sin sobrescribir artefactos sellados previos
|
||||||
|
- [ ] Rechazos/inconsistencias registran motivo obligatorio y SLA de 1 hora desde el timestamp de rechazo
|
||||||
|
- [ ] Versiones corregidas aprobadas quedan disponibles para ANH / dispuestas para consulta ANH, sin flujo de envío saliente
|
||||||
|
- [ ] Audit trail muestra actor, motivo, timestamp, versión previa, versión raíz, hash y objeto sellado
|
||||||
- [ ] Nomenclatura del archivo cumple con el Art. 34
|
- [ ] Nomenclatura del archivo cumple con el Art. 34
|
||||||
- [ ] Rol `anh_reader` creado para el auditor de la ANH
|
- [ ] Rol `anh_reader` creado para el auditor de la ANH
|
||||||
- [ ] MinIO accesible para descarga de reportes
|
- [ ] MinIO accesible para descarga de reportes
|
||||||
|
|||||||
@@ -102,14 +102,14 @@ COMPOSE_PATH_SEPARATOR=:
|
|||||||
INVITATION_BASE_URL=https://app.omnioil.app
|
INVITATION_BASE_URL=https://app.omnioil.app
|
||||||
SMTP_HOST=smtp.tu-proveedor.com
|
SMTP_HOST=smtp.tu-proveedor.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=notificaciones@omnioil.app
|
SMTP_USER=soporte@omnioil.app
|
||||||
SMTP_PASS=<gestionar-en-secrets-manager>
|
SMTP_PASS=<gestionar-en-secrets-manager>
|
||||||
SMTP_FROM=notificaciones@omnioil.app
|
SMTP_FROM=OmniOil <soporte@omnioil.app>
|
||||||
```
|
```
|
||||||
|
|
||||||
`INVITATION_BASE_URL` debe apuntar al Admin App público porque los clientes activan su cuenta en `https://app.omnioil.app/activate?token=...`, no en la Console interna. Las variables `SMTP_*` son requeridas para los correos de aprobación/activación y reutilizan la configuración SMTP del backend; `SMTP_PASS` debe cargarse desde el gestor de secretos de Dokploy o equivalente, nunca desde el repositorio.
|
`INVITATION_BASE_URL` debe apuntar al Admin App público porque los clientes activan su cuenta en `https://app.omnioil.app/activate?token=...`, no en la Console interna. Las variables `SMTP_*` son requeridas para acuses de recibo, rechazos, aprobación/activación y reutilizan la configuración SMTP del backend; `SMTP_PASS` debe cargarse desde el gestor de secretos de Dokploy o equivalente, nunca desde el repositorio. Los únicos correos autorizados para aprobar, rechazar, marcar en revisión o reenviar invitaciones son `w.farfan@omnioil.app` y `h.ortegon@omnioil.app`; esta allowlist se aplica en backend.
|
||||||
|
|
||||||
Cuando se aprueba una solicitud, el backend crea el proyecto, el usuario administrador cliente, el acceso al proyecto y una suscripción inicial `trial` de 7 días antes de intentar enviar el correo. Si SMTP falla, la aprobación NO se revierte: el operador debe revisar la solicitud en Console y usar `Reenviar invitación` después de corregir la configuración o incidente SMTP.
|
Cuando se aprueba una solicitud, el backend crea el proyecto, el usuario administrador cliente, el acceso al proyecto y una suscripción inicial `trial` de 30 días antes de intentar enviar el correo. Si SMTP falla, la aprobación NO se revierte: el operador debe revisar la solicitud en Console y usar `Reenviar invitación` después de corregir la configuración o incidente SMTP.
|
||||||
|
|
||||||
### 2.3 Configuración MQTT sobre WSS
|
### 2.3 Configuración MQTT sobre WSS
|
||||||
|
|
||||||
@@ -136,11 +136,13 @@ wss://mqtt.omnioil.app:443
|
|||||||
- [ ] `TURNSTILE_SECRET_KEY` configurado en backend para validar solicitudes públicas de acceso
|
- [ ] `TURNSTILE_SECRET_KEY` configurado en backend para validar solicitudes públicas de acceso
|
||||||
- [ ] Puertos 5432 (TimescaleDB) y 6379 (Redis) NO expuestos públicamente
|
- [ ] Puertos 5432 (TimescaleDB) y 6379 (Redis) NO expuestos públicamente
|
||||||
- [ ] `VITE_API_BASE` apunta al subdominio correcto de la API
|
- [ ] `VITE_API_BASE` apunta al subdominio correcto de la API
|
||||||
|
- [ ] `VITE_ADMIN_APP_ORIGIN=https://app.omnioil.app` y `VITE_MOBILE_APP_ORIGIN=https://mobile.omnioil.app` configurados antes de rebuildar frontends
|
||||||
- [ ] `VITE_CONSOLE_APP_ORIGIN` apunta a `https://console.omnioil.app`
|
- [ ] `VITE_CONSOLE_APP_ORIGIN` apunta a `https://console.omnioil.app`
|
||||||
- [ ] `ALLOWED_ORIGINS` incluye `https://app.omnioil.app`, `https://mobile.omnioil.app` y `https://console.omnioil.app`
|
- [ ] `ALLOWED_ORIGINS` incluye `https://app.omnioil.app`, `https://mobile.omnioil.app` y `https://console.omnioil.app`
|
||||||
- [ ] `VITE_TURNSTILE_SITE_KEY` configurado en el frontend admin para el formulario público de registro
|
- [ ] `VITE_TURNSTILE_SITE_KEY` configurado en el frontend admin para el formulario público de registro
|
||||||
- [ ] `INVITATION_BASE_URL=https://app.omnioil.app` configurado para links de activación de solicitudes aprobadas
|
- [ ] `INVITATION_BASE_URL=https://app.omnioil.app` configurado para links de activación de solicitudes aprobadas
|
||||||
- [ ] `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` y `SMTP_FROM` configurados para alertas ANH y correos de aprobación/activación
|
- [ ] `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER=soporte@omnioil.app`, `SMTP_PASS` y `SMTP_FROM=OmniOil <soporte@omnioil.app>` configurados para alertas ANH y correos de acceso
|
||||||
|
- [ ] Console recibe `VITE_ACCESS_REQUEST_REVIEWER_EMAILS=w.farfan@omnioil.app,h.ortegon@omnioil.app` como ayuda visual; la autorización real se valida en backend
|
||||||
- [ ] Operadores informados: un fallo SMTP no revierte la aprobación; corregir SMTP y usar `Reenviar invitación` desde Console
|
- [ ] Operadores informados: un fallo SMTP no revierte la aprobación; corregir SMTP y usar `Reenviar invitación` desde Console
|
||||||
- [ ] `TELEGRAM_*` configurado para alertas ANH cuando aplique
|
- [ ] `TELEGRAM_*` configurado para alertas ANH cuando aplique
|
||||||
- [ ] Backup inicial de la base de datos configurado
|
- [ ] Backup inicial de la base de datos configurado
|
||||||
|
|||||||
200
docs/DOCKER_CACHE_OPTIMIZATION.md
Normal file
200
docs/DOCKER_CACHE_OPTIMIZATION.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# Docker Build Cache Optimization Guide
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. **Dockerfile.unified** (Rust Services)
|
||||||
|
|
||||||
|
#### Problem Solved
|
||||||
|
- Previously, `planner` and `cacher` stages only generated/compiled dependencies for `backend-api`
|
||||||
|
- This caused other services to recompile shared dependencies independently
|
||||||
|
- Each service rebuild invalidated previous builds' caches
|
||||||
|
|
||||||
|
#### Improvements
|
||||||
|
- **Planner stage**: Now copies only `Cargo.toml` files and creates stub source files
|
||||||
|
- Generates recipe for **ALL workspace dependencies**, not just backend-api
|
||||||
|
- Maximizes cache reuse across all services
|
||||||
|
|
||||||
|
- **Cacher stage**: Now compiles **all dependencies** for the entire workspace
|
||||||
|
- Single cache layer for all Rust services
|
||||||
|
- Dependencies are compiled once and reused by all builders
|
||||||
|
|
||||||
|
- **Builder stages**: Each builder (linux/windows) now:
|
||||||
|
- Copies from cacher (pre-compiled deps)
|
||||||
|
- Uses `--mount=type=cache` for incremental builds
|
||||||
|
- Only rebuilds when source code changes
|
||||||
|
|
||||||
|
#### Expected Improvement
|
||||||
|
- **First build**: ~1-2 hours (initializes dependencies)
|
||||||
|
- **Code changes only**: 30-60 seconds (skips dependency compilation)
|
||||||
|
- **Dependency changes**: ~5-10 minutes (only affected services rebuild)
|
||||||
|
|
||||||
|
### 2. **Frontend Dockerfiles** (Node.js SPAs)
|
||||||
|
|
||||||
|
#### Problem Solved
|
||||||
|
- Previously, copied `package.json` and `pnpm-lock.yaml` together
|
||||||
|
- Any source code change invalidated the entire deps cache
|
||||||
|
- Rebuild would reinstall `node_modules` even if dependencies unchanged
|
||||||
|
|
||||||
|
#### Improvements
|
||||||
|
|
||||||
|
**Standard pattern for all frontends** (admin, console, pwa, landing):
|
||||||
|
```dockerfile
|
||||||
|
# Stage 1: Dependencies (cached separately)
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install (with --mount=type=cache)
|
||||||
|
|
||||||
|
# Stage 2: Build (reuses cached node_modules)
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Stage 3: Runtime (Nginx)
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Expected Improvement
|
||||||
|
- **Dependency changes only**: 2-5 minutes (installs new deps)
|
||||||
|
- **Source code changes**: 10-30 seconds (build only)
|
||||||
|
- **Rebuild with no changes**: <5 seconds (pulls from cache)
|
||||||
|
|
||||||
|
### 3. **docker-compose.yml** (Context Normalization)
|
||||||
|
|
||||||
|
#### Problem Solved
|
||||||
|
- Inconsistent `context` values across services
|
||||||
|
- Some used `context: .` with full dockerfile paths
|
||||||
|
- Others used `context: ./services/...` with relative paths
|
||||||
|
- This made builds unpredictable and paths ambiguous
|
||||||
|
|
||||||
|
#### Improvements
|
||||||
|
- **All services now use consistent context**:
|
||||||
|
- Rust services: `context: .` with `dockerfile: Dockerfile.unified` (workspace build)
|
||||||
|
- Frontend services: `context: ./services/frontend-xxx` with `dockerfile: Dockerfile`
|
||||||
|
- Infrastructure: `context: ./infrastructure/mosquitto` (local builds)
|
||||||
|
|
||||||
|
### 4. **.dockerignore** (Build Context Optimization)
|
||||||
|
|
||||||
|
Already configured to exclude:
|
||||||
|
- Build artifacts (`target/`, `dist/`, `.next/`, `node_modules/`)
|
||||||
|
- Git and IDE files
|
||||||
|
- Documentation (not needed in images)
|
||||||
|
- Test files and CI configs
|
||||||
|
|
||||||
|
## Docker Build Commands
|
||||||
|
|
||||||
|
### Full Stack Build (with caching)
|
||||||
|
```bash
|
||||||
|
# Build all services (leverages existing cache)
|
||||||
|
docker compose -f docker-compose.yml build
|
||||||
|
|
||||||
|
# Build specific service
|
||||||
|
docker compose -f docker-compose.yml build backend-api
|
||||||
|
docker compose -f docker-compose.yml build frontend-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Stack
|
||||||
|
```bash
|
||||||
|
# Start with local cache
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# Rebuild after code changes (fast with cache)
|
||||||
|
docker compose -f docker-compose.yml build --no-cache # Clear all cache
|
||||||
|
docker compose -f docker-compose.yml build # Use cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI/CD Recommendations
|
||||||
|
|
||||||
|
For **GitHub Actions** or **GitLab CI**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Use --cache-from and --cache-to for BuildKit cache export
|
||||||
|
docker buildx build --cache-from=type=gha \
|
||||||
|
--cache-to=type=gha,mode=max \
|
||||||
|
-t myimage:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
For **Local Development**:
|
||||||
|
```bash
|
||||||
|
# Enable BuildKit (faster, better caching)
|
||||||
|
export DOCKER_BUILDKIT=1
|
||||||
|
|
||||||
|
# Build with progress output
|
||||||
|
docker compose build --progress plain
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cache Invalidation Behavior
|
||||||
|
|
||||||
|
| Change Type | Cache Invalidated | Rebuild Time |
|
||||||
|
|-------------|------------------|--------------|
|
||||||
|
| No changes | ❌ No | <5s (pull from cache) |
|
||||||
|
| Source code only | ❌ Deps cache remains | 10-60s |
|
||||||
|
| `package.json` / `pnpm-lock.yaml` only | ❌ Node_modules reused if hash matches | 2-5m |
|
||||||
|
| `Cargo.toml` / `Cargo.lock` only | ❌ Deps recompiled, binaries reused | 5-15m |
|
||||||
|
| All of above changed | ✅ Full rebuild | 1-2h |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. **Use BuildKit for faster builds**
|
||||||
|
```bash
|
||||||
|
export DOCKER_BUILDKIT=1
|
||||||
|
export COMPOSE_DOCKER_CLI_BUILD=1
|
||||||
|
export BUILDKIT_PROGRESS=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Keep Dockerfiles stable**
|
||||||
|
- Don't modify Dockerfile frequently (invalidates base layer cache)
|
||||||
|
- Update `.dockerignore` to exclude unnecessary files
|
||||||
|
|
||||||
|
### 3. **Dependency management**
|
||||||
|
- Update `Cargo.toml` / `pnpm-lock.yaml` separately when possible
|
||||||
|
- Commit lock files to version control
|
||||||
|
- Don't run `cargo update` or `pnpm update` in builds
|
||||||
|
|
||||||
|
### 4. **Multi-stage builds**
|
||||||
|
- Each stage is independently cacheable
|
||||||
|
- Earlier stages (deps) change less frequently
|
||||||
|
- Later stages (source) are rebuilt more often
|
||||||
|
|
||||||
|
### 5. **Monitor cache efficiency**
|
||||||
|
```bash
|
||||||
|
# View cache layers
|
||||||
|
docker buildx du
|
||||||
|
|
||||||
|
# Clear unused cache
|
||||||
|
docker buildx prune
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Cache not working
|
||||||
|
```bash
|
||||||
|
# Check if BuildKit is enabled
|
||||||
|
docker buildx version
|
||||||
|
|
||||||
|
# Force rebuild without cache
|
||||||
|
docker compose build --no-cache
|
||||||
|
|
||||||
|
# Clear all local cache
|
||||||
|
docker buildx prune -a
|
||||||
|
```
|
||||||
|
|
||||||
|
### Slow builds after "fix"
|
||||||
|
- Verify `.dockerignore` isn't excluding needed files
|
||||||
|
- Check if `Cargo.lock` and `pnpm-lock.yaml` are stable
|
||||||
|
- Ensure CI systems use persistent cache volumes
|
||||||
|
|
||||||
|
### Container size optimization
|
||||||
|
```bash
|
||||||
|
# Check layer sizes
|
||||||
|
docker history myimage:tag
|
||||||
|
|
||||||
|
# Use slim base images (alpine, distroless)
|
||||||
|
# Already configured in this project
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
- [Docker BuildKit Documentation](https://docs.docker.com/develop/build/build_context/)
|
||||||
|
- [Dockerfile Best Practices](https://docs.docker.com/develop/dev-best-practices/)
|
||||||
|
- [cargo-chef for Rust](https://github.com/LukeMathWalker/cargo-chef)
|
||||||
@@ -523,9 +523,9 @@ DOCKER_REGISTRY_URL=docker-registry:5000
|
|||||||
# --- NOTIFICACIONES ---
|
# --- NOTIFICACIONES ---
|
||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=465
|
SMTP_PORT=465
|
||||||
SMTP_USER=alertas@omnioil.app
|
SMTP_USER=soporte@omnioil.app
|
||||||
SMTP_PASS=clave_de_aplicacion_smtp
|
SMTP_PASS=clave_de_aplicacion_smtp
|
||||||
SMTP_FROM=alertas@omnioil.app
|
SMTP_FROM=OmniOil <soporte@omnioil.app>
|
||||||
TELEGRAM_BOT_TOKEN=bot_token_telegram
|
TELEGRAM_BOT_TOKEN=bot_token_telegram
|
||||||
TELEGRAM_CHAT_ID=-100123456789
|
TELEGRAM_CHAT_ID=-100123456789
|
||||||
|
|
||||||
@@ -588,10 +588,9 @@ El backend expone métricas en `GET /api/metrics` en formato Prometheus:
|
|||||||
| Ruta | Descripción |
|
| Ruta | Descripción |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `/login` | Login de operador |
|
| `/login` | Login de operador |
|
||||||
| `/projects` | Lista de proyectos asignados |
|
| `/app/field` | Inicio de la aplicación de campo |
|
||||||
| `/projects/:id/assets` | Activos del proyecto |
|
| `/app/field/measurements` | Registrar medición manual |
|
||||||
| `/assets/:id/readings` | Registrar medición manual |
|
| `/app/field/movements` | Registrar movimiento de producción |
|
||||||
| `/assets/:id/movements` | Registrar movimiento de producción |
|
|
||||||
| `/offline` | Estado offline / sincronización pendiente |
|
| `/offline` | Estado offline / sincronización pendiente |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
132
docs/SIMULADORES.md
Normal file
132
docs/SIMULADORES.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# 📊 Simulador Multiprotocolo Unificado (Modbus TCP & Siemens S7)
|
||||||
|
|
||||||
|
Este documento detalla el funcionamiento, configuración y uso del **Simulador Multiprotocolo Unificado** (`tests/simulators/unified_simulator.py`) desarrollado para facilitar las pruebas locales y el desarrollo dentro del ecosistema **OmniOil**.
|
||||||
|
|
||||||
|
El simulador unifica los flujos de simulación industrial en un único script concurrente, evitando tener que levantar múltiples procesos por separado. Además, incorpora un **servidor HTTP con dashboard web interactivo** para visualizar las señales de telemetría en tiempo real.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 Puertos de Exposición y Servicios
|
||||||
|
|
||||||
|
El simulador expone tres servicios en puertos distintos sobre la interfaz de red local (`localhost` / `0.0.0.0`):
|
||||||
|
|
||||||
|
| Servicio | Protocolo | Puerto | Descripción |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Modbus TCP Server** | Modbus TCP | **`5020`** | Servidor de registros Modbus (Holding/Input Registers) para adquisición de datos. |
|
||||||
|
| **Siemens S7 Server** | S7 (RFC1006) | **`1102`** | Servidor PLC simulado con área de datos DB1 activa para lectura de bloques. |
|
||||||
|
| **Dashboard & API** | HTTP (Web) | **`8085`** | Panel visual interactivo y API JSON para desarrollo rápido. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Requisitos e Instalación
|
||||||
|
|
||||||
|
El simulador requiere **Python 3.8+** y un conjunto de dependencias de comunicación industrial.
|
||||||
|
|
||||||
|
### 1. Entorno Virtual de Python (Recomendado)
|
||||||
|
Desde la raíz del proyecto, activa tu entorno virtual:
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # En Linux/WSL/macOS
|
||||||
|
# o: venv\Scripts\activate en Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Dependencias de Python
|
||||||
|
Instala las dependencias necesarias:
|
||||||
|
```bash
|
||||||
|
pip install pymodbus python-snap7
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Requisito del Sistema para Siemens S7 (`snap7`)
|
||||||
|
La librería `python-snap7` es un wrapper de la librería nativa en C **Snap7**. Por lo tanto, el sistema operativo necesita el binario de Snap7 para poder instanciar el servidor S7:
|
||||||
|
|
||||||
|
#### En Linux (Ubuntu / WSL / Debian):
|
||||||
|
```bash
|
||||||
|
sudo add-apt-repository ppa:gijzelaar/snap7
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install libsnap7-dev libsnap7-1
|
||||||
|
```
|
||||||
|
*Nota: Si estás ejecutando en Linux, el puerto S7 estándar es el 102. Como ese puerto es menor que 1024, requiere privilegios de superusuario (`sudo`). Para facilitar el desarrollo y evitar ejecutar como root, este simulador expone S7 en el puerto alternativo **`1102`**.*
|
||||||
|
|
||||||
|
#### En Windows o macOS:
|
||||||
|
Descarga el binario compilado de `snap7.dll` o `libsnap7.dylib` desde el [repositorio oficial de SourceForge de Snap7](https://sourceforge.net/projects/snap7/) y colócalo en el directorio del sistema (por ejemplo, `C:\Windows\System32`) o agrégalo a tus variables de entorno (`PATH` o `LD_LIBRARY_PATH`).
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **Resiliencia:** Si no tienes configurado `snap7` en tu sistema, el script unificado capturará el error de forma segura, informará de la falta del binario por consola, y **levantará exitosamente** los servidores Modbus TCP (5020) y HTTP Dashboard (8085) sin colapsar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Ejecución del Simulador
|
||||||
|
|
||||||
|
Para iniciar todos los simuladores concurrentemente, ejecuta el script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tests/simulators/unified_simulator.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comportamiento al iniciar:
|
||||||
|
1. Imprime el estado de carga de cada protocolo por consola.
|
||||||
|
2. Inicia el servidor HTTP en el puerto `8085`.
|
||||||
|
3. Cada **5 segundos** genera nuevos valores aleatorios realistas y actualiza la memoria interna de los servidores Modbus y S7.
|
||||||
|
4. Imprime los valores simulados en la terminal con un formato limpio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Mapeo y Estructura de Datos Simulados
|
||||||
|
|
||||||
|
Los valores cambian dinámicamente cada 5 segundos simulando condiciones industriales en tiempo real:
|
||||||
|
|
||||||
|
### 1. Modbus TCP (Puerto `5020`, ID de Esclavo por defecto: `0x00`)
|
||||||
|
|
||||||
|
* **Holding Registers (FC 3):**
|
||||||
|
* `HR1` [Registro `40001`]: Temperatura simulada. Rango: `500` a `9500`.
|
||||||
|
* `HR2` [Registro `40002`]: Presión simulada. Rango: `500` a `9500`.
|
||||||
|
* `HR3` [Registro `40003`]: Flujo simulado. Rango: `500` a `9500`.
|
||||||
|
* `HR4` [Registro `40004`]: Nivel simulado. Rango: `500` a `9500`.
|
||||||
|
* `HR5` [Registro `40005`]: Estado simulado. Rango: `500` a `9500`.
|
||||||
|
* **Input Registers (FC 4):**
|
||||||
|
* `IR1` [Registro `30001`]: Voltaje simulado. Rango: `1000` a `15000`.
|
||||||
|
* `IR2` [Registro `30002`]: Corriente simulada. Rango: `1000` a `15000`.
|
||||||
|
* `IR3` [Registro `30003`]: Frecuencia simulada. Rango: `1000` a `15000`.
|
||||||
|
|
||||||
|
### 2. Siemens S7 (Puerto `1102`, Rack 0, Slot 1)
|
||||||
|
|
||||||
|
* **Data Block 1 (DB1):**
|
||||||
|
* `DB1.DBW0` (Offset 0): Integer de 16 bits (Big Endian). Rango: `100` a `30000`.
|
||||||
|
* `DB1.DBW2` (Offset 2): Integer de 16 bits (Big Endian). Rango: `50` a `20000`.
|
||||||
|
|
||||||
|
### 3. API JSON HTTP (Puerto `8085`)
|
||||||
|
|
||||||
|
Puedes realizar una petición HTTP tipo `GET` a `http://localhost:8085/api/data` para recuperar el estado instantáneo de la simulación en formato JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-23 09:30:15",
|
||||||
|
"modbus": {
|
||||||
|
"running": true,
|
||||||
|
"holding_registers": [4832, 1289, 7891, 5421, 3312],
|
||||||
|
"input_registers": [12015, 8431, 14201]
|
||||||
|
},
|
||||||
|
"s7": {
|
||||||
|
"running": true,
|
||||||
|
"db1_dbw0": 18451,
|
||||||
|
"db1_dbw2": 11342
|
||||||
|
},
|
||||||
|
"http": {
|
||||||
|
"running": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Visualización en Navegador (Dashboard Web)
|
||||||
|
|
||||||
|
Abre tu navegador web favorito y dirígete a:
|
||||||
|
|
||||||
|
👉 **`http://localhost:8085`** o **`http://localhost:8085/dashboard`**
|
||||||
|
|
||||||
|
### Características del Dashboard:
|
||||||
|
- **Diseño Premium:** Estética oscura moderna con efectos de traslucidez (glassmorphism) y paleta de colores curada para el sector energético.
|
||||||
|
- **Gráficas en Vivo:** Tendencias dinámicas e interactivas representadas mediante *Chart.js* con actualizaciones continuas cada 5 segundos.
|
||||||
|
- **Micro-animaciones:** Los valores parpadean con un sutil destello de color cian/púrpura en el momento exacto en el que cambia la telemetría, y los indicadores de estado pulsan en verde cuando los servidores están activos.
|
||||||
|
- **Selectores de Señales:** Permite alternar la gráfica en tiempo real entre los Holding Registers, Input Registers o las variables S7 con un solo clic.
|
||||||
0
infrastructure/mosquitto/entrypoint.sh
Normal file → Executable file
0
infrastructure/mosquitto/entrypoint.sh
Normal file → Executable file
0
infrastructure/nginx/entrypoint.sh
Normal file → Executable file
0
infrastructure/nginx/entrypoint.sh
Normal file → Executable file
@@ -4,6 +4,14 @@ map $sent_http_content_type $csp_header {
|
|||||||
default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss:; frame-ancestors 'none';";
|
default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss:; frame-ancestors 'none';";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Access logs must not include query strings: URLs can carry short-lived tickets,
|
||||||
|
# invitation tokens, or other sensitive one-time values. Use $uri instead of
|
||||||
|
# $request to avoid persisting secrets in Nginx/Docker logs.
|
||||||
|
log_format main_no_query '$remote_addr - $remote_user [$time_local] '
|
||||||
|
'"$request_method $uri $server_protocol" $status $body_bytes_sent '
|
||||||
|
'"$http_referer" "$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
access_log /var/log/nginx/access.log main_no_query;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name omnioil.app www.omnioil.app localhost;
|
server_name omnioil.app www.omnioil.app localhost;
|
||||||
|
|||||||
@@ -128,9 +128,9 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO telemetry_raw (time, project_id, data)
|
`INSERT INTO telemetry_raw (time, project_id, asset_id, data)
|
||||||
SELECT $1::timestamptz, id, $2::jsonb FROM edge_projects LIMIT 1`,
|
VALUES ($1::timestamptz, $2::uuid, $3::uuid, $4::jsonb)`,
|
||||||
[time.toISOString(), JSON.stringify(data)]
|
[time.toISOString(), projectId, assetId, JSON.stringify(data)]
|
||||||
)
|
)
|
||||||
insertCount++
|
insertCount++
|
||||||
}
|
}
|
||||||
|
|||||||
10537
pnpm-lock.yaml
generated
Normal file
10537
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
pnpm-workspace.yaml
Normal file
5
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
packages:
|
||||||
|
- "services/landing"
|
||||||
|
- "services/frontend-admin"
|
||||||
|
- "services/frontend-console"
|
||||||
|
- "services/frontend-pwa"
|
||||||
@@ -5,6 +5,7 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
shared-lib = { workspace = true }
|
shared-lib = { workspace = true }
|
||||||
|
json-generator = { path = "../json-generator" }
|
||||||
axum = { workspace = true, features = ["macros", "ws"] }
|
axum = { workspace = true, features = ["macros", "ws"] }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
@@ -18,6 +19,7 @@ dotenvy = { workspace = true }
|
|||||||
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] }
|
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] }
|
||||||
argon2 = { version = "0.5.3", features = ["password-hash", "rand"] }
|
argon2 = { version = "0.5.3", features = ["password-hash", "rand"] }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
chrono-tz = { workspace = true }
|
||||||
axum-extra = { version = "0.12.5", features = ["typed-header"] }
|
axum-extra = { version = "0.12.5", features = ["typed-header"] }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
@@ -41,3 +43,4 @@ reqwest = { workspace = true, features = ["json", "rustls"] }
|
|||||||
semver = { workspace = true }
|
semver = { workspace = true }
|
||||||
axum-prometheus = "0.8"
|
axum-prometheus = "0.8"
|
||||||
metrics = "0.24"
|
metrics = "0.24"
|
||||||
|
csv = "1.4.0"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ WORKDIR /app
|
|||||||
# Instala dependencias del sistema necesarias para compilar crates de C y linkeo rápido
|
# Instala dependencias del sistema necesarias para compilar crates de C y linkeo rápido
|
||||||
# Nota: libatk1.0-dev y libgtk-3-dev se añaden para satisfacer dependencias transitivas de GUI si el workspace las tiene
|
# Nota: libatk1.0-dev y libgtk-3-dev se añaden para satisfacer dependencias transitivas de GUI si el workspace las tiene
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
cmake nasm pkg-config libssl-dev libglib2.0-dev libatk1.0-dev libgtk-3-dev build-essential ca-certificates lld \
|
pkg-config libssl-dev build-essential ca-certificates lld \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
# Linker LLD para reducir el tiempo de linkeado significativamente
|
# Linker LLD para reducir el tiempo de linkeado significativamente
|
||||||
ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld"
|
ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld"
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Enforce one active access request per email, case-insensitively.
|
||||||
|
-- Rejected requests remain historical and allow a future retry.
|
||||||
|
|
||||||
|
-- Production may already contain duplicate active requests created before this
|
||||||
|
-- invariant existed. Keep the most advanced/latest request and close the rest
|
||||||
|
-- before creating the partial unique index, otherwise the deploy migration fails.
|
||||||
|
WITH ranked_active_requests AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY lower(email)
|
||||||
|
ORDER BY
|
||||||
|
CASE status
|
||||||
|
WHEN 'approved' THEN 1
|
||||||
|
WHEN 'in_review' THEN 2
|
||||||
|
WHEN 'pending' THEN 3
|
||||||
|
ELSE 4
|
||||||
|
END,
|
||||||
|
created_at DESC,
|
||||||
|
id DESC
|
||||||
|
) AS duplicate_rank
|
||||||
|
FROM access_requests
|
||||||
|
WHERE status IN ('pending', 'in_review', 'approved')
|
||||||
|
)
|
||||||
|
UPDATE access_requests ar
|
||||||
|
SET status = 'rejected',
|
||||||
|
rejection_reason = 'duplicate_active_request_before_unique_index',
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM ranked_active_requests ranked
|
||||||
|
WHERE ar.id = ranked.id
|
||||||
|
AND ranked.duplicate_rank > 1;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_access_requests_active_email_unique
|
||||||
|
ON access_requests (lower(email))
|
||||||
|
WHERE status IN ('pending', 'in_review', 'approved');
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Alter edge_devices check constraint to include new SCADA protocols
|
||||||
|
ALTER TABLE edge_devices DROP CONSTRAINT IF EXISTS edge_devices_protocol_check;
|
||||||
|
ALTER TABLE edge_devices ADD CONSTRAINT edge_devices_protocol_check
|
||||||
|
CHECK (protocol IN ('MODBUS_TCP', 'MODBUS_RTU', 'OPC_UA', 'MQTT', 'ETHERNET_IP', 'PROFINET', 'S7', 'MQTT_SPARKPLUG_B', 'SQL_DB'));
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Migration: Add agent_download_tokens table for one-time script downloads
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_download_tokens (
|
||||||
|
token VARCHAR(100) PRIMARY KEY,
|
||||||
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_download_tokens_project ON agent_download_tokens(project_id);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE anh_reports
|
||||||
|
ADD COLUMN IF NOT EXISTS object_key TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS sealed_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS content_type TEXT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_anh_reports_object_key_unique
|
||||||
|
ON anh_reports (object_key)
|
||||||
|
WHERE object_key IS NOT NULL;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS anh_report_schedules (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
operator_name TEXT NOT NULL,
|
||||||
|
contract_number TEXT NOT NULL,
|
||||||
|
local_time TIME NOT NULL DEFAULT '06:00',
|
||||||
|
timezone TEXT NOT NULL DEFAULT 'America/Bogota',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
next_run_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_run_at TIMESTAMPTZ,
|
||||||
|
last_status TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
claim_token UUID,
|
||||||
|
claimed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT anh_report_schedules_unit_key UNIQUE (operator_name, contract_number),
|
||||||
|
CONSTRAINT anh_report_schedules_time_minute CHECK (date_trunc('minute', local_time) = local_time)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_anh_report_schedules_due
|
||||||
|
ON anh_report_schedules (next_run_at)
|
||||||
|
WHERE is_active = true;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_anh_report_schedules_claimed_at
|
||||||
|
ON anh_report_schedules (claimed_at)
|
||||||
|
WHERE claimed_at IS NOT NULL;
|
||||||
|
|
||||||
|
INSERT INTO anh_report_schedules (operator_name, contract_number, local_time, timezone, is_active, next_run_at)
|
||||||
|
SELECT DISTINCT
|
||||||
|
operator_name,
|
||||||
|
contract_number,
|
||||||
|
TIME '06:00',
|
||||||
|
'America/Bogota',
|
||||||
|
true,
|
||||||
|
CASE
|
||||||
|
WHEN (((NOW() AT TIME ZONE 'America/Bogota')::date + TIME '06:00') AT TIME ZONE 'America/Bogota') > NOW()
|
||||||
|
THEN (((NOW() AT TIME ZONE 'America/Bogota')::date + TIME '06:00') AT TIME ZONE 'America/Bogota')
|
||||||
|
ELSE ((((NOW() AT TIME ZONE 'America/Bogota')::date + 1) + TIME '06:00') AT TIME ZONE 'America/Bogota')
|
||||||
|
END
|
||||||
|
FROM edge_projects
|
||||||
|
WHERE is_active = true
|
||||||
|
AND operator_name IS NOT NULL
|
||||||
|
AND contract_number IS NOT NULL
|
||||||
|
ON CONFLICT (operator_name, contract_number) DO NOTHING;
|
||||||
|
|
||||||
|
ALTER TABLE anh_reports
|
||||||
|
ADD COLUMN IF NOT EXISTS schedule_id UUID REFERENCES anh_report_schedules(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS operator_name TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS contract_number TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS generation_source TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS approved_by UUID REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_anh_reports_generation_scope_unique
|
||||||
|
ON anh_reports (operator_name, contract_number, period_start, period_end)
|
||||||
|
WHERE operator_name IS NOT NULL
|
||||||
|
AND contract_number IS NOT NULL
|
||||||
|
AND period_start IS NOT NULL
|
||||||
|
AND period_end IS NOT NULL;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Remove trial/placeholder ANH artifacts. These were never confirmed contracts
|
||||||
|
-- and must not appear as regulatory reports.
|
||||||
|
|
||||||
|
DELETE FROM anh_reports
|
||||||
|
WHERE upper(btrim(COALESCE(contract_number, content ->> 'CONTRATO', ''))) LIKE 'PENDING-%'
|
||||||
|
OR upper(btrim(COALESCE(contract_number, content ->> 'CONTRATO', ''))) LIKE 'TRIAL-%';
|
||||||
|
|
||||||
|
DELETE FROM anh_report_schedules
|
||||||
|
WHERE upper(btrim(contract_number)) LIKE 'PENDING-%'
|
||||||
|
OR upper(btrim(contract_number)) LIKE 'TRIAL-%';
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
ALTER TABLE anh_reports
|
||||||
|
ADD COLUMN IF NOT EXISTS root_report_id UUID REFERENCES anh_reports(id) ON DELETE RESTRICT,
|
||||||
|
ADD COLUMN IF NOT EXISTS previous_report_id UUID REFERENCES anh_reports(id) ON DELETE RESTRICT,
|
||||||
|
ADD COLUMN IF NOT EXISTS superseded_by_report_id UUID REFERENCES anh_reports(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS correction_version INT NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS rejection_reason TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS rejected_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS correction_reason TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS correction_deadline_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS correction_created_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS correction_created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS available_for_anh_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS available_for_anh_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS availability_note TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS superseded_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
UPDATE anh_reports
|
||||||
|
SET root_report_id = id
|
||||||
|
WHERE root_report_id IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE anh_reports
|
||||||
|
ADD CONSTRAINT anh_reports_correction_version_non_negative
|
||||||
|
CHECK (correction_version >= 0),
|
||||||
|
ADD CONSTRAINT anh_reports_correction_reason_required
|
||||||
|
CHECK (correction_version = 0 OR NULLIF(BTRIM(correction_reason), '') IS NOT NULL),
|
||||||
|
ADD CONSTRAINT anh_reports_rejection_reason_required
|
||||||
|
CHECK ((rejected_at IS NULL AND rejection_reason IS NULL) OR (rejected_at IS NOT NULL AND NULLIF(BTRIM(rejection_reason), '') IS NOT NULL));
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_anh_reports_generation_scope_unique;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_anh_reports_generation_scope_version_unique
|
||||||
|
ON anh_reports (operator_name, contract_number, period_start, period_end, correction_version)
|
||||||
|
WHERE operator_name IS NOT NULL
|
||||||
|
AND contract_number IS NOT NULL
|
||||||
|
AND period_start IS NOT NULL
|
||||||
|
AND period_end IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_anh_reports_active_draft_unique
|
||||||
|
ON anh_reports (operator_name, contract_number, period_start, period_end)
|
||||||
|
WHERE status IN ('GENERATED', 'CORRECTION_DRAFT')
|
||||||
|
AND operator_name IS NOT NULL
|
||||||
|
AND contract_number IS NOT NULL
|
||||||
|
AND period_start IS NOT NULL
|
||||||
|
AND period_end IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_anh_reports_root_version
|
||||||
|
ON anh_reports (root_report_id, correction_version);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
ALTER TABLE telemetry_alarms
|
||||||
|
ADD COLUMN IF NOT EXISTS acknowledged BOOLEAN,
|
||||||
|
ADD COLUMN IF NOT EXISTS acknowledged_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS acknowledged_by UUID;
|
||||||
|
|
||||||
|
ALTER TABLE telemetry_alarms
|
||||||
|
ALTER COLUMN acknowledged SET DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telemetry_alarms_acknowledged
|
||||||
|
ON telemetry_alarms(project_id, acknowledged, timestamp DESC);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Migration: Short-lived one-time WebSocket telemetry tickets
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS websocket_tickets (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_websocket_tickets_project ON websocket_tickets(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_websocket_tickets_expires_at ON websocket_tickets(expires_at);
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
pub struct BuiltEmail {
|
pub struct BuiltEmail {
|
||||||
pub subject: String,
|
pub subject: String,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
|
pub html_body: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OMNIOIL_LOGO_URL: &str = "https://www.omnioil.app/omnioil-logo-transparente.svg";
|
||||||
|
|
||||||
pub fn activation_link(token: &str) -> String {
|
pub fn activation_link(token: &str) -> String {
|
||||||
let base_url = std::env::var("INVITATION_BASE_URL")
|
let base_url = std::env::var("INVITATION_BASE_URL")
|
||||||
.unwrap_or_else(|_| "https://app.omnioil.app".to_string());
|
.unwrap_or_else(|_| "https://app.omnioil.app".to_string());
|
||||||
@@ -13,18 +16,49 @@ pub fn activation_link(token: &str) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn activation_email(full_name: &str, company: &str, token: &str) -> BuiltEmail {
|
pub fn activation_email(full_name: &str, _company: &str, token: &str) -> BuiltEmail {
|
||||||
let url = activation_link(token);
|
let url = activation_link(token);
|
||||||
BuiltEmail {
|
BuiltEmail {
|
||||||
subject: "Activa tu cuenta OmniOil".to_string(),
|
subject: "Tu acceso de prueba a OmniOil ya está disponible".to_string(),
|
||||||
body: format!(
|
body: format!(
|
||||||
"Hola {full_name},\n\n\
|
"Hola {full_name},\n\n\
|
||||||
Aprobamos la solicitud de acceso para {company}.\n\n\
|
Gracias por tu interés en OmniOil. Tu acceso de prueba por 30 días ya fue aprobado.\n\n\
|
||||||
Activa tu cuenta y crea tu contraseña desde este enlace:\n\
|
Para comenzar, activa tu cuenta y crea una contraseña segura desde el siguiente enlace:\n\
|
||||||
{url}\n\n\
|
{url}\n\n\
|
||||||
El enlace vence en 7 días. Si no reconocés esta solicitud, podés ignorar este correo.\n\n\
|
Una vez activada la cuenta, podrás ingresar a la plataforma desde:\n\
|
||||||
|
https://app.omnioil.app/login\n\n\
|
||||||
|
Por seguridad, este enlace vence en 7 días. Si no solicitaste este acceso, puedes ignorar este mensaje o escribirnos a soporte@omnioil.app.\n\n\
|
||||||
Equipo OmniOil"
|
Equipo OmniOil"
|
||||||
),
|
),
|
||||||
|
html_body: Some(format!(
|
||||||
|
r#"<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<body style="margin:0;background:#080808;padding:32px 16px;font-family:Arial,Helvetica,sans-serif;color:#f8fafc;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;margin:0 auto;background:#0d1117;border:1px solid rgba(255,255,255,0.10);border-radius:24px;overflow:hidden;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:32px 32px 20px;text-align:left;background:linear-gradient(135deg,rgba(249,115,22,0.18),rgba(13,17,23,0));">
|
||||||
|
<img src="{OMNIOIL_LOGO_URL}" alt="OmniOil" width="132" style="display:block;max-width:132px;height:auto;margin-bottom:28px;" />
|
||||||
|
<p style="margin:0 0 10px;color:#fb923c;font-size:12px;font-weight:700;letter-spacing:0.18em;text-transform:uppercase;">Prueba gratuita aprobada</p>
|
||||||
|
<h1 style="margin:0;color:#ffffff;font-size:28px;line-height:1.2;font-weight:800;">Tu acceso a OmniOil ya está disponible</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 32px 32px;color:#cbd5e1;font-size:16px;line-height:1.7;">
|
||||||
|
<p style="margin:0 0 18px;">Hola {full_name},</p>
|
||||||
|
<p style="margin:0 0 18px;">Gracias por tu interés en OmniOil. Tu acceso de prueba por 30 días fue aprobado y ya podés activar tu cuenta.</p>
|
||||||
|
<p style="margin:0 0 28px;">Creá una contraseña segura desde el siguiente enlace:</p>
|
||||||
|
<p style="margin:0 0 28px;">
|
||||||
|
<a href="{url}" style="display:inline-block;background:#f97316;color:#111827;text-decoration:none;font-weight:800;padding:14px 22px;border-radius:14px;">Activar mi cuenta</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 18px;">Después de activar tu cuenta, ingresá a la plataforma desde <a href="https://app.omnioil.app/login" style="color:#fb923c;text-decoration:none;font-weight:700;">app.omnioil.app/login</a>.</p>
|
||||||
|
<p style="margin:0 0 18px;color:#94a3b8;font-size:14px;">Por seguridad, este enlace vence en 7 días. Si no solicitaste este acceso, podés ignorar este mensaje o escribirnos a <a href="mailto:soporte@omnioil.app" style="color:#fb923c;text-decoration:none;">soporte@omnioil.app</a>.</p>
|
||||||
|
<p style="margin:28px 0 0;color:#e5e7eb;font-weight:700;">Equipo OmniOil</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>"#
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +72,33 @@ pub fn access_ready_email(full_name: &str, company: &str) -> BuiltEmail {
|
|||||||
https://app.omnioil.app/login\n\n\
|
https://app.omnioil.app/login\n\n\
|
||||||
Equipo OmniOil"
|
Equipo OmniOil"
|
||||||
),
|
),
|
||||||
|
html_body: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn access_request_acknowledgement_email(full_name: &str) -> BuiltEmail {
|
||||||
|
BuiltEmail {
|
||||||
|
subject: "Recibimos tu solicitud de acceso OmniOil".to_string(),
|
||||||
|
body: format!(
|
||||||
|
"Hola {full_name},\n\n\
|
||||||
|
Recibimos tu solicitud de acceso a OmniOil. Nuestro equipo revisará la información y te responderá con la aceptación o negación en menos de 24 horas.\n\n\
|
||||||
|
Si necesitás agregar información, escribinos a soporte@omnioil.app.\n\n\
|
||||||
|
Equipo OmniOil"
|
||||||
|
),
|
||||||
|
html_body: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn access_request_rejection_email(full_name: &str) -> BuiltEmail {
|
||||||
|
BuiltEmail {
|
||||||
|
subject: "Respuesta a tu solicitud de acceso OmniOil".to_string(),
|
||||||
|
body: format!(
|
||||||
|
"Hola {full_name},\n\n\
|
||||||
|
Revisamos tu solicitud de acceso a OmniOil y no podemos aprobarla en este momento.\n\n\
|
||||||
|
Si necesitás más información o querés actualizar los datos enviados, escribinos a soporte@omnioil.app.\n\n\
|
||||||
|
Equipo OmniOil"
|
||||||
|
),
|
||||||
|
html_body: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,12 +119,16 @@ pub fn platform_operator_activation_email(full_name: &str, token: &str) -> Built
|
|||||||
El enlace vence en 7 días. Si no reconocés esta invitación, podés ignorar este correo.\n\n\
|
El enlace vence en 7 días. Si no reconocés esta invitación, podés ignorar este correo.\n\n\
|
||||||
Equipo OmniOil"
|
Equipo OmniOil"
|
||||||
),
|
),
|
||||||
|
html_body: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{activation_email, activation_link, platform_operator_activation_email};
|
use super::{
|
||||||
|
OMNIOIL_LOGO_URL, access_request_acknowledgement_email, access_request_rejection_email,
|
||||||
|
activation_email, activation_link, platform_operator_activation_email,
|
||||||
|
};
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||||
@@ -100,6 +165,15 @@ mod tests {
|
|||||||
|
|
||||||
let email = activation_email("Ada Lovelace", "Analytical Engines", "prod-token-456");
|
let email = activation_email("Ada Lovelace", "Analytical Engines", "prod-token-456");
|
||||||
|
|
||||||
|
assert!(email.body.contains("acceso de prueba por 30 días"));
|
||||||
|
assert!(email.body.contains("soporte@omnioil.app"));
|
||||||
|
assert!(
|
||||||
|
email
|
||||||
|
.html_body
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|html| html.contains(OMNIOIL_LOGO_URL))
|
||||||
|
);
|
||||||
|
assert!(email.body.contains("https://app.omnioil.app/login"));
|
||||||
assert!(
|
assert!(
|
||||||
email
|
email
|
||||||
.body
|
.body
|
||||||
@@ -125,4 +199,24 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(email.body.contains("http://localhost:3002/login"));
|
assert!(email.body.contains("http://localhost:3002/login"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acknowledgement_email_sets_customer_expectation_under_24_hours() {
|
||||||
|
let email = access_request_acknowledgement_email("Ana Lopez");
|
||||||
|
|
||||||
|
assert_eq!(email.subject, "Recibimos tu solicitud de acceso OmniOil");
|
||||||
|
assert!(email.body.contains("menos de 24 horas"));
|
||||||
|
assert!(email.body.contains("soporte@omnioil.app"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejection_email_is_generic_and_omits_internal_reason() {
|
||||||
|
let email = access_request_rejection_email("Ana Lopez");
|
||||||
|
|
||||||
|
assert_eq!(email.subject, "Respuesta a tu solicitud de acceso OmniOil");
|
||||||
|
assert!(email.body.contains("no podemos aprobarla"));
|
||||||
|
assert!(!email.body.contains("fraude"));
|
||||||
|
assert!(!email.body.contains("riesgo"));
|
||||||
|
assert!(email.body.contains("soporte@omnioil.app"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ use axum::{
|
|||||||
|
|
||||||
pub async fn add_version_header(request: Request<Body>, next: Next) -> Response<Body> {
|
pub async fn add_version_header(request: Request<Body>, next: Next) -> Response<Body> {
|
||||||
let mut response = next.run(request).await;
|
let mut response = next.run(request).await;
|
||||||
response.headers_mut().insert(
|
response
|
||||||
"X-API-Version",
|
.headers_mut()
|
||||||
axum::http::HeaderValue::from_static("1"),
|
.insert("X-API-Version", axum::http::HeaderValue::from_static("1"));
|
||||||
);
|
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
"X-Powered-By",
|
"X-Powered-By",
|
||||||
axum::http::HeaderValue::from_static("OmniOil/1.0"),
|
axum::http::HeaderValue::from_static("OmniOil/1.0"),
|
||||||
|
|||||||
@@ -388,6 +388,20 @@ pub fn role_satisfies_platform_admin_claims(role: &str) -> bool {
|
|||||||
role == "platform_admin"
|
role == "platform_admin"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn require_access_request_reviewer(claims: &Claims) -> Result<(), crate::errors::AppError> {
|
||||||
|
let email = claims.email.trim().to_ascii_lowercase();
|
||||||
|
if matches!(
|
||||||
|
email.as_str(),
|
||||||
|
"w.farfan@omnioil.app" | "h.ortegon@omnioil.app"
|
||||||
|
) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(crate::errors::AppError::Forbidden(
|
||||||
|
"No tienes permisos para gestionar solicitudes de acceso".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
impl<S> FromRequestParts<S> for PlatformAdminClaims
|
impl<S> FromRequestParts<S> for PlatformAdminClaims
|
||||||
where
|
where
|
||||||
sqlx::PgPool: axum::extract::FromRef<S>,
|
sqlx::PgPool: axum::extract::FromRef<S>,
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ pub async fn establish_connection() -> Result<DbPool, sqlx::Error> {
|
|||||||
if retries >= max_retries {
|
if retries >= max_retries {
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
tracing::warn!("⏳ Fallo conexión a DB (intento {}/{}): {}. Reintentando en 5s...", retries, max_retries, e);
|
tracing::warn!(
|
||||||
|
"⏳ Fallo conexión a DB (intento {}/{}): {}. Reintentando en 5s...",
|
||||||
|
retries,
|
||||||
|
max_retries,
|
||||||
|
e
|
||||||
|
);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response, Json},
|
response::{IntoResponse, Json, Response},
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
Database(sqlx::Error),
|
Database(sqlx::Error),
|
||||||
Internal(anyhow::Error),
|
Internal(anyhow::Error),
|
||||||
@@ -21,21 +22,23 @@ impl IntoResponse for AppError {
|
|||||||
let (status, message) = match self {
|
let (status, message) = match self {
|
||||||
AppError::Database(ref e) => {
|
AppError::Database(ref e) => {
|
||||||
error!(correlation_id = %correlation_id, "Database Error: {:?}", e);
|
error!(correlation_id = %correlation_id, "Database Error: {:?}", e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Error interno de base de datos".to_string())
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Error interno de base de datos".to_string(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
AppError::Internal(ref e) => {
|
AppError::Internal(ref e) => {
|
||||||
error!(correlation_id = %correlation_id, "Internal Error: {:?}", e);
|
error!(correlation_id = %correlation_id, "Internal Error: {:?}", e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Error interno del servidor".to_string())
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Error interno del servidor".to_string(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
AppError::NotFound(entity) => {
|
AppError::NotFound(entity) => {
|
||||||
(StatusCode::NOT_FOUND, format!("{} no encontrado", entity))
|
(StatusCode::NOT_FOUND, format!("{} no encontrado", entity))
|
||||||
}
|
}
|
||||||
AppError::Forbidden(msg) => {
|
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg),
|
||||||
(StatusCode::FORBIDDEN, msg)
|
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||||
}
|
|
||||||
AppError::BadRequest(msg) => {
|
|
||||||
(StatusCode::BAD_REQUEST, msg)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ struct CreatedInvitation {
|
|||||||
token: String,
|
token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
|
||||||
|
struct InvitationEmailDeliveryUpdate {
|
||||||
|
status: &'static str,
|
||||||
|
error: Option<String>,
|
||||||
|
sent_at_should_be_set: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
struct ProjectDefaults {
|
struct ProjectDefaults {
|
||||||
name: String,
|
name: String,
|
||||||
client: String,
|
client: String,
|
||||||
@@ -47,13 +54,6 @@ struct ProjectDefaults {
|
|||||||
metadata: serde_json::Value,
|
metadata: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
|
|
||||||
struct InvitationEmailDeliveryUpdate {
|
|
||||||
status: &'static str,
|
|
||||||
error: Option<String>,
|
|
||||||
sent_at_should_be_set: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
|
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
|
||||||
enum ResendDeliveryKind {
|
enum ResendDeliveryKind {
|
||||||
ActivationInvitation,
|
ActivationInvitation,
|
||||||
@@ -108,6 +108,7 @@ pub struct AccessRequestRow {
|
|||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
pub invitation_email_status: Option<String>,
|
pub invitation_email_status: Option<String>,
|
||||||
pub invitation_email_sent_at: Option<DateTime<Utc>>,
|
pub invitation_email_sent_at: Option<DateTime<Utc>>,
|
||||||
|
pub trial_ends_at: Option<DateTime<Utc>>,
|
||||||
pub invitation_expires_at: Option<DateTime<Utc>>,
|
pub invitation_expires_at: Option<DateTime<Utc>>,
|
||||||
pub invitation_consumed_at: Option<DateTime<Utc>>,
|
pub invitation_consumed_at: Option<DateTime<Utc>>,
|
||||||
pub invitation_revoked_at: Option<DateTime<Utc>>,
|
pub invitation_revoked_at: Option<DateTime<Utc>>,
|
||||||
@@ -123,12 +124,20 @@ const ACCESS_REQUEST_SELECT: &str = r#"
|
|||||||
ar.provisioned_at, ar.request_ip, ar.user_agent, ar.created_at, ar.updated_at,
|
ar.provisioned_at, ar.request_ip, ar.user_agent, ar.created_at, ar.updated_at,
|
||||||
inv.email_status AS invitation_email_status,
|
inv.email_status AS invitation_email_status,
|
||||||
inv.email_sent_at AS invitation_email_sent_at,
|
inv.email_sent_at AS invitation_email_sent_at,
|
||||||
|
sub.trial_ends_at AS trial_ends_at,
|
||||||
inv.expires_at AS invitation_expires_at,
|
inv.expires_at AS invitation_expires_at,
|
||||||
inv.consumed_at AS invitation_consumed_at,
|
inv.consumed_at AS invitation_consumed_at,
|
||||||
inv.revoked_at AS invitation_revoked_at
|
inv.revoked_at AS invitation_revoked_at
|
||||||
FROM access_requests ar
|
FROM access_requests ar
|
||||||
LEFT JOIN users u ON u.id = ar.provisioned_user_id
|
LEFT JOIN users u ON u.id = ar.provisioned_user_id
|
||||||
LEFT JOIN edge_projects p ON p.id = ar.provisioned_project_id
|
LEFT JOIN edge_projects p ON p.id = ar.provisioned_project_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT trial_ends_at
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE project_id = ar.provisioned_project_id
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) sub ON true
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT email_status, email_sent_at, expires_at, consumed_at, revoked_at
|
SELECT email_status, email_sent_at, expires_at, consumed_at, revoked_at
|
||||||
FROM access_request_invitations
|
FROM access_request_invitations
|
||||||
@@ -313,8 +322,10 @@ async fn mark_invitation_email_failed(pool: &PgPool, invitation_id: Uuid, error:
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn invitation_email_delivery_update(result: Result<(), String>) -> InvitationEmailDeliveryUpdate {
|
fn invitation_email_delivery_update(
|
||||||
match result {
|
delivery_result: Result<(), String>,
|
||||||
|
) -> InvitationEmailDeliveryUpdate {
|
||||||
|
match delivery_result {
|
||||||
Ok(()) => InvitationEmailDeliveryUpdate {
|
Ok(()) => InvitationEmailDeliveryUpdate {
|
||||||
status: "sent",
|
status: "sent",
|
||||||
error: None,
|
error: None,
|
||||||
@@ -388,6 +399,8 @@ pub async fn approve_access_request(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<AccessRequestRow>, AppError> {
|
) -> Result<Json<AccessRequestRow>, AppError> {
|
||||||
|
crate::auth::require_access_request_reviewer(&platform.0)?;
|
||||||
|
|
||||||
let platform_user_id = Uuid::parse_str(&platform.0.sub)
|
let platform_user_id = Uuid::parse_str(&platform.0.sub)
|
||||||
.map_err(|_| AppError::BadRequest("ID de operador inválido".to_string()))?;
|
.map_err(|_| AppError::BadRequest("ID de operador inválido".to_string()))?;
|
||||||
|
|
||||||
@@ -496,7 +509,7 @@ pub async fn approve_access_request(
|
|||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"INSERT INTO subscriptions (project_id, status, trial_ends_at, notes)
|
r#"INSERT INTO subscriptions (project_id, status, trial_ends_at, notes)
|
||||||
VALUES ($1, 'trial', NOW() + INTERVAL '7 days', $2)
|
VALUES ($1, 'trial', NOW() + INTERVAL '30 days', $2)
|
||||||
ON CONFLICT (project_id) DO NOTHING"#,
|
ON CONFLICT (project_id) DO NOTHING"#,
|
||||||
)
|
)
|
||||||
.bind(project_id)
|
.bind(project_id)
|
||||||
@@ -562,6 +575,7 @@ pub async fn approve_access_request(
|
|||||||
to: &request.email,
|
to: &request.email,
|
||||||
subject: &email.subject,
|
subject: &email.subject,
|
||||||
body: &email.body,
|
body: &email.body,
|
||||||
|
html_body: email.html_body.as_deref(),
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -663,12 +677,286 @@ pub async fn approve_access_request(
|
|||||||
Ok(Json(row))
|
Ok(Json(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn auto_approve_access_request(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
headers: &axum::http::HeaderMap,
|
||||||
|
) -> Result<AccessRequestRow, AppError> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
let request = sqlx::query_as::<_, ApprovalRequestRow>(
|
||||||
|
r#"SELECT id, full_name, email, company, nit, position, phone, message, status
|
||||||
|
FROM access_requests
|
||||||
|
WHERE id = $1
|
||||||
|
FOR UPDATE"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Solicitud de acceso".to_string()))?;
|
||||||
|
|
||||||
|
if request.status != "pending" {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"La solicitud no puede aprobarse automáticamente".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let base_project_name = project_name_base(&request.company);
|
||||||
|
let project_name_exists: bool =
|
||||||
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM edge_projects WHERE name = $1)")
|
||||||
|
.bind(&base_project_name)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let project_name = if project_name_exists {
|
||||||
|
format!("{}-{}", base_project_name, request_suffix(request.id))
|
||||||
|
} else {
|
||||||
|
base_project_name
|
||||||
|
};
|
||||||
|
|
||||||
|
let project_defaults = project_defaults(&request, &project_name);
|
||||||
|
|
||||||
|
let project_id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"INSERT INTO edge_projects
|
||||||
|
(name, client, operator_name, contract_number, description, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id"#,
|
||||||
|
)
|
||||||
|
.bind(&project_defaults.name)
|
||||||
|
.bind(&project_defaults.client)
|
||||||
|
.bind(&project_defaults.operator_name)
|
||||||
|
.bind(&project_defaults.contract_number)
|
||||||
|
.bind(&project_defaults.description)
|
||||||
|
.bind(project_defaults.metadata)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let existing_user = sqlx::query_as::<_, ExistingUserRow>(
|
||||||
|
r#"SELECT u.id, r.name AS role_name, u.is_active
|
||||||
|
FROM users u
|
||||||
|
JOIN roles r ON r.id = u.role_id
|
||||||
|
WHERE u.email = $1"#,
|
||||||
|
)
|
||||||
|
.bind(request.email.trim().to_lowercase())
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (user_id, user_is_active, user_was_created) = if let Some(user) = existing_user {
|
||||||
|
if matches!(
|
||||||
|
user.role_name.as_str(),
|
||||||
|
"platform_admin" | "platform_operator" | "superadmin"
|
||||||
|
) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"El email pertenece a un usuario proveedor y no puede provisionarse como cliente"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
(user.id, user.is_active, false)
|
||||||
|
} else {
|
||||||
|
let admin_role_id: i32 = sqlx::query_scalar("SELECT id FROM roles WHERE name = 'admin'")
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("Rol admin no encontrado")))?;
|
||||||
|
let unusable_password = crate::invitation_tokens::generate_activation_token();
|
||||||
|
let password_hash = crate::passwords::hash_password(&unusable_password)?;
|
||||||
|
let user_id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"INSERT INTO users (email, full_name, password_hash, role_id, is_active)
|
||||||
|
VALUES ($1, $2, $3, $4, false)
|
||||||
|
RETURNING id"#,
|
||||||
|
)
|
||||||
|
.bind(request.email.trim().to_lowercase())
|
||||||
|
.bind(&request.full_name)
|
||||||
|
.bind(password_hash)
|
||||||
|
.bind(admin_role_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
(user_id, false, true)
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"INSERT INTO user_project_access (user_id, project_id, project_role, is_active)
|
||||||
|
VALUES ($1, $2, 'owner', true)
|
||||||
|
ON CONFLICT (user_id, project_id)
|
||||||
|
DO UPDATE SET project_role = 'owner', is_active = true, updated_at = NOW()"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(project_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"INSERT INTO subscriptions (project_id, status, trial_ends_at, notes)
|
||||||
|
VALUES ($1, 'trial', NOW() + INTERVAL '30 days', $2)
|
||||||
|
ON CONFLICT (project_id) DO NOTHING"#,
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(format!(
|
||||||
|
"Trial automático de 30 días creado desde solicitud de acceso {}",
|
||||||
|
request.id
|
||||||
|
))
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let invitation = if user_is_active {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let token = crate::invitation_tokens::generate_activation_token();
|
||||||
|
let token_hash = crate::invitation_tokens::hash_activation_token(&token);
|
||||||
|
let invitation_id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"INSERT INTO access_request_invitations
|
||||||
|
(access_request_id, user_id, project_id, token_hash, expires_at, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW() + INTERVAL '7 days', NULL)
|
||||||
|
RETURNING id"#,
|
||||||
|
)
|
||||||
|
.bind(request.id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Some(CreatedInvitation {
|
||||||
|
id: invitation_id,
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE access_requests
|
||||||
|
SET status = 'approved', reviewed_by = NULL, reviewed_at = NOW(),
|
||||||
|
rejection_reason = NULL, provisioned_user_id = $2,
|
||||||
|
provisioned_project_id = $3, provisioned_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = $1"#,
|
||||||
|
)
|
||||||
|
.bind(request.id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(project_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
let email = if let Some(invitation) = &invitation {
|
||||||
|
crate::access_request_emails::activation_email(
|
||||||
|
&request.full_name,
|
||||||
|
&request.company,
|
||||||
|
&invitation.token,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
crate::access_request_emails::access_ready_email(&request.full_name, &request.company)
|
||||||
|
};
|
||||||
|
|
||||||
|
let email_result = crate::smtp_mailer::send_email(crate::smtp_mailer::EmailMessage {
|
||||||
|
to: &request.email,
|
||||||
|
subject: &email.subject,
|
||||||
|
body: &email.body,
|
||||||
|
html_body: email.html_body.as_deref(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let invitation_id = invitation.as_ref().map(|invitation| invitation.id);
|
||||||
|
let email_audit_result = match (&invitation, email_result) {
|
||||||
|
(Some(invitation), Ok(())) => {
|
||||||
|
mark_invitation_email_sent(pool, invitation.id).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
(Some(invitation), Err(err)) => {
|
||||||
|
let error = err.to_string();
|
||||||
|
mark_invitation_email_failed(pool, invitation.id, &error).await;
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
(None, Err(err)) => {
|
||||||
|
let error = err.to_string();
|
||||||
|
tracing::warn!(request_id = %request.id, error = %error, "Access-ready email failed");
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
(None, Ok(())) => Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let row = fetch_access_request(pool, request.id).await?;
|
||||||
|
let metadata = provisioning_audit_metadata(
|
||||||
|
&request,
|
||||||
|
project_id,
|
||||||
|
user_id,
|
||||||
|
user_was_created,
|
||||||
|
invitation.is_some(),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (action, metadata) in [
|
||||||
|
("access_request.auto_approve", metadata),
|
||||||
|
(
|
||||||
|
"access_request.project_provisioned",
|
||||||
|
serde_json::json!({ "project_id": project_id }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
if user_was_created {
|
||||||
|
"access_request.user_created"
|
||||||
|
} else {
|
||||||
|
"access_request.user_linked"
|
||||||
|
},
|
||||||
|
serde_json::json!({ "user_id": user_id, "email": &request.email }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"access_request.trial_created",
|
||||||
|
serde_json::json!({ "project_id": project_id, "status": "trial", "trial_days": 30 }),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
crate::audit::log(
|
||||||
|
pool,
|
||||||
|
crate::audit::AuditEntry::new(action)
|
||||||
|
.with_headers(headers)
|
||||||
|
.with_resource(format!("access_request:{}", request.id))
|
||||||
|
.with_metadata(metadata),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(invitation_id) = invitation_id {
|
||||||
|
crate::audit::log(
|
||||||
|
pool,
|
||||||
|
crate::audit::AuditEntry::new("access_request.invitation_created")
|
||||||
|
.with_headers(headers)
|
||||||
|
.with_resource(format!("access_request:{}", request.id))
|
||||||
|
.with_metadata(serde_json::json!({
|
||||||
|
"invitation_id": invitation_id,
|
||||||
|
"project_id": project_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::audit::log(
|
||||||
|
pool,
|
||||||
|
crate::audit::AuditEntry::new(if email_audit_result.is_ok() {
|
||||||
|
"access_request.email_sent"
|
||||||
|
} else {
|
||||||
|
"access_request.email_failed"
|
||||||
|
})
|
||||||
|
.with_headers(headers)
|
||||||
|
.with_resource(format!("access_request:{}", request.id))
|
||||||
|
.with_metadata(serde_json::json!({
|
||||||
|
"invitation_id": invitation_id,
|
||||||
|
"project_id": project_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"email": &request.email,
|
||||||
|
"error": email_audit_result.err(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
/// POST /api/platform/access-requests/:id/invitation/resend
|
/// POST /api/platform/access-requests/:id/invitation/resend
|
||||||
pub async fn resend_access_request_invitation(
|
pub async fn resend_access_request_invitation(
|
||||||
platform: PlatformAdminClaims,
|
platform: PlatformAdminClaims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<AccessRequestRow>, AppError> {
|
) -> Result<Json<AccessRequestRow>, AppError> {
|
||||||
|
crate::auth::require_access_request_reviewer(&platform.0)?;
|
||||||
|
|
||||||
let platform_user_id = Uuid::parse_str(&platform.0.sub)
|
let platform_user_id = Uuid::parse_str(&platform.0.sub)
|
||||||
.map_err(|_| AppError::BadRequest("ID de operador inválido".to_string()))?;
|
.map_err(|_| AppError::BadRequest("ID de operador inválido".to_string()))?;
|
||||||
|
|
||||||
@@ -751,6 +1039,7 @@ pub async fn resend_access_request_invitation(
|
|||||||
to: &request.email,
|
to: &request.email,
|
||||||
subject: &email.subject,
|
subject: &email.subject,
|
||||||
body: &email.body,
|
body: &email.body,
|
||||||
|
html_body: email.html_body.as_deref(),
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -811,6 +1100,8 @@ pub async fn reject_access_request(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(payload): Json<RejectAccessRequestPayload>,
|
Json(payload): Json<RejectAccessRequestPayload>,
|
||||||
) -> Result<Json<AccessRequestRow>, AppError> {
|
) -> Result<Json<AccessRequestRow>, AppError> {
|
||||||
|
crate::auth::require_access_request_reviewer(&platform.0)?;
|
||||||
|
|
||||||
let reason = payload.reason.trim();
|
let reason = payload.reason.trim();
|
||||||
|
|
||||||
if reason.len() < 3 {
|
if reason.len() < 3 {
|
||||||
@@ -838,6 +1129,20 @@ pub async fn reject_access_request(
|
|||||||
|
|
||||||
let row = fetch_access_request(&pool, rejected_id).await?;
|
let row = fetch_access_request(&pool, rejected_id).await?;
|
||||||
|
|
||||||
|
let rejection_email =
|
||||||
|
crate::access_request_emails::access_request_rejection_email(&row.full_name);
|
||||||
|
let rejection_email_result = crate::smtp_mailer::send_email(crate::smtp_mailer::EmailMessage {
|
||||||
|
to: &row.email,
|
||||||
|
subject: &rejection_email.subject,
|
||||||
|
body: &rejection_email.body,
|
||||||
|
html_body: rejection_email.html_body.as_deref(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(error) = &rejection_email_result {
|
||||||
|
tracing::warn!(request_id = %row.id, error = %error, "Access request rejection email failed");
|
||||||
|
}
|
||||||
|
|
||||||
crate::audit::log(
|
crate::audit::log(
|
||||||
&pool,
|
&pool,
|
||||||
crate::audit::AuditEntry::new("access_request.reject")
|
crate::audit::AuditEntry::new("access_request.reject")
|
||||||
@@ -848,6 +1153,7 @@ pub async fn reject_access_request(
|
|||||||
"company": &row.company,
|
"company": &row.company,
|
||||||
"nit": &row.nit,
|
"nit": &row.nit,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
|
"rejection_email_status": if rejection_email_result.is_ok() { "sent" } else { "failed" },
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -861,6 +1167,8 @@ pub async fn mark_access_request_in_review(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<(StatusCode, Json<AccessRequestRow>), AppError> {
|
) -> Result<(StatusCode, Json<AccessRequestRow>), AppError> {
|
||||||
|
crate::auth::require_access_request_reviewer(&platform.0)?;
|
||||||
|
|
||||||
let platform_user_id = Uuid::parse_str(&platform.0.sub)
|
let platform_user_id = Uuid::parse_str(&platform.0.sub)
|
||||||
.map_err(|_| AppError::BadRequest("ID de operador inválido".to_string()))?;
|
.map_err(|_| AppError::BadRequest("ID de operador inválido".to_string()))?;
|
||||||
|
|
||||||
@@ -1041,6 +1349,22 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn access_request_select_exposes_trial_expiry_from_subscription_without_filtering_requests() {
|
||||||
|
assert!(
|
||||||
|
ACCESS_REQUEST_SELECT.contains("sub.trial_ends_at AS trial_ends_at"),
|
||||||
|
"access request responses should expose trial expiry from the provisioned project subscription"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ACCESS_REQUEST_SELECT.contains("LEFT JOIN LATERAL"),
|
||||||
|
"subscription trial lookup must keep unprovisioned requests in list/detail responses"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ACCESS_REQUEST_SELECT.contains("WHERE project_id = ar.provisioned_project_id"),
|
||||||
|
"subscription trial lookup should be joined through provisioned_project_id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_defaults_follow_design_mapping_for_company_operator_and_contract() {
|
fn project_defaults_follow_design_mapping_for_company_operator_and_contract() {
|
||||||
let request = approval_request_row();
|
let request = approval_request_row();
|
||||||
|
|||||||
@@ -1,9 +1,109 @@
|
|||||||
use axum::{Json, extract::{Path, State}, http::StatusCode};
|
use crate::auth::{AdminClaims, Claims, role_satisfies_platform_claims};
|
||||||
|
use crate::errors::AppError;
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use shared_lib::models::{AlertRule, CreateAlertRuleRequest};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use shared_lib::models::{AlertRule, CreateAlertRuleRequest};
|
|
||||||
use crate::auth::{Claims, AdminClaims};
|
#[derive(Deserialize)]
|
||||||
use crate::errors::AppError;
|
pub struct AcknowledgeAlarmsRequest {
|
||||||
|
pub ids: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_platform_global(claims: &Claims) -> bool {
|
||||||
|
role_satisfies_platform_claims(&claims.role)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alarm_select_sql(has_project: bool, platform_global: bool) -> &'static str {
|
||||||
|
match (has_project, platform_global) {
|
||||||
|
(true, true) => {
|
||||||
|
r#"SELECT row_to_json(t) FROM (
|
||||||
|
SELECT ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
|
||||||
|
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, COALESCE(ta.acknowledged, FALSE) AS acknowledged,
|
||||||
|
ta.acknowledged_at, ta.acknowledged_by, ta.timestamp
|
||||||
|
FROM telemetry_alarms ta
|
||||||
|
WHERE ta.project_id = $1
|
||||||
|
ORDER BY ta.timestamp DESC LIMIT $2
|
||||||
|
) t"#
|
||||||
|
}
|
||||||
|
(true, false) => {
|
||||||
|
r#"SELECT row_to_json(t) FROM (
|
||||||
|
SELECT ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
|
||||||
|
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, COALESCE(ta.acknowledged, FALSE) AS acknowledged,
|
||||||
|
ta.acknowledged_at, ta.acknowledged_by, ta.timestamp
|
||||||
|
FROM telemetry_alarms ta
|
||||||
|
JOIN user_project_access upa ON upa.project_id = ta.project_id
|
||||||
|
WHERE ta.project_id = $1
|
||||||
|
AND upa.user_id = $2
|
||||||
|
AND upa.is_active = TRUE
|
||||||
|
ORDER BY ta.timestamp DESC LIMIT $3
|
||||||
|
) t"#
|
||||||
|
}
|
||||||
|
(false, true) => {
|
||||||
|
r#"SELECT row_to_json(t) FROM (
|
||||||
|
SELECT ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
|
||||||
|
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, COALESCE(ta.acknowledged, FALSE) AS acknowledged,
|
||||||
|
ta.acknowledged_at, ta.acknowledged_by, ta.timestamp
|
||||||
|
FROM telemetry_alarms ta
|
||||||
|
ORDER BY ta.timestamp DESC LIMIT $1
|
||||||
|
) t"#
|
||||||
|
}
|
||||||
|
(false, false) => {
|
||||||
|
r#"SELECT row_to_json(t) FROM (
|
||||||
|
SELECT ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
|
||||||
|
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, COALESCE(ta.acknowledged, FALSE) AS acknowledged,
|
||||||
|
ta.acknowledged_at, ta.acknowledged_by, ta.timestamp
|
||||||
|
FROM telemetry_alarms ta
|
||||||
|
JOIN user_project_access upa ON upa.project_id = ta.project_id
|
||||||
|
WHERE upa.user_id = $1
|
||||||
|
AND upa.is_active = TRUE
|
||||||
|
ORDER BY ta.timestamp DESC LIMIT $2
|
||||||
|
) t"#
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alarm_ack_scope_clause(platform_global: bool) -> &'static str {
|
||||||
|
if platform_global {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
r#"AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_project_access upa
|
||||||
|
WHERE upa.user_id = $2
|
||||||
|
AND upa.project_id = ta.project_id
|
||||||
|
AND upa.is_active = TRUE
|
||||||
|
)"#
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn acknowledge_alarm_sql(platform_global: bool, bulk: bool) -> String {
|
||||||
|
let id_predicate = if bulk {
|
||||||
|
"ta.id = ANY($1)"
|
||||||
|
} else {
|
||||||
|
"ta.id = $1"
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
r#"WITH updated AS (
|
||||||
|
UPDATE telemetry_alarms ta
|
||||||
|
SET acknowledged = TRUE,
|
||||||
|
acknowledged_at = COALESCE(ta.acknowledged_at, NOW()),
|
||||||
|
acknowledged_by = COALESCE(ta.acknowledged_by, $2)
|
||||||
|
WHERE {id_predicate}
|
||||||
|
{scope_clause}
|
||||||
|
RETURNING ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
|
||||||
|
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, ta.acknowledged,
|
||||||
|
ta.acknowledged_at, ta.acknowledged_by, ta.timestamp
|
||||||
|
)
|
||||||
|
SELECT row_to_json(updated) FROM updated"#,
|
||||||
|
scope_clause = alarm_ack_scope_clause(platform_global)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/alert-rules?project_id=xxx
|
/// GET /api/alert-rules?project_id=xxx
|
||||||
pub async fn list_alert_rules(
|
pub async fn list_alert_rules(
|
||||||
@@ -13,25 +113,36 @@ pub async fn list_alert_rules(
|
|||||||
) -> Result<Json<Vec<AlertRule>>, AppError> {
|
) -> Result<Json<Vec<AlertRule>>, AppError> {
|
||||||
let user_id = Uuid::parse_str(&claims.sub)
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
.map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?;
|
.map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?;
|
||||||
let project_id = params.get("project_id")
|
let project_id = params
|
||||||
|
.get("project_id")
|
||||||
.and_then(|s| Uuid::parse_str(s).ok());
|
.and_then(|s| Uuid::parse_str(s).ok());
|
||||||
|
|
||||||
let rules = if claims.role == "admin" || claims.role == "superadmin" {
|
let rules = if claims.role == "admin" || claims.role == "superadmin" {
|
||||||
match project_id {
|
match project_id {
|
||||||
Some(pid) => sqlx::query_as::<_, AlertRule>(
|
Some(pid) => {
|
||||||
"SELECT * FROM alert_rules WHERE project_id = $1 ORDER BY created_at DESC"
|
sqlx::query_as::<_, AlertRule>(
|
||||||
).bind(pid).fetch_all(&pool).await?,
|
"SELECT * FROM alert_rules WHERE project_id = $1 ORDER BY created_at DESC",
|
||||||
None => sqlx::query_as::<_, AlertRule>(
|
)
|
||||||
"SELECT * FROM alert_rules ORDER BY created_at DESC"
|
.bind(pid)
|
||||||
).fetch_all(&pool).await?,
|
.fetch_all(&pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
sqlx::query_as::<_, AlertRule>("SELECT * FROM alert_rules ORDER BY created_at DESC")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as::<_, AlertRule>(
|
sqlx::query_as::<_, AlertRule>(
|
||||||
r#"SELECT ar.* FROM alert_rules ar
|
r#"SELECT ar.* FROM alert_rules ar
|
||||||
JOIN user_project_access upa ON ar.project_id = upa.project_id
|
JOIN user_project_access upa ON ar.project_id = upa.project_id
|
||||||
WHERE upa.user_id = $1 AND upa.is_active = true
|
WHERE upa.user_id = $1 AND upa.is_active = true
|
||||||
ORDER BY ar.created_at DESC"#
|
ORDER BY ar.created_at DESC"#,
|
||||||
).bind(user_id).fetch_all(&pool).await?
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(rules))
|
Ok(Json(rules))
|
||||||
@@ -130,46 +241,194 @@ pub async fn list_alarms(
|
|||||||
claims: Claims,
|
claims: Claims,
|
||||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||||
) -> Result<Json<Vec<serde_json::Value>>, AppError> {
|
) -> Result<Json<Vec<serde_json::Value>>, AppError> {
|
||||||
let limit: i64 = params.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50).min(500);
|
let limit: i64 = params
|
||||||
let project_id = params.get("project_id").and_then(|s| Uuid::parse_str(s).ok());
|
.get("limit")
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
let alarms = match project_id {
|
.unwrap_or(50)
|
||||||
Some(pid) => {
|
.min(500);
|
||||||
sqlx::query_scalar::<_, serde_json::Value>(
|
let project_id = params
|
||||||
r#"SELECT row_to_json(t) FROM (
|
.get("project_id")
|
||||||
SELECT id, project_id, asset_id, variable_alias, current_value,
|
.and_then(|s| Uuid::parse_str(s).ok());
|
||||||
alarm_min, alarm_max, alarm_type, message, timestamp
|
|
||||||
FROM telemetry_alarms
|
|
||||||
WHERE project_id = $1
|
|
||||||
ORDER BY timestamp DESC LIMIT $2
|
|
||||||
) t"#
|
|
||||||
).bind(pid).bind(limit).fetch_all(&pool).await?
|
|
||||||
},
|
|
||||||
None if claims.role == "admin" || claims.role == "superadmin" => {
|
|
||||||
sqlx::query_scalar::<_, serde_json::Value>(
|
|
||||||
r#"SELECT row_to_json(t) FROM (
|
|
||||||
SELECT id, project_id, asset_id, variable_alias, current_value,
|
|
||||||
alarm_min, alarm_max, alarm_type, message, timestamp
|
|
||||||
FROM telemetry_alarms
|
|
||||||
ORDER BY timestamp DESC LIMIT $1
|
|
||||||
) t"#
|
|
||||||
).bind(limit).fetch_all(&pool).await?
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
let user_id = Uuid::parse_str(&claims.sub)
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
.map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?;
|
.map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?;
|
||||||
sqlx::query_scalar::<_, serde_json::Value>(
|
|
||||||
r#"SELECT row_to_json(t) FROM (
|
let platform_global = is_platform_global(&claims);
|
||||||
SELECT ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
|
let alarms = match project_id {
|
||||||
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, ta.timestamp
|
Some(pid) if platform_global => {
|
||||||
FROM telemetry_alarms ta
|
sqlx::query_scalar::<_, serde_json::Value>(alarm_select_sql(true, platform_global))
|
||||||
JOIN user_project_access upa ON ta.project_id = upa.project_id
|
.bind(pid)
|
||||||
WHERE upa.user_id = $1 AND upa.is_active = true
|
.bind(limit)
|
||||||
ORDER BY ta.timestamp DESC LIMIT $2
|
.fetch_all(&pool)
|
||||||
) t"#
|
.await?
|
||||||
).bind(user_id).bind(limit).fetch_all(&pool).await?
|
}
|
||||||
|
Some(pid) => {
|
||||||
|
sqlx::query_scalar::<_, serde_json::Value>(alarm_select_sql(true, platform_global))
|
||||||
|
.bind(pid)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
None if platform_global => {
|
||||||
|
sqlx::query_scalar::<_, serde_json::Value>(alarm_select_sql(false, platform_global))
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
sqlx::query_scalar::<_, serde_json::Value>(alarm_select_sql(false, platform_global))
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(alarms))
|
Ok(Json(alarms))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PATCH /api/alarms/:id/acknowledge — marcar una alarma como reconocida
|
||||||
|
pub async fn acknowledge_alarm(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
claims: Claims,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
|
.map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?;
|
||||||
|
|
||||||
|
let platform_global = is_platform_global(&claims);
|
||||||
|
let alarm =
|
||||||
|
sqlx::query_scalar::<_, serde_json::Value>(&acknowledge_alarm_sql(platform_global, false))
|
||||||
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound("Alarma".to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(alarm))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PATCH /api/alarms/acknowledge — marcar múltiples alarmas como reconocidas
|
||||||
|
pub async fn acknowledge_alarms(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
claims: Claims,
|
||||||
|
Json(req): Json<AcknowledgeAlarmsRequest>,
|
||||||
|
) -> Result<Json<Vec<serde_json::Value>>, AppError> {
|
||||||
|
if req.ids.is_empty() {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Debe enviar al menos una alarma".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
|
.map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?;
|
||||||
|
|
||||||
|
let platform_global = is_platform_global(&claims);
|
||||||
|
let alarms =
|
||||||
|
sqlx::query_scalar::<_, serde_json::Value>(&acknowledge_alarm_sql(platform_global, true))
|
||||||
|
.bind(&req.ids)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if alarms.is_empty() {
|
||||||
|
return Err(AppError::NotFound("Alarmas".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(alarms))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
acknowledge_alarm_sql, alarm_ack_scope_clause, alarm_select_sql, is_platform_global,
|
||||||
|
};
|
||||||
|
use crate::auth::Claims;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn claims(role: &str) -> Claims {
|
||||||
|
Claims {
|
||||||
|
sub: Uuid::new_v4().to_string(),
|
||||||
|
role: role.to_string(),
|
||||||
|
email: format!("{role}@example.com"),
|
||||||
|
projects: Vec::new(),
|
||||||
|
exp: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn customer_alarm_list_without_project_is_scoped_by_project_access() {
|
||||||
|
let sql = alarm_select_sql(false, false);
|
||||||
|
|
||||||
|
assert!(sql.contains("JOIN user_project_access upa"));
|
||||||
|
assert!(sql.contains("upa.user_id = $1"));
|
||||||
|
assert!(sql.contains("upa.project_id = ta.project_id"));
|
||||||
|
assert!(sql.contains("upa.is_active = TRUE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platform_alarm_list_without_project_omits_project_access_scope() {
|
||||||
|
let sql = alarm_select_sql(false, true);
|
||||||
|
|
||||||
|
assert!(!sql.contains("user_project_access"));
|
||||||
|
assert!(sql.contains("FROM telemetry_alarms"));
|
||||||
|
assert!(sql.contains("LIMIT $1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn customer_alarm_list_for_project_is_scoped_by_project_access() {
|
||||||
|
let sql = alarm_select_sql(true, false);
|
||||||
|
|
||||||
|
assert!(sql.contains("JOIN user_project_access upa"));
|
||||||
|
assert!(sql.contains("ta.project_id = $1"));
|
||||||
|
assert!(sql.contains("upa.user_id = $2"));
|
||||||
|
assert!(sql.contains("LIMIT $3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platform_alarm_list_for_project_omits_project_access_scope() {
|
||||||
|
let sql = alarm_select_sql(true, true);
|
||||||
|
|
||||||
|
assert!(!sql.contains("user_project_access"));
|
||||||
|
assert!(sql.contains("ta.project_id = $1"));
|
||||||
|
assert!(sql.contains("LIMIT $2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn customer_ack_scope_requires_project_access() {
|
||||||
|
let clause = alarm_ack_scope_clause(false);
|
||||||
|
|
||||||
|
assert!(clause.contains("EXISTS"));
|
||||||
|
assert!(clause.contains("FROM user_project_access upa"));
|
||||||
|
assert!(clause.contains("upa.user_id = $2"));
|
||||||
|
assert!(clause.contains("upa.project_id = ta.project_id"));
|
||||||
|
assert!(clause.contains("upa.is_active = TRUE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platform_ack_scope_does_not_require_project_access() {
|
||||||
|
let clause = alarm_ack_scope_clause(true);
|
||||||
|
|
||||||
|
assert!(!clause.contains("user_project_access"));
|
||||||
|
assert!(clause.trim().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn customer_single_and_bulk_ack_sql_include_project_access_scope() {
|
||||||
|
let single_sql = acknowledge_alarm_sql(false, false);
|
||||||
|
let bulk_sql = acknowledge_alarm_sql(false, true);
|
||||||
|
|
||||||
|
assert!(single_sql.contains("WHERE ta.id = $1"));
|
||||||
|
assert!(single_sql.contains("FROM user_project_access upa"));
|
||||||
|
assert!(bulk_sql.contains("WHERE ta.id = ANY($1)"));
|
||||||
|
assert!(bulk_sql.contains("FROM user_project_access upa"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_platform_roles_are_platform_global_for_alarms() {
|
||||||
|
assert!(is_platform_global(&claims("platform_admin")));
|
||||||
|
assert!(is_platform_global(&claims("platform_operator")));
|
||||||
|
assert!(!is_platform_global(&claims("admin")));
|
||||||
|
assert!(!is_platform_global(&claims("superadmin")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
|
use serde_json::Value;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
pub async fn log_audit(
|
pub async fn log_audit(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
|
|||||||
@@ -44,6 +44,23 @@ fn extract_refresh_cookie(headers: &HeaderMap) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_active_access_request_unique_violation(error: &sqlx::Error) -> bool {
|
||||||
|
error
|
||||||
|
.as_database_error()
|
||||||
|
.and_then(|database_error| database_error.constraint())
|
||||||
|
== Some("idx_access_requests_active_email_unique")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duplicate_access_request_response() -> axum::response::Response {
|
||||||
|
(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
Json(json!({
|
||||||
|
"error": "Ya existe una solicitud activa para este email"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, validator::Validate)]
|
#[derive(Deserialize, validator::Validate)]
|
||||||
pub struct LoginPayload {
|
pub struct LoginPayload {
|
||||||
#[validate(email(message = "Email inválido"))]
|
#[validate(email(message = "Email inválido"))]
|
||||||
@@ -60,16 +77,8 @@ pub struct RequestAccessPayload {
|
|||||||
full_name: String,
|
full_name: String,
|
||||||
#[validate(email)]
|
#[validate(email)]
|
||||||
email: String,
|
email: String,
|
||||||
#[validate(length(min = 2, max = 200))]
|
#[validate(length(max = 50))]
|
||||||
company: String,
|
whatsapp: Option<String>,
|
||||||
#[validate(length(min = 3, max = 30))]
|
|
||||||
nit: String,
|
|
||||||
#[validate(length(min = 2, max = 150))]
|
|
||||||
position: String,
|
|
||||||
#[validate(length(min = 7, max = 50))]
|
|
||||||
phone: String,
|
|
||||||
#[validate(length(max = 2000))]
|
|
||||||
message: Option<String>,
|
|
||||||
#[validate(length(min = 1))]
|
#[validate(length(min = 1))]
|
||||||
turnstile_token: String,
|
turnstile_token: String,
|
||||||
}
|
}
|
||||||
@@ -359,21 +368,6 @@ pub async fn login(
|
|||||||
Err(AuthError::InvalidCredentials)
|
Err(AuthError::InvalidCredentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_valid_nit(nit: &str) -> bool {
|
|
||||||
let mut parts = nit.split('-');
|
|
||||||
let Some(number) = parts.next() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Some(check_digit) = parts.next() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
parts.next().is_none()
|
|
||||||
&& (9..=10).contains(&number.len())
|
|
||||||
&& number.chars().all(|c| c.is_ascii_digit())
|
|
||||||
&& check_digit.len() == 1
|
|
||||||
&& check_digit.chars().all(|c| c.is_ascii_digit())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn verify_turnstile(token: &str, headers: &HeaderMap) -> Result<(), AuthError> {
|
async fn verify_turnstile(token: &str, headers: &HeaderMap) -> Result<(), AuthError> {
|
||||||
let secret = std::env::var("TURNSTILE_SECRET_KEY")
|
let secret = std::env::var("TURNSTILE_SECRET_KEY")
|
||||||
.map_err(|_| AuthError::Internal(anyhow::anyhow!("TURNSTILE_SECRET_KEY must be set")))?;
|
.map_err(|_| AuthError::Internal(anyhow::anyhow!("TURNSTILE_SECRET_KEY must be set")))?;
|
||||||
@@ -412,6 +406,21 @@ async fn verify_turnstile(token: &str, headers: &HeaderMap) -> Result<(), AuthEr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_valid_nit(nit: &str) -> bool {
|
||||||
|
let mut parts = nit.split('-');
|
||||||
|
let Some(number) = parts.next() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(check_digit) = parts.next() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
parts.next().is_none()
|
||||||
|
&& (9..=10).contains(&number.len())
|
||||||
|
&& number.chars().all(|c| c.is_ascii_digit())
|
||||||
|
&& check_digit.len() == 1
|
||||||
|
&& check_digit.chars().all(|c| c.is_ascii_digit())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn request_access(
|
pub async fn request_access(
|
||||||
State(pool): State<DbPool>,
|
State(pool): State<DbPool>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -419,14 +428,6 @@ pub async fn request_access(
|
|||||||
RequestAccessPayload,
|
RequestAccessPayload,
|
||||||
>,
|
>,
|
||||||
) -> Result<impl IntoResponse, AuthError> {
|
) -> Result<impl IntoResponse, AuthError> {
|
||||||
if !is_valid_nit(&payload.nit) {
|
|
||||||
return Ok((
|
|
||||||
StatusCode::UNPROCESSABLE_ENTITY,
|
|
||||||
Json(json!({ "error": "Formato NIT inválido" })),
|
|
||||||
)
|
|
||||||
.into_response());
|
|
||||||
}
|
|
||||||
|
|
||||||
verify_turnstile(&payload.turnstile_token, &headers).await?;
|
verify_turnstile(&payload.turnstile_token, &headers).await?;
|
||||||
|
|
||||||
let request_ip = headers
|
let request_ip = headers
|
||||||
@@ -444,51 +445,114 @@ pub async fn request_access(
|
|||||||
.get("User-Agent")
|
.get("User-Agent")
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.map(str::to_string);
|
.map(str::to_string);
|
||||||
|
let email = payload.email.trim().to_ascii_lowercase();
|
||||||
|
|
||||||
sqlx::query(
|
let has_active_request: bool = sqlx::query_scalar(
|
||||||
r#"INSERT INTO access_requests
|
r#"SELECT EXISTS(
|
||||||
(full_name, email, company, nit, position, phone, message, request_ip, user_agent)
|
SELECT 1
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"#,
|
FROM access_requests
|
||||||
|
WHERE lower(email) = $1
|
||||||
|
AND status IN ('pending', 'in_review', 'approved')
|
||||||
|
)"#,
|
||||||
)
|
)
|
||||||
.bind(payload.full_name.trim())
|
.bind(&email)
|
||||||
.bind(payload.email.trim().to_lowercase())
|
.fetch_one(&pool)
|
||||||
.bind(payload.company.trim())
|
|
||||||
.bind(payload.nit.trim())
|
|
||||||
.bind(payload.position.trim())
|
|
||||||
.bind(payload.phone.trim())
|
|
||||||
.bind(
|
|
||||||
payload
|
|
||||||
.message
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|s| !s.is_empty()),
|
|
||||||
)
|
|
||||||
.bind(&request_ip)
|
|
||||||
.bind(&user_agent)
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Access request insert error: {}", e);
|
tracing::error!("Access request duplicate lookup error: {}", e);
|
||||||
AuthError::Internal(anyhow::anyhow!("Error al registrar solicitud"))
|
AuthError::Internal(anyhow::anyhow!("Error al validar solicitud"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
if has_active_request {
|
||||||
|
return Ok(duplicate_access_request_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let request_id = sqlx::query_scalar(
|
||||||
|
r#"INSERT INTO access_requests
|
||||||
|
(full_name, email, company, nit, position, phone, message, request_ip, user_agent)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id"#,
|
||||||
|
)
|
||||||
|
.bind(payload.full_name.trim())
|
||||||
|
.bind(&email)
|
||||||
|
.bind(payload.full_name.trim())
|
||||||
|
.bind("PENDING")
|
||||||
|
.bind("Trial lead")
|
||||||
|
.bind(
|
||||||
|
payload
|
||||||
|
.whatsapp
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(""),
|
||||||
|
)
|
||||||
|
.bind(Option::<&str>::None)
|
||||||
|
.bind(&request_ip)
|
||||||
|
.bind(&user_agent)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let request_id = match request_id {
|
||||||
|
Ok(request_id) => request_id,
|
||||||
|
Err(error) => {
|
||||||
|
if is_active_access_request_unique_violation(&error) {
|
||||||
|
return Ok(duplicate_access_request_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::error!("Access request insert error: {}", error);
|
||||||
|
return Err(AuthError::Internal(anyhow::anyhow!(
|
||||||
|
"Error al registrar solicitud"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
crate::audit::log(
|
crate::audit::log(
|
||||||
&pool,
|
&pool,
|
||||||
crate::audit::AuditEntry::new("access_request.create")
|
crate::audit::AuditEntry::new("access_request.create")
|
||||||
.with_headers(&headers)
|
.with_headers(&headers)
|
||||||
.with_metadata(json!({
|
.with_metadata(json!({
|
||||||
"email": payload.email,
|
"email": &payload.email,
|
||||||
"company": payload.company,
|
"whatsapp_provided": payload.whatsapp.as_deref().is_some_and(|value| !value.trim().is_empty()),
|
||||||
"nit": payload.nit,
|
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok((
|
let approved_request = if let Ok(row) =
|
||||||
|
crate::handlers::access_requests::auto_approve_access_request(&pool, request_id, &headers)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
row
|
||||||
|
} else {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"UPDATE access_requests
|
||||||
|
SET status = 'rejected', rejection_reason = 'auto_approval_failed', updated_at = NOW()
|
||||||
|
WHERE id = $1 AND status = 'pending'"#,
|
||||||
|
)
|
||||||
|
.bind(request_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
tracing::error!(request_id = %request_id, "Access request auto-approval failed");
|
||||||
|
return Err(AuthError::Internal(anyhow::anyhow!(
|
||||||
|
"Error al aprobar la prueba gratuita"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
if approved_request.invitation_email_status.as_deref() == Some("failed") {
|
||||||
|
return Ok((
|
||||||
StatusCode::ACCEPTED,
|
StatusCode::ACCEPTED,
|
||||||
Json(AccessRequestResponse {
|
Json(AccessRequestResponse {
|
||||||
status: "pending",
|
status: "approved_email_failed",
|
||||||
message: "Solicitud recibida. Nuestro equipo revisará la información.",
|
message: "Tu prueba gratuita fue aprobada, pero no pudimos enviar el correo de activación. Escríbenos a soporte@omnioil.app.",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(AccessRequestResponse {
|
||||||
|
status: "approved",
|
||||||
|
message: "Tu prueba gratuita fue aprobada. Revisa tu correo para activar la cuenta.",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_response())
|
.into_response())
|
||||||
@@ -660,11 +724,242 @@ fn hash_password(password: &str) -> Result<String, AuthError> {
|
|||||||
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Hash error: {}", e)))
|
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Hash error: {}", e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate)]
|
||||||
|
pub struct PublicRegisterPayload {
|
||||||
|
#[validate(email(message = "Email inválido"))]
|
||||||
|
pub email: String,
|
||||||
|
#[validate(length(min = 6, message = "Contraseña debe tener al menos 6 caracteres"))]
|
||||||
|
pub password: String,
|
||||||
|
#[validate(length(min = 3, message = "Nombre completo inválido"))]
|
||||||
|
pub full_name: String,
|
||||||
|
#[validate(length(min = 2, message = "Nombre de la empresa inválido"))]
|
||||||
|
pub company: String,
|
||||||
|
#[validate(length(min = 3, max = 30))]
|
||||||
|
pub nit: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
State(_pool): State<DbPool>,
|
State(state): State<crate::AppState>,
|
||||||
Json(_payload): Json<SetupRegisterPayload>,
|
headers: HeaderMap,
|
||||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
crate::validation::ValidatedJson(payload): crate::validation::ValidatedJson<
|
||||||
Err(AuthError::RegistrationDisabled)
|
PublicRegisterPayload,
|
||||||
|
>,
|
||||||
|
) -> Result<impl IntoResponse, AuthError> {
|
||||||
|
if !is_valid_nit(&payload.nit) {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
Json(json!({ "error": "Formato NIT inválido" })),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let password_hash = hash_password(&payload.password)?;
|
||||||
|
|
||||||
|
let mut tx =
|
||||||
|
state.pool.begin().await.map_err(|e| {
|
||||||
|
AuthError::Internal(anyhow::anyhow!("Error al iniciar transacción: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let user_exists: bool =
|
||||||
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
|
||||||
|
.bind(payload.email.trim().to_lowercase())
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AuthError::Internal(anyhow::anyhow!(
|
||||||
|
"Error al verificar existencia de usuario: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if user_exists {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
Json(json!({ "error": "El correo electrónico ya está registrado" })),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let admin_role_id: i32 = sqlx::query_scalar("SELECT id FROM roles WHERE name = 'admin'")
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al buscar rol: {}", e)))?
|
||||||
|
.ok_or_else(|| AuthError::Internal(anyhow::anyhow!("Rol admin no encontrado")))?;
|
||||||
|
|
||||||
|
let user_id: uuid::Uuid = sqlx::query_scalar(
|
||||||
|
"INSERT INTO users (email, password_hash, full_name, role_id, is_active) VALUES ($1, $2, $3, $4, true) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(payload.email.trim().to_lowercase())
|
||||||
|
.bind(&password_hash)
|
||||||
|
.bind(payload.full_name.trim())
|
||||||
|
.bind(admin_role_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al crear usuario: {}", e)))?;
|
||||||
|
|
||||||
|
let base_project_name = {
|
||||||
|
let sanitized: String = payload
|
||||||
|
.company
|
||||||
|
.chars()
|
||||||
|
.map(|ch| {
|
||||||
|
if ch.is_ascii_alphanumeric() || ch == ' ' || ch == '-' || ch == '_' {
|
||||||
|
ch
|
||||||
|
} else {
|
||||||
|
' '
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let collapsed = sanitized.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
let base = if collapsed.is_empty() {
|
||||||
|
"Cliente OmniOil"
|
||||||
|
} else {
|
||||||
|
collapsed.as_str()
|
||||||
|
};
|
||||||
|
base.chars().take(80).collect::<String>()
|
||||||
|
};
|
||||||
|
|
||||||
|
let project_name_exists: bool =
|
||||||
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM edge_projects WHERE name = $1)")
|
||||||
|
.bind(&base_project_name)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AuthError::Internal(anyhow::anyhow!(
|
||||||
|
"Error al verificar existencia de proyecto: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let project_name = if project_name_exists {
|
||||||
|
format!(
|
||||||
|
"{}-{}",
|
||||||
|
base_project_name,
|
||||||
|
&uuid::Uuid::new_v4().to_string()[..8]
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
base_project_name
|
||||||
|
};
|
||||||
|
|
||||||
|
let project_id: uuid::Uuid = sqlx::query_scalar(
|
||||||
|
r#"INSERT INTO edge_projects
|
||||||
|
(name, client, operator_name, contract_number, description, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id"#,
|
||||||
|
)
|
||||||
|
.bind(&project_name)
|
||||||
|
.bind(&payload.company)
|
||||||
|
.bind(&payload.company)
|
||||||
|
.bind(format!("TRIAL-{}", payload.nit.trim()))
|
||||||
|
.bind(format!(
|
||||||
|
"Proyecto trial para {} (NIT {})",
|
||||||
|
payload.company, payload.nit
|
||||||
|
))
|
||||||
|
.bind(serde_json::json!({ "auto_onboarded": true, "company_nit": payload.nit }))
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al insertar proyecto: {}", e)))?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"INSERT INTO user_project_access (user_id, project_id, project_role, is_active)
|
||||||
|
VALUES ($1, $2, 'owner', true)"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(project_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AuthError::Internal(anyhow::anyhow!(
|
||||||
|
"Error al asociar usuario a proyecto: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"INSERT INTO subscriptions (project_id, status, trial_ends_at, notes)
|
||||||
|
VALUES ($1, 'trial', NOW() + INTERVAL '30 days', $2)"#,
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.bind("Trial de 30 días creado automáticamente al registrarse")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al crear suscripción: {}", e)))?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al realizar commit: {}", e)))?;
|
||||||
|
|
||||||
|
let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
|
||||||
|
project_id,
|
||||||
|
deployment_id: Some(project_id),
|
||||||
|
name: project_name.clone(),
|
||||||
|
client: payload.company.clone(),
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(payload_str) = serde_json::to_string(&event) {
|
||||||
|
let _ = state
|
||||||
|
.mqtt_client
|
||||||
|
.publish(
|
||||||
|
"omnioil/events/project_created",
|
||||||
|
rumqttc::QoS::AtLeastOnce,
|
||||||
|
false,
|
||||||
|
payload_str,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::audit::log(
|
||||||
|
&state.pool,
|
||||||
|
crate::audit::AuditEntry::new("user.register")
|
||||||
|
.with_user(user_id, &payload.email)
|
||||||
|
.with_headers(&headers),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let tokens = create_jwt_tokens(
|
||||||
|
&user_id.to_string(),
|
||||||
|
"admin",
|
||||||
|
&payload.email,
|
||||||
|
vec![project_id],
|
||||||
|
)
|
||||||
|
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al crear tokens: {}", e)))?;
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(tokens.refresh_token.as_bytes());
|
||||||
|
let refresh_hash = format!("{:x}", hasher.finalize());
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)")
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(refresh_hash)
|
||||||
|
.bind(chrono::Utc::now() + chrono::Duration::days(7))
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Error storing refresh token: {}", e);
|
||||||
|
AuthError::Internal(anyhow::anyhow!("Error de sesión"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let cookie = build_refresh_cookie(&tokens.refresh_token, 7 * 24 * 3600);
|
||||||
|
let mut response_headers = HeaderMap::new();
|
||||||
|
response_headers.insert(SET_COOKIE, cookie.parse().unwrap());
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
response_headers,
|
||||||
|
Json(json!({
|
||||||
|
"access_token": tokens.access_token,
|
||||||
|
"token": tokens.access_token,
|
||||||
|
"role": "admin",
|
||||||
|
"email": payload.email,
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"id": project_id,
|
||||||
|
"name": project_name
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ pub struct ProjectBillingRow {
|
|||||||
pub period_end: DateTime<Utc>,
|
pub period_end: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct ProjectLookup {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct UpdateSubscriptionRequest {
|
pub struct UpdateSubscriptionRequest {
|
||||||
pub tier: Option<String>,
|
pub tier: Option<String>,
|
||||||
@@ -173,10 +178,9 @@ pub async fn get_billing_usage(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get project name
|
// Get project name
|
||||||
let project = sqlx::query!(
|
let project =
|
||||||
"SELECT id, name FROM edge_projects WHERE id = $1",
|
sqlx::query_as::<_, ProjectLookup>("SELECT name FROM edge_projects WHERE id = $1")
|
||||||
project_id
|
.bind(project_id)
|
||||||
)
|
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -448,14 +452,13 @@ pub async fn take_usage_snapshot(
|
|||||||
|
|
||||||
/// Idempotent daily snapshot for all active projects. Called by watchdog.
|
/// Idempotent daily snapshot for all active projects. Called by watchdog.
|
||||||
pub async fn take_daily_snapshots(pool: &PgPool) -> anyhow::Result<usize> {
|
pub async fn take_daily_snapshots(pool: &PgPool) -> anyhow::Result<usize> {
|
||||||
let projects = sqlx::query!("SELECT id FROM edge_projects WHERE is_active = true")
|
let projects: Vec<Uuid> =
|
||||||
|
sqlx::query_scalar("SELECT id FROM edge_projects WHERE is_active = true")
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut count = 0usize;
|
let mut count = 0usize;
|
||||||
for row in &projects {
|
for project_id in &projects {
|
||||||
let project_id = row.id;
|
|
||||||
|
|
||||||
let active_tags: i64 = sqlx::query_scalar(
|
let active_tags: i64 = sqlx::query_scalar(
|
||||||
r#"SELECT COUNT(*)
|
r#"SELECT COUNT(*)
|
||||||
FROM edge_variables ev
|
FROM edge_variables ev
|
||||||
|
|||||||
112
services/backend-api/src/handlers/billing_webhook.rs
Normal file
112
services/backend-api/src/handlers/billing_webhook.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
use crate::AppState;
|
||||||
|
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct StripeEvent {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub event_type: String,
|
||||||
|
pub data: StripeData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct StripeData {
|
||||||
|
pub object: StripeObject,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct StripeObject {
|
||||||
|
pub client_reference_id: Option<String>,
|
||||||
|
pub metadata: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stripe_webhook(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<StripeEvent>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
tracing::info!("📩 Received Stripe Webhook event: {}", payload.event_type);
|
||||||
|
|
||||||
|
let event_type = payload.event_type.as_str();
|
||||||
|
|
||||||
|
if event_type == "checkout.session.completed"
|
||||||
|
|| event_type == "customer.subscription.updated"
|
||||||
|
|| event_type == "customer.subscription.created"
|
||||||
|
{
|
||||||
|
// Try to get project ID from client_reference_id or metadata
|
||||||
|
let project_id_str = payload.data.object.client_reference_id.clone().or_else(|| {
|
||||||
|
payload
|
||||||
|
.data
|
||||||
|
.object
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.get("project_id"))
|
||||||
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(ref id_str) = project_id_str {
|
||||||
|
if let Ok(project_id) = uuid::Uuid::parse_str(id_str) {
|
||||||
|
// Update subscription status to 'active'
|
||||||
|
// Upgrade tier to 'standard' and set limit to 200 variables
|
||||||
|
let update_result = sqlx::query(
|
||||||
|
r#"UPDATE subscriptions
|
||||||
|
SET status = 'active', tier = 'standard', tag_limit = 200, updated_at = NOW()
|
||||||
|
WHERE project_id = $1"#,
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match update_result {
|
||||||
|
Ok(res) => {
|
||||||
|
if res.rows_affected() > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"✅ Successfully activated subscription for project: {}",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// If no subscription exists for this project, let's insert one!
|
||||||
|
let insert_result = sqlx::query(
|
||||||
|
r#"INSERT INTO subscriptions (project_id, tier, tag_limit, status, notes)
|
||||||
|
VALUES ($1, 'standard', 200, 'active', 'Creado vía Stripe Webhook')"#,
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if insert_result.is_ok() {
|
||||||
|
tracing::info!(
|
||||||
|
"✅ Created standard active subscription for project: {}",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::error!(
|
||||||
|
"❌ Failed to insert active subscription for project: {}",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
"❌ Failed to update subscription for project {}: {:?}",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"⚠️ Stripe event contained invalid UUID project ID: {}",
|
||||||
|
id_str
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("⚠️ Stripe event did not contain project_id / client_reference_id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(StatusCode::OK, "Webhook processed successfully").into_response()
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use axum::{extract::State, response::IntoResponse, Json};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use crate::db::DbPool;
|
|
||||||
use crate::auth::Claims;
|
use crate::auth::Claims;
|
||||||
|
use crate::db::DbPool;
|
||||||
|
use axum::{Json, extract::State, response::IntoResponse};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde_json::{Value, json};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -13,10 +13,7 @@ struct TelemetryRow {
|
|||||||
data: Value,
|
data: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_summary(
|
pub async fn get_summary(State(pool): State<DbPool>, claims: Claims) -> impl IntoResponse {
|
||||||
State(pool): State<DbPool>,
|
|
||||||
claims: Claims,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let user_id = Uuid::parse_str(&claims.sub).unwrap();
|
let user_id = Uuid::parse_str(&claims.sub).unwrap();
|
||||||
|
|
||||||
let records = sqlx::query_as::<_, TelemetryRow>(
|
let records = sqlx::query_as::<_, TelemetryRow>(
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use axum::response::Html;
|
use axum::response::Html;
|
||||||
|
|
||||||
pub async fn swagger_ui() -> Html<String> {
|
pub async fn swagger_ui() -> Html<String> {
|
||||||
Html(format!(r#"<!DOCTYPE html>
|
Html(format!(
|
||||||
|
r#"<!DOCTYPE html>
|
||||||
<html lang="es">
|
<html lang="es">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
@@ -36,7 +37,8 @@ pub async fn swagger_ui() -> Html<String> {
|
|||||||
}});
|
}});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>"#))
|
</html>"#
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn openapi_yaml() -> axum::response::Response {
|
pub async fn openapi_yaml() -> axum::response::Response {
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ pub async fn list_downtime(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin permiso para ver paradas de este activo".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para ver paradas de este activo".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let downtime: Vec<OperationDowntime> = sqlx::query_as::<Postgres, OperationDowntime>(
|
let downtime: Vec<OperationDowntime> = sqlx::query_as::<Postgres, OperationDowntime>(
|
||||||
@@ -78,7 +80,9 @@ pub async fn create_downtime(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin permiso para registrar paradas en este activo".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para registrar paradas en este activo".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let downtime: OperationDowntime = sqlx::query_as::<Postgres, OperationDowntime>(
|
let downtime: OperationDowntime = sqlx::query_as::<Postgres, OperationDowntime>(
|
||||||
|
|||||||
@@ -2,18 +2,18 @@
|
|||||||
//!
|
//!
|
||||||
//! Este módulo centraliza la telemetría enviada por los agentes instalados en las plantas.
|
//! Este módulo centraliza la telemetría enviada por los agentes instalados en las plantas.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::Claims;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Json,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
|
||||||
};
|
};
|
||||||
|
use rumqttc::QoS;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use shared_lib::edge_models::*;
|
use shared_lib::edge_models::*;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use crate::auth::Claims;
|
|
||||||
use crate::AppState;
|
|
||||||
use rumqttc::QoS;
|
|
||||||
|
|
||||||
use crate::handlers::update_info::fetch_sha256_internal;
|
use crate::handlers::update_info::fetch_sha256_internal;
|
||||||
|
|
||||||
@@ -49,13 +49,13 @@ pub async fn trigger_ota_update(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve project_id from agent_id (agents are keyed by project_id)
|
// Resolve project_id from agent_id (agents are keyed by project_id)
|
||||||
let project_id: Uuid = sqlx::query_scalar(
|
let project_id: Uuid = sqlx::query_scalar("SELECT id FROM edge_projects WHERE id = $1")
|
||||||
"SELECT id FROM edge_projects WHERE id = $1"
|
|
||||||
)
|
|
||||||
.bind(agent_id)
|
.bind(agent_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| crate::errors::AppError::NotFound(format!("Agent/project {} not found", agent_id)))?;
|
.ok_or_else(|| {
|
||||||
|
crate::errors::AppError::NotFound(format!("Agent/project {} not found", agent_id))
|
||||||
|
})?;
|
||||||
|
|
||||||
// Resolve target version: explicit request → AGENT_LATEST_VERSION env → error
|
// Resolve target version: explicit request → AGENT_LATEST_VERSION env → error
|
||||||
let target_version = match req.version.as_deref() {
|
let target_version = match req.version.as_deref() {
|
||||||
@@ -71,8 +71,8 @@ pub async fn trigger_ota_update(
|
|||||||
|
|
||||||
// Build the download URL pointing to this backend's proxy endpoint.
|
// Build the download URL pointing to this backend's proxy endpoint.
|
||||||
// The agent makes ONE outbound HTTPS GET using this URL — no stored credentials needed.
|
// The agent makes ONE outbound HTTPS GET using this URL — no stored credentials needed.
|
||||||
let api_base = std::env::var("API_BASE_URL")
|
let api_base =
|
||||||
.unwrap_or_else(|_| "https://api.omnioil.com".to_string());
|
std::env::var("API_BASE_URL").unwrap_or_else(|_| "https://api.omnioil.com".to_string());
|
||||||
let download_url = format!(
|
let download_url = format!(
|
||||||
"{}/api/edge/download-agent?version={}&asset_type={}",
|
"{}/api/edge/download-agent?version={}&asset_type={}",
|
||||||
api_base.trim_end_matches('/'),
|
api_base.trim_end_matches('/'),
|
||||||
@@ -94,13 +94,17 @@ pub async fn trigger_ota_update(
|
|||||||
"issued_at": chrono::Utc::now().to_rfc3339(),
|
"issued_at": chrono::Utc::now().to_rfc3339(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let payload_bytes = serde_json::to_vec(&command_payload)
|
let payload_bytes = serde_json::to_vec(&command_payload).map_err(|e| {
|
||||||
.map_err(|e| crate::errors::AppError::Internal(anyhow::anyhow!("Serialization error: {}", e)))?;
|
crate::errors::AppError::Internal(anyhow::anyhow!("Serialization error: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
state.mqtt_client
|
state
|
||||||
|
.mqtt_client
|
||||||
.publish(&topic, QoS::AtLeastOnce, false, payload_bytes)
|
.publish(&topic, QoS::AtLeastOnce, false, payload_bytes)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| crate::errors::AppError::Internal(anyhow::anyhow!("MQTT publish failed: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
crate::errors::AppError::Internal(anyhow::anyhow!("MQTT publish failed: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"📡 OTA command published to {} by {} (target: v{}, sha256: {})",
|
"📡 OTA command published to {} by {} (target: v{}, sha256: {})",
|
||||||
@@ -224,7 +228,9 @@ pub async fn get_agent_health(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin acceso al estado de este proyecto".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin acceso al estado de este proyecto".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Obtener último log de health para saber si sigue comunicando
|
// Obtener último log de health para saber si sigue comunicando
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
//! Handlers para activos (Pozos, Tanques, etc.)
|
//! Handlers para activos (Pozos, Tanques, etc.)
|
||||||
|
|
||||||
|
use crate::auth::Claims;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Json,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use shared_lib::models::{EdgeAsset, CreateEdgeAssetRequest};
|
use shared_lib::models::{CreateEdgeAssetRequest, EdgeAsset};
|
||||||
use crate::auth::Claims;
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct UpdateAssetRequest {
|
pub struct UpdateAssetRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
pub metadata: Option<Value>,
|
pub metadata: Option<Value>,
|
||||||
|
pub last_calibration_date: Option<chrono::NaiveDate>,
|
||||||
|
pub next_calibration_date: Option<chrono::NaiveDate>,
|
||||||
|
pub calibration_certificate_url: Option<String>,
|
||||||
pub external_api_url: Option<String>,
|
pub external_api_url: Option<String>,
|
||||||
pub external_api_key: Option<String>,
|
pub external_api_key: Option<String>,
|
||||||
pub is_collector_enabled: Option<bool>,
|
pub is_collector_enabled: Option<bool>,
|
||||||
@@ -41,11 +44,13 @@ pub async fn list_assets_by_project(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin acceso a los activos de este proyecto".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin acceso a los activos de este proyecto".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let assets = sqlx::query_as::<_, EdgeAsset>(
|
let assets = sqlx::query_as::<_, EdgeAsset>(
|
||||||
"SELECT * FROM edge_assets WHERE project_id = $1 ORDER BY created_at DESC"
|
"SELECT * FROM edge_assets WHERE project_id = $1 ORDER BY created_at DESC",
|
||||||
)
|
)
|
||||||
.bind(project_id)
|
.bind(project_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
@@ -71,13 +76,19 @@ pub async fn create_asset(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin permiso para crear activos en este proyecto".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para crear activos en este proyecto".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let asset = sqlx::query_as::<_, EdgeAsset>(
|
let asset = sqlx::query_as::<_, EdgeAsset>(
|
||||||
r#"INSERT INTO edge_assets (project_id, code, name, asset_type, metadata, external_api_url, external_api_key, is_collector_enabled)
|
r#"INSERT INTO edge_assets (
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
project_id, code, name, asset_type, metadata,
|
||||||
RETURNING *"#
|
external_api_url, external_api_key, is_collector_enabled,
|
||||||
|
last_calibration_date, next_calibration_date, calibration_certificate_url
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING *"#,
|
||||||
)
|
)
|
||||||
.bind(req.project_id)
|
.bind(req.project_id)
|
||||||
.bind(&req.code)
|
.bind(&req.code)
|
||||||
@@ -87,6 +98,9 @@ pub async fn create_asset(
|
|||||||
.bind(req.external_api_url)
|
.bind(req.external_api_url)
|
||||||
.bind(req.external_api_key)
|
.bind(req.external_api_key)
|
||||||
.bind(req.is_collector_enabled.unwrap_or(false))
|
.bind(req.is_collector_enabled.unwrap_or(false))
|
||||||
|
.bind(req.last_calibration_date)
|
||||||
|
.bind(req.next_calibration_date)
|
||||||
|
.bind(&req.calibration_certificate_url)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -147,15 +161,18 @@ pub async fn update_asset(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin permiso para actualizar este activo".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para actualizar este activo".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated = sqlx::query_as::<_, EdgeAsset>(
|
let updated = sqlx::query_as::<_, EdgeAsset>(
|
||||||
r#"UPDATE edge_assets
|
r#"UPDATE edge_assets
|
||||||
SET name = $1, is_active = $2, metadata = $3,
|
SET name = $1, is_active = $2, metadata = $3,
|
||||||
external_api_url = $4, external_api_key = $5, is_collector_enabled = $6,
|
external_api_url = $4, external_api_key = $5, is_collector_enabled = $6,
|
||||||
|
last_calibration_date = $7, next_calibration_date = $8, calibration_certificate_url = $9,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $7
|
WHERE id = $10
|
||||||
RETURNING *"#
|
RETURNING *"#
|
||||||
)
|
)
|
||||||
.bind(&req.name)
|
.bind(&req.name)
|
||||||
@@ -164,6 +181,9 @@ pub async fn update_asset(
|
|||||||
.bind(req.external_api_url)
|
.bind(req.external_api_url)
|
||||||
.bind(req.external_api_key)
|
.bind(req.external_api_key)
|
||||||
.bind(req.is_collector_enabled.unwrap_or(false))
|
.bind(req.is_collector_enabled.unwrap_or(false))
|
||||||
|
.bind(req.last_calibration_date)
|
||||||
|
.bind(req.next_calibration_date)
|
||||||
|
.bind(&req.calibration_certificate_url)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await?
|
.await?
|
||||||
@@ -183,8 +203,9 @@ pub async fn list_tanks_by_project(
|
|||||||
claims: Claims,
|
claims: Claims,
|
||||||
Query(params): Query<TankQueryParams>,
|
Query(params): Query<TankQueryParams>,
|
||||||
) -> Result<Json<Vec<EdgeAsset>>, AppError> {
|
) -> Result<Json<Vec<EdgeAsset>>, AppError> {
|
||||||
let project_id = params.project_id
|
let project_id = params.project_id.ok_or(AppError::BadRequest(
|
||||||
.ok_or(AppError::BadRequest("project_id es obligatorio".to_string()))?;
|
"project_id es obligatorio".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
// Validar acceso al proyecto
|
// Validar acceso al proyecto
|
||||||
let user_id = Uuid::parse_str(&claims.sub)
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
@@ -197,7 +218,9 @@ pub async fn list_tanks_by_project(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin acceso a los tanques de este proyecto".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin acceso a los tanques de este proyecto".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let tanks = sqlx::query_as::<_, EdgeAsset>(
|
let tanks = sqlx::query_as::<_, EdgeAsset>(
|
||||||
@@ -234,7 +257,9 @@ pub async fn delete_asset(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin permiso para eliminar este activo".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para eliminar este activo".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query("DELETE FROM edge_assets WHERE id = $1")
|
sqlx::query("DELETE FROM edge_assets WHERE id = $1")
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ use axum::{
|
|||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
|
use chrono;
|
||||||
use shared_lib::edge_models::*;
|
use shared_lib::edge_models::*;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono;
|
|
||||||
|
|
||||||
/// GET /api/edge/projects/:project_id/deployments
|
/// GET /api/edge/projects/:project_id/deployments
|
||||||
///
|
///
|
||||||
@@ -129,7 +129,11 @@ pub async fn trigger_build(
|
|||||||
|
|
||||||
if let Ok(payload) = serde_json::to_string(&event) {
|
if let Ok(payload) = serde_json::to_string(&event) {
|
||||||
let mqtt_clone = mqtt.clone();
|
let mqtt_clone = mqtt.clone();
|
||||||
tracing::info!("📡 Enviando señal de construcción para despliegue {}: {}", deployment.id, payload);
|
tracing::info!(
|
||||||
|
"📡 Enviando señal de construcción para despliegue {}: {}",
|
||||||
|
deployment.id,
|
||||||
|
payload
|
||||||
|
);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = mqtt_clone
|
let _ = mqtt_clone
|
||||||
.publish(
|
.publish(
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
//! Handlers para CRUD de dispositivos Edge.
|
//! Handlers para CRUD de dispositivos Edge.
|
||||||
|
|
||||||
|
use crate::auth::Claims;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Json,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
|
||||||
};
|
};
|
||||||
|
use shared_lib::edge_models::*;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use shared_lib::edge_models::*;
|
|
||||||
use crate::auth::Claims;
|
|
||||||
|
|
||||||
/// GET /api/edge/assets/:asset_id/devices — Listar dispositivos de un activo.
|
/// GET /api/edge/assets/:asset_id/devices — Listar dispositivos de un activo.
|
||||||
pub async fn list_devices(
|
pub async fn list_devices(
|
||||||
@@ -21,7 +21,7 @@ pub async fn list_devices(
|
|||||||
let access = sqlx::query(
|
let access = sqlx::query(
|
||||||
r#"SELECT 1 FROM edge_assets ea
|
r#"SELECT 1 FROM edge_assets ea
|
||||||
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
)
|
)
|
||||||
.bind(asset_id)
|
.bind(asset_id)
|
||||||
.bind(Uuid::parse_str(&claims.sub).unwrap())
|
.bind(Uuid::parse_str(&claims.sub).unwrap())
|
||||||
@@ -30,12 +30,15 @@ pub async fn list_devices(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Access denied to this asset's devices".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Access denied to this asset's devices".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let devices = sqlx::query_as::<_, EdgeDevice>(
|
let devices = sqlx::query_as::<_, EdgeDevice>(
|
||||||
"SELECT * FROM edge_devices WHERE asset_id = $1 ORDER BY name"
|
"SELECT * FROM edge_devices WHERE asset_id = $1 ORDER BY name",
|
||||||
)
|
)
|
||||||
.bind(asset_id)
|
.bind(asset_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
@@ -57,7 +60,7 @@ pub async fn create_device(
|
|||||||
let access = sqlx::query(
|
let access = sqlx::query(
|
||||||
r#"SELECT 1 FROM edge_assets ea
|
r#"SELECT 1 FROM edge_assets ea
|
||||||
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
)
|
)
|
||||||
.bind(asset_id)
|
.bind(asset_id)
|
||||||
.bind(Uuid::parse_str(&claims.sub).unwrap())
|
.bind(Uuid::parse_str(&claims.sub).unwrap())
|
||||||
@@ -66,14 +69,17 @@ pub async fn create_device(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Access denied to create devices for this asset".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Access denied to create devices for this asset".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let device = sqlx::query_as::<_, EdgeDevice>(
|
let device = sqlx::query_as::<_, EdgeDevice>(
|
||||||
r#"INSERT INTO edge_devices (asset_id, name, protocol, host, port, protocol_config)
|
r#"INSERT INTO edge_devices (asset_id, name, protocol, host, port, protocol_config)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING *"#
|
RETURNING *"#,
|
||||||
)
|
)
|
||||||
.bind(asset_id)
|
.bind(asset_id)
|
||||||
.bind(&req.name)
|
.bind(&req.name)
|
||||||
@@ -101,7 +107,7 @@ pub async fn update_device(
|
|||||||
r#"SELECT 1 FROM edge_devices ed
|
r#"SELECT 1 FROM edge_devices ed
|
||||||
JOIN edge_assets ea ON ed.asset_id = ea.id
|
JOIN edge_assets ea ON ed.asset_id = ea.id
|
||||||
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
|
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(Uuid::parse_str(&claims.sub).unwrap())
|
.bind(Uuid::parse_str(&claims.sub).unwrap())
|
||||||
@@ -110,7 +116,10 @@ pub async fn update_device(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Access denied to this device".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Access denied to this device".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +132,7 @@ pub async fn update_device(
|
|||||||
protocol_config = COALESCE($6, protocol_config),
|
protocol_config = COALESCE($6, protocol_config),
|
||||||
is_enabled = COALESCE($7, is_enabled)
|
is_enabled = COALESCE($7, is_enabled)
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *"#
|
RETURNING *"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(&req.name)
|
.bind(&req.name)
|
||||||
@@ -152,7 +161,7 @@ pub async fn delete_device(
|
|||||||
r#"SELECT 1 FROM edge_devices ed
|
r#"SELECT 1 FROM edge_devices ed
|
||||||
JOIN edge_assets ea ON ed.asset_id = ea.id
|
JOIN edge_assets ea ON ed.asset_id = ea.id
|
||||||
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
|
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(Uuid::parse_str(&claims.sub).unwrap())
|
.bind(Uuid::parse_str(&claims.sub).unwrap())
|
||||||
@@ -161,7 +170,10 @@ pub async fn delete_device(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Access denied to delete this device".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Access denied to delete this device".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,18 +233,25 @@ pub async fn trigger_opcua_scan(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "Device not found".to_string()))?;
|
.ok_or_else(|| (StatusCode::NOT_FOUND, "Device not found".to_string()))?;
|
||||||
|
|
||||||
let protocol: String = row.try_get("protocol")
|
let protocol: String = row
|
||||||
|
.try_get("protocol")
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
let host: String = row.try_get("host")
|
let host: String = row
|
||||||
|
.try_get("host")
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
let port: i32 = row.try_get("port")
|
let port: i32 = row
|
||||||
|
.try_get("port")
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
let project_id: Uuid = row.try_get("project_id")
|
let project_id: Uuid = row
|
||||||
|
.try_get("project_id")
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Verify this is an OPC UA device
|
// Verify this is an OPC UA device
|
||||||
if protocol != "OPC_UA" {
|
if protocol != "OPC_UA" {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Device protocol is not OPC_UA".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Device protocol is not OPC_UA".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
// Check access
|
||||||
@@ -281,17 +300,25 @@ pub async fn trigger_opcua_scan(
|
|||||||
"issued_at": chrono::Utc::now().to_rfc3339(),
|
"issued_at": chrono::Utc::now().to_rfc3339(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let payload = serde_json::to_vec(&cmd)
|
let payload =
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
serde_json::to_vec(&cmd).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
state.mqtt_client
|
state
|
||||||
|
.mqtt_client
|
||||||
.publish(&topic, QoS::AtLeastOnce, false, payload)
|
.publish(&topic, QoS::AtLeastOnce, false, payload)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("MQTT publish failed: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("MQTT publish failed: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"🔍 OPC UA scan triggered for device {} (project {}). Scan ID: {}",
|
"🔍 OPC UA scan triggered for device {} (project {}). Scan ID: {}",
|
||||||
device_id, project_id, scan_id
|
device_id,
|
||||||
|
project_id,
|
||||||
|
scan_id
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
@@ -323,7 +350,7 @@ pub async fn get_opcua_scan_result(
|
|||||||
r#"SELECT 1 FROM edge_devices ed
|
r#"SELECT 1 FROM edge_devices ed
|
||||||
JOIN edge_assets ea ON ed.asset_id = ea.id
|
JOIN edge_assets ea ON ed.asset_id = ea.id
|
||||||
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
|
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
)
|
)
|
||||||
.bind(device_id)
|
.bind(device_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@@ -345,15 +372,20 @@ pub async fn get_opcua_scan_result(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "Scan not found".to_string()))?;
|
.ok_or_else(|| (StatusCode::NOT_FOUND, "Scan not found".to_string()))?;
|
||||||
|
|
||||||
let status: String = row.try_get("status")
|
let status: String = row
|
||||||
|
.try_get("status")
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
let result: Option<serde_json::Value> = row.try_get("result")
|
let result: Option<serde_json::Value> = row
|
||||||
|
.try_get("result")
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
let error_message: Option<String> = row.try_get("error_message")
|
let error_message: Option<String> = row
|
||||||
|
.try_get("error_message")
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
let created_at: chrono::DateTime<chrono::Utc> = row.try_get("created_at")
|
let created_at: chrono::DateTime<chrono::Utc> = row
|
||||||
|
.try_get("created_at")
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
let expires_at: chrono::DateTime<chrono::Utc> = row.try_get("expires_at")
|
let expires_at: chrono::DateTime<chrono::Utc> = row
|
||||||
|
.try_get("expires_at")
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
//! Handlers para CRUD de proyectos Edge.
|
//! Handlers para CRUD de proyectos Edge.
|
||||||
|
|
||||||
|
use crate::auth::Claims;
|
||||||
|
use crate::handlers::anh_reports::ensure_default_anh_schedule_for_confirmed_contract;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use shared_lib::edge_models::*;
|
|
||||||
use crate::auth::Claims;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
use chrono;
|
use chrono;
|
||||||
|
use shared_lib::edge_models::*;
|
||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// GET /api/edge/projects — Listar todos los proyectos.
|
/// GET /api/edge/projects — Listar todos los proyectos.
|
||||||
pub async fn list_projects(
|
pub async fn list_projects(
|
||||||
@@ -22,7 +23,10 @@ pub async fn list_projects(
|
|||||||
WHERE upa.user_id = $1 AND upa.is_active = true
|
WHERE upa.user_id = $1 AND upa.is_active = true
|
||||||
ORDER BY p.created_at DESC"#,
|
ORDER BY p.created_at DESC"#,
|
||||||
)
|
)
|
||||||
.bind(Uuid::parse_str(&claims.sub).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?)
|
.bind(
|
||||||
|
Uuid::parse_str(&claims.sub)
|
||||||
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?,
|
||||||
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
@@ -39,9 +43,15 @@ pub async fn create_project(
|
|||||||
) -> Result<(StatusCode, Json<EdgeProject>), (StatusCode, String)> {
|
) -> Result<(StatusCode, Json<EdgeProject>), (StatusCode, String)> {
|
||||||
// Solo admins pueden crear proyectos globales
|
// Solo admins pueden crear proyectos globales
|
||||||
if claims.role != "admin" {
|
if claims.role != "admin" {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only admins can create projects".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only admins can create projects".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
let mut tx = pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let project = sqlx::query_as::<_, EdgeProject>(
|
let project = sqlx::query_as::<_, EdgeProject>(
|
||||||
r#"INSERT INTO edge_projects (name, client, operator_name, contract_number, description, metadata)
|
r#"INSERT INTO edge_projects (name, client, operator_name, contract_number, description, metadata)
|
||||||
@@ -59,7 +69,21 @@ pub async fn create_project(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Autolink al creador as owner
|
// Autolink al creador as owner
|
||||||
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID in token".to_string()))?;
|
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Invalid user ID in token".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
ensure_default_anh_schedule_for_confirmed_contract(
|
||||||
|
&mut *tx,
|
||||||
|
&req.operator_name,
|
||||||
|
&req.contract_number,
|
||||||
|
Some(user_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO user_project_access (user_id, project_id, project_role) VALUES ($1, $2, 'owner')"
|
"INSERT INTO user_project_access (user_id, project_id, project_role) VALUES ($1, $2, 'owner')"
|
||||||
@@ -70,7 +94,9 @@ pub async fn create_project(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// ─── Dispatch Domain Event via MQTT (Internal Messaging) ──────────────────────────────
|
// ─── Dispatch Domain Event via MQTT (Internal Messaging) ──────────────────────────────
|
||||||
let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
|
let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
|
||||||
@@ -102,14 +128,41 @@ pub async fn create_project(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::audit::log(&pool, crate::audit::AuditEntry::new("project.create")
|
crate::audit::log(
|
||||||
|
&pool,
|
||||||
|
crate::audit::AuditEntry::new("project.create")
|
||||||
.with_user(user_id, &claims.email)
|
.with_user(user_id, &claims.email)
|
||||||
.with_resource(format!("project:{}", project.id))
|
.with_resource(format!("project:{}", project.id)),
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(project)))
|
Ok((StatusCode::CREATED, Json(project)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn create_project_wires_default_anh_schedule_before_commit() {
|
||||||
|
let source = include_str!("edge_projects.rs");
|
||||||
|
let schedule_call = source
|
||||||
|
.find("ensure_default_anh_schedule_for_confirmed_contract(\n &mut *tx")
|
||||||
|
.expect("create_project should call ANH schedule provisioning helper");
|
||||||
|
let user_id_parse = source
|
||||||
|
.find("let user_id = Uuid::parse_str(&claims.sub)")
|
||||||
|
.expect("create_project should parse the authenticated user id");
|
||||||
|
let access_insert = source
|
||||||
|
.find("INSERT INTO user_project_access")
|
||||||
|
.expect("create_project should create owner access");
|
||||||
|
let commit = source
|
||||||
|
.find("tx.commit()")
|
||||||
|
.expect("create_project should commit transaction");
|
||||||
|
|
||||||
|
assert!(user_id_parse < schedule_call);
|
||||||
|
assert!(schedule_call < commit);
|
||||||
|
assert!(access_insert < commit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/edge/projects/:id — Detalle de un proyecto.
|
/// GET /api/edge/projects/:id — Detalle de un proyecto.
|
||||||
pub async fn get_project(
|
pub async fn get_project(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
@@ -125,7 +178,10 @@ pub async fn get_project(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Access denied to this project".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Access denied to this project".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
|
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
|
||||||
@@ -154,7 +210,10 @@ pub async fn update_project(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only project owners or authorized admins can update project details".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only project owners or authorized admins can update project details".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let project = sqlx::query_as::<_, EdgeProject>(
|
let project = sqlx::query_as::<_, EdgeProject>(
|
||||||
@@ -196,7 +255,10 @@ pub async fn delete_project(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only project owners can delete projects".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only project owners can delete projects".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = sqlx::query("DELETE FROM edge_projects WHERE id = $1")
|
let result = sqlx::query("DELETE FROM edge_projects WHERE id = $1")
|
||||||
@@ -227,7 +289,10 @@ pub async fn get_project_full_config(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Access denied to this project".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Access denied to this project".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
use shared_lib::models::EdgeAsset;
|
use shared_lib::models::EdgeAsset;
|
||||||
|
|
||||||
@@ -300,7 +365,10 @@ pub async fn download_installer(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Access denied to this project installer".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Access denied to this project installer".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
|
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -323,13 +391,12 @@ pub async fn download_installer(
|
|||||||
return Err((
|
return Err((
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("Invalid installer key format: '{}'", installer_key),
|
format!("Invalid installer key format: '{}'", installer_key),
|
||||||
))
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// URL interna de MinIO (solo accesible desde la red Docker)
|
// URL interna de MinIO (solo accesible desde la red Docker)
|
||||||
let endpoint_url =
|
let endpoint_url = std::env::var("MINIO_ENDPOINT_URL").expect("MINIO_ENDPOINT_URL must be set");
|
||||||
std::env::var("MINIO_ENDPOINT_URL").expect("MINIO_ENDPOINT_URL must be set");
|
|
||||||
|
|
||||||
// Configurar credenciales explícitas
|
// Configurar credenciales explícitas
|
||||||
let access_key = std::env::var("AWS_ACCESS_KEY_ID")
|
let access_key = std::env::var("AWS_ACCESS_KEY_ID")
|
||||||
@@ -359,7 +426,12 @@ pub async fn download_installer(
|
|||||||
.build();
|
.build();
|
||||||
let client = aws_sdk_s3::Client::from_conf(s3_config);
|
let client = aws_sdk_s3::Client::from_conf(s3_config);
|
||||||
|
|
||||||
tracing::info!("📥 Intentando descargar desde S3 bucket: {}, key: {} en {}", bucket_name, key_name, endpoint_url);
|
tracing::info!(
|
||||||
|
"📥 Intentando descargar desde S3 bucket: {}, key: {} en {}",
|
||||||
|
bucket_name,
|
||||||
|
key_name,
|
||||||
|
endpoint_url
|
||||||
|
);
|
||||||
|
|
||||||
let get_res = client
|
let get_res = client
|
||||||
.get_object()
|
.get_object()
|
||||||
@@ -371,7 +443,12 @@ pub async fn download_installer(
|
|||||||
let get_req = match get_res {
|
let get_req = match get_res {
|
||||||
Ok(res) => res,
|
Ok(res) => res,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("❌ Error de S3 al recuperar archivo (bucket: {}, key: {}): {:?}", bucket_name, key_name, e);
|
tracing::error!(
|
||||||
|
"❌ Error de S3 al recuperar archivo (bucket: {}, key: {}): {:?}",
|
||||||
|
bucket_name,
|
||||||
|
key_name,
|
||||||
|
e
|
||||||
|
);
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("Failed to retrieve file from S3 ({}): {}", key_name, e),
|
format!("Failed to retrieve file from S3 ({}): {}", key_name, e),
|
||||||
@@ -415,7 +492,10 @@ pub async fn deploy_project(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Access denied to deploy this project".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Access denied to deploy this project".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Obtener config FULL (Project + Assets + Devices + Variables)
|
// 2. Obtener config FULL (Project + Assets + Devices + Variables)
|
||||||
@@ -457,7 +537,10 @@ pub async fn deploy_project(
|
|||||||
|
|
||||||
device_configs.push(DeviceConfig { device, variables });
|
device_configs.push(DeviceConfig { device, variables });
|
||||||
}
|
}
|
||||||
asset_configs.push(AssetConfig { asset, devices: device_configs });
|
asset_configs.push(AssetConfig {
|
||||||
|
asset,
|
||||||
|
devices: device_configs,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let full_config = ProjectConfig {
|
let full_config = ProjectConfig {
|
||||||
@@ -467,16 +550,28 @@ pub async fn deploy_project(
|
|||||||
|
|
||||||
// 3. Publicar en MQTT para que el sistema de despliegue lo procese
|
// 3. Publicar en MQTT para que el sistema de despliegue lo procese
|
||||||
// Tópico: omnioil/control/deploy/{project_id}
|
// Tópico: omnioil/control/deploy/{project_id}
|
||||||
let payload = serde_json::to_string(&full_config)
|
let payload = serde_json::to_string(&full_config).map_err(|e| {
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize config: {}", e)))?;
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to serialize config: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let topic = format!("omnioil/control/deploy/{}", id);
|
let topic = format!("omnioil/control/deploy/{}", id);
|
||||||
|
|
||||||
mqtt.publish(topic, rumqttc::QoS::AtLeastOnce, false, payload)
|
mqtt.publish(topic, rumqttc::QoS::AtLeastOnce, false, payload)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("MQTT Publish failed: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("MQTT Publish failed: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
tracing::info!("🚀 Despliegue solicitado para proyecto: {}. Configuración publicada en MQTT.", id);
|
tracing::info!(
|
||||||
|
"🚀 Despliegue solicitado para proyecto: {}. Configuración publicada en MQTT.",
|
||||||
|
id
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"message": "Despliegue iniciado correctamente",
|
"message": "Despliegue iniciado correctamente",
|
||||||
@@ -485,15 +580,12 @@ pub async fn deploy_project(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/edge/projects/:id/linux-install — Genera script de instalación Linux.
|
/// POST /api/edge/projects/:id/linux-install-token — Genera un token de descarga temporal de un solo uso.
|
||||||
///
|
pub async fn generate_download_token(
|
||||||
/// Crea un nuevo token de provisioning y devuelve un script bash listo para
|
|
||||||
/// ejecutar en el dispositivo de campo: curl -sSL <url> | sudo bash
|
|
||||||
pub async fn linux_install_script(
|
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
claims: Claims,
|
claims: Claims,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Response, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
// Verificar acceso al proyecto
|
// Verificar acceso al proyecto
|
||||||
let access = sqlx::query(
|
let access = sqlx::query(
|
||||||
"SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true"
|
"SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true"
|
||||||
@@ -505,12 +597,135 @@ pub async fn linux_install_script(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Acceso denegado a este proyecto".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Acceso denegado a este proyecto".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let download_token = uuid::Uuid::new_v4().to_string();
|
||||||
|
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(10);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO agent_download_tokens (token, project_id, expires_at) VALUES ($1, $2, $3)",
|
||||||
|
)
|
||||||
|
.bind(&download_token)
|
||||||
|
.bind(id)
|
||||||
|
.bind(expires_at)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "token": download_token })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct InstallScriptQuery {
|
||||||
|
token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/edge/projects/:id/linux-install — Genera script de instalación Linux.
|
||||||
|
///
|
||||||
|
/// Crea un nuevo token de provisioning y devuelve un script bash listo para
|
||||||
|
/// ejecutar en el dispositivo de campo. Puede autenticarse con sesión activa (JWT)
|
||||||
|
/// o mediante un token de descarga temporal de un solo uso en la query param: ?token=...
|
||||||
|
pub async fn linux_install_script(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Query(query): Query<InstallScriptQuery>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
|
// Intentar extraer y verificar claims desde los headers de forma manual
|
||||||
|
let claims_opt = if let Some(auth_header) = headers
|
||||||
|
.get(axum::http::header::AUTHORIZATION)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
{
|
||||||
|
if auth_header.starts_with("Bearer ") {
|
||||||
|
let token = &auth_header[7..];
|
||||||
|
if let Ok(token_data) = crate::auth::verify_jwt(token) {
|
||||||
|
let claims = token_data.claims;
|
||||||
|
if let Ok(user_id) = uuid::Uuid::parse_str(&claims.sub) {
|
||||||
|
let user_active: Option<bool> =
|
||||||
|
sqlx::query_scalar("SELECT is_active FROM users WHERE id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
if user_active == Some(true) {
|
||||||
|
Some(claims)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let project_id = if let Some(claims) = claims_opt {
|
||||||
|
// Verificar acceso al proyecto
|
||||||
|
let access = sqlx::query(
|
||||||
|
"SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true"
|
||||||
|
)
|
||||||
|
.bind(Uuid::parse_str(&claims.sub).unwrap())
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if access.is_none() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Acceso denegado a este proyecto".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
id
|
||||||
|
} else {
|
||||||
|
// Validar por token de descarga temporal
|
||||||
|
let token_str = query.token.ok_or((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Token de descarga o sesión JWT faltante".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let token_row = sqlx::query(
|
||||||
|
"SELECT project_id FROM agent_download_tokens WHERE token = $1 AND expires_at > NOW()",
|
||||||
|
)
|
||||||
|
.bind(&token_str)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Token de descarga inválido o expirado".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let token_project_id: Uuid = token_row.get(0);
|
||||||
|
if token_project_id != id {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"El token no corresponde a este proyecto".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar el token para que sea de un solo uso
|
||||||
|
let _ = sqlx::query("DELETE FROM agent_download_tokens WHERE token = $1")
|
||||||
|
.bind(&token_str)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
id
|
||||||
|
};
|
||||||
|
|
||||||
// Verificar que el proyecto existe
|
// Verificar que el proyecto existe
|
||||||
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
|
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
|
||||||
.bind(id)
|
.bind(project_id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
@@ -525,7 +740,7 @@ pub async fn linux_install_script(
|
|||||||
r#"INSERT INTO agent_provisioning_tokens (project_id, token, expires_at)
|
r#"INSERT INTO agent_provisioning_tokens (project_id, token, expires_at)
|
||||||
VALUES ($1, $2, $3)"#,
|
VALUES ($1, $2, $3)"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(project_id)
|
||||||
.bind(&provisioning_token)
|
.bind(&provisioning_token)
|
||||||
.bind(token_expires_at)
|
.bind(token_expires_at)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
@@ -533,7 +748,7 @@ pub async fn linux_install_script(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Crear/renovar usuario MQTT de provisioning temporal
|
// Crear/renovar usuario MQTT de provisioning temporal
|
||||||
let prov_mqtt_user = format!("provision:{}", id);
|
let prov_mqtt_user = format!("provision:{}", project_id);
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO mqtt_users (username, password_hash)
|
"INSERT INTO mqtt_users (username, password_hash)
|
||||||
VALUES ($1, crypt($2, gen_salt('bf', 10)))
|
VALUES ($1, crypt($2, gen_salt('bf', 10)))
|
||||||
@@ -547,8 +762,8 @@ pub async fn linux_install_script(
|
|||||||
|
|
||||||
// ACLs de provisioning
|
// ACLs de provisioning
|
||||||
let prov_acls = vec![
|
let prov_acls = vec![
|
||||||
(format!("provision/request/{}", id), 2i32),
|
(format!("provision/request/{}", project_id), 2i32),
|
||||||
(format!("provision/response/{}", id), 4i32),
|
(format!("provision/response/{}", project_id), 4i32),
|
||||||
];
|
];
|
||||||
for (topic, rw) in prov_acls {
|
for (topic, rw) in prov_acls {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -571,7 +786,7 @@ pub async fn linux_install_script(
|
|||||||
.unwrap_or_else(|_| "https://tu-servidor.omnioil.io".to_string());
|
.unwrap_or_else(|_| "https://tu-servidor.omnioil.io".to_string());
|
||||||
|
|
||||||
let script = generate_install_script(
|
let script = generate_install_script(
|
||||||
&id.to_string(),
|
&project_id.to_string(),
|
||||||
&provisioning_token,
|
&provisioning_token,
|
||||||
&mqtt_host,
|
&mqtt_host,
|
||||||
&mqtt_port,
|
&mqtt_port,
|
||||||
@@ -585,7 +800,10 @@ pub async fn linux_install_script(
|
|||||||
(header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8"),
|
(header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8"),
|
||||||
(
|
(
|
||||||
header::CONTENT_DISPOSITION,
|
header::CONTENT_DISPOSITION,
|
||||||
&format!("attachment; filename=\"install-omnioil-{}.sh\"", &id.to_string()[..8]),
|
&format!(
|
||||||
|
"attachment; filename=\"install-omnioil-{}.sh\"",
|
||||||
|
&project_id.to_string()[..8]
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
script,
|
script,
|
||||||
@@ -602,7 +820,8 @@ fn generate_install_script(
|
|||||||
server_url: &str,
|
server_url: &str,
|
||||||
project_name: &str,
|
project_name: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
format!(r#"#!/bin/bash
|
format!(
|
||||||
|
r#"#!/bin/bash
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# OmniOil Edge Agent — Instalador Linux
|
# OmniOil Edge Agent — Instalador Linux
|
||||||
# Proyecto: {project_name}
|
# Proyecto: {project_name}
|
||||||
@@ -744,11 +963,15 @@ pub async fn download_linux_binary(
|
|||||||
let arch_tag = match arch.as_str() {
|
let arch_tag = match arch.as_str() {
|
||||||
"x86_64" | "amd64" => "x86_64",
|
"x86_64" | "amd64" => "x86_64",
|
||||||
"aarch64" | "arm64" => "aarch64",
|
"aarch64" | "arm64" => "aarch64",
|
||||||
_ => return Err((StatusCode::BAD_REQUEST, format!("Arquitectura no soportada: {}", arch))),
|
_ => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("Arquitectura no soportada: {}", arch),
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let endpoint_url = std::env::var("MINIO_ENDPOINT_URL")
|
let endpoint_url = std::env::var("MINIO_ENDPOINT_URL").expect("MINIO_ENDPOINT_URL must be set");
|
||||||
.expect("MINIO_ENDPOINT_URL must be set");
|
|
||||||
let access_key = std::env::var("AWS_ACCESS_KEY_ID")
|
let access_key = std::env::var("AWS_ACCESS_KEY_ID")
|
||||||
.or_else(|_| std::env::var("MINIO_USER"))
|
.or_else(|_| std::env::var("MINIO_USER"))
|
||||||
.expect("MinIO credentials must be set");
|
.expect("MinIO credentials must be set");
|
||||||
@@ -756,9 +979,8 @@ pub async fn download_linux_binary(
|
|||||||
.or_else(|_| std::env::var("MINIO_PASSWORD"))
|
.or_else(|_| std::env::var("MINIO_PASSWORD"))
|
||||||
.expect("MinIO credentials must be set");
|
.expect("MinIO credentials must be set");
|
||||||
|
|
||||||
let credentials = aws_sdk_s3::config::Credentials::new(
|
let credentials =
|
||||||
access_key, secret_key, None, None, "minio",
|
aws_sdk_s3::config::Credentials::new(access_key, secret_key, None, None, "minio");
|
||||||
);
|
|
||||||
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
||||||
.region(aws_sdk_s3::config::Region::new("us-east-1"))
|
.region(aws_sdk_s3::config::Region::new("us-east-1"))
|
||||||
.endpoint_url(&endpoint_url)
|
.endpoint_url(&endpoint_url)
|
||||||
@@ -771,25 +993,38 @@ pub async fn download_linux_binary(
|
|||||||
let client = aws_sdk_s3::Client::from_conf(s3_config);
|
let client = aws_sdk_s3::Client::from_conf(s3_config);
|
||||||
|
|
||||||
let key = format!("agents/linux/{}/edge-agent", arch_tag);
|
let key = format!("agents/linux/{}/edge-agent", arch_tag);
|
||||||
let bucket = std::env::var("S3_INSTALLERS_BUCKET")
|
let bucket =
|
||||||
.unwrap_or_else(|_| "omnioil-releases".to_string());
|
std::env::var("S3_INSTALLERS_BUCKET").unwrap_or_else(|_| "omnioil-releases".to_string());
|
||||||
let get_res = client.get_object()
|
let get_res = client
|
||||||
|
.get_object()
|
||||||
.bucket(&bucket)
|
.bucket(&bucket)
|
||||||
.key(&key)
|
.key(&key)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::NOT_FOUND, format!("Binario Linux no disponible aún: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
format!("Binario Linux no disponible aún: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let bytes = get_res.body.collect().await
|
let bytes = get_res
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
[
|
[
|
||||||
(header::CONTENT_TYPE, "application/octet-stream"),
|
(header::CONTENT_TYPE, "application/octet-stream"),
|
||||||
(header::CONTENT_DISPOSITION, "attachment; filename=\"edge-agent\""),
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"edge-agent\"",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
bytes.into_bytes(),
|
bytes.into_bytes(),
|
||||||
).into_response())
|
)
|
||||||
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/edge/projects/:id/rebuild — Forzar la reconstrucción del instalador (MSI).
|
/// POST /api/edge/projects/:id/rebuild — Forzar la reconstrucción del instalador (MSI).
|
||||||
@@ -808,13 +1043,19 @@ pub async fn rebuild_installer(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if access.is_none() && claims.role != "admin" {
|
if access.is_none() && claims.role != "admin" {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only admins or authorized users can trigger installer rebuilds".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only admins or authorized users can trigger installer rebuilds".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
// Si no tiene acceso directo y es admin, tal vez queramos permitirlo en el futuro,
|
// Si no tiene acceso directo y es admin, tal vez queramos permitirlo en el futuro,
|
||||||
// pero el requerimiento es separacion TOTAL.
|
// pero el requerimiento es separacion TOTAL.
|
||||||
// Así que exigimos acceso directo.
|
// Así que exigimos acceso directo.
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Access denied to this project".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Access denied to this project".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Obtener datos básicos del proyecto
|
// 2. Obtener datos básicos del proyecto
|
||||||
@@ -842,14 +1083,31 @@ pub async fn rebuild_installer(
|
|||||||
timestamp: chrono::Utc::now(),
|
timestamp: chrono::Utc::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let payload = serde_json::to_string(&event)
|
let payload = serde_json::to_string(&event).map_err(|e| {
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize event: {}", e)))?;
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to serialize event: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
mqtt.publish("omnioil/events/project_created", rumqttc::QoS::AtLeastOnce, false, payload)
|
mqtt.publish(
|
||||||
|
"omnioil/events/project_created",
|
||||||
|
rumqttc::QoS::AtLeastOnce,
|
||||||
|
false,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("MQTT Publish failed: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("MQTT Publish failed: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
tracing::info!("🛠️ Reconstrucción de instalador solicitada para proyecto: {}", id);
|
tracing::info!(
|
||||||
|
"🛠️ Reconstrucción de instalador solicitada para proyecto: {}",
|
||||||
|
id
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"message": "Reconstrucción de instalador iniciada",
|
"message": "Reconstrucción de instalador iniciada",
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
//! Handlers para CRUD de variables Edge.
|
//! Handlers para CRUD de variables Edge.
|
||||||
|
|
||||||
|
use crate::auth::Claims;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Json,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
|
||||||
};
|
};
|
||||||
|
use shared_lib::edge_models::*;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use shared_lib::edge_models::*;
|
|
||||||
use crate::auth::Claims;
|
|
||||||
|
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ pub async fn list_variables(
|
|||||||
r#"SELECT 1 FROM edge_devices ed
|
r#"SELECT 1 FROM edge_devices ed
|
||||||
JOIN edge_assets ea ON ed.asset_id = ea.id
|
JOIN edge_assets ea ON ed.asset_id = ea.id
|
||||||
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
|
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
)
|
)
|
||||||
.bind(device_id)
|
.bind(device_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@@ -35,12 +35,14 @@ pub async fn list_variables(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin acceso a las variables de este dispositivo".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin acceso a las variables de este dispositivo".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let variables = sqlx::query_as::<_, EdgeVariable>(
|
let variables = sqlx::query_as::<_, EdgeVariable>(
|
||||||
"SELECT * FROM edge_variables WHERE device_id = $1 ORDER BY alias"
|
"SELECT * FROM edge_variables WHERE device_id = $1 ORDER BY alias",
|
||||||
)
|
)
|
||||||
.bind(device_id)
|
.bind(device_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
@@ -65,7 +67,7 @@ pub async fn create_variable(
|
|||||||
r#"SELECT 1 FROM edge_devices ed
|
r#"SELECT 1 FROM edge_devices ed
|
||||||
JOIN edge_assets ea ON ed.asset_id = ea.id
|
JOIN edge_assets ea ON ed.asset_id = ea.id
|
||||||
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
|
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
)
|
)
|
||||||
.bind(device_id)
|
.bind(device_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@@ -73,7 +75,9 @@ pub async fn create_variable(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin permiso para crear variables en este dispositivo".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para crear variables en este dispositivo".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +88,7 @@ pub async fn create_variable(
|
|||||||
byte_order, metadata
|
byte_order, metadata
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING *"#
|
RETURNING *"#,
|
||||||
)
|
)
|
||||||
.bind(device_id)
|
.bind(device_id)
|
||||||
.bind(&req.address)
|
.bind(&req.address)
|
||||||
@@ -120,7 +124,7 @@ pub async fn update_variable(
|
|||||||
JOIN edge_devices ed ON ev.device_id = ed.id
|
JOIN edge_devices ed ON ev.device_id = ed.id
|
||||||
JOIN edge_assets ea ON ed.asset_id = ea.id
|
JOIN edge_assets ea ON ed.asset_id = ea.id
|
||||||
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
WHERE ev.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
|
WHERE ev.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@@ -128,7 +132,9 @@ pub async fn update_variable(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin acceso a esta variable".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin acceso a esta variable".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +152,7 @@ pub async fn update_variable(
|
|||||||
metadata = COALESCE($11, metadata),
|
metadata = COALESCE($11, metadata),
|
||||||
is_enabled = COALESCE($12, is_enabled)
|
is_enabled = COALESCE($12, is_enabled)
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *"#
|
RETURNING *"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(&req.address)
|
.bind(&req.address)
|
||||||
@@ -183,7 +189,7 @@ pub async fn delete_variable(
|
|||||||
JOIN edge_devices ed ON ev.device_id = ed.id
|
JOIN edge_devices ed ON ev.device_id = ed.id
|
||||||
JOIN edge_assets ea ON ed.asset_id = ea.id
|
JOIN edge_assets ea ON ed.asset_id = ea.id
|
||||||
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
WHERE ev.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
|
WHERE ev.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@@ -191,7 +197,9 @@ pub async fn delete_variable(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin permiso para eliminar esta variable".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para eliminar esta variable".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ pub async fn health_check(State(pool): State<PgPool>) -> Json<HealthResponse> {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let overall = if db_status.status == "ok" { "ok" } else { "degraded" };
|
let overall = if db_status.status == "ok" {
|
||||||
|
"ok"
|
||||||
|
} else {
|
||||||
|
"degraded"
|
||||||
|
};
|
||||||
|
|
||||||
Json(HealthResponse {
|
Json(HealthResponse {
|
||||||
status: overall,
|
status: overall,
|
||||||
|
|||||||
844
services/backend-api/src/handlers/import.rs
Normal file
844
services/backend-api/src/handlers/import.rs
Normal file
@@ -0,0 +1,844 @@
|
|||||||
|
use crate::{AppState, auth::Claims};
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use csv::ReaderBuilder;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use sqlx::Row;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct ModbusTcpRecord {
|
||||||
|
#[serde(rename = "WellCode")]
|
||||||
|
pub well_code: String,
|
||||||
|
#[serde(rename = "DeviceName")]
|
||||||
|
pub device_name: String,
|
||||||
|
#[serde(rename = "Host")]
|
||||||
|
pub host: String,
|
||||||
|
#[serde(rename = "Port")]
|
||||||
|
pub port: i32,
|
||||||
|
#[serde(rename = "UnitId")]
|
||||||
|
pub unit_id: u8,
|
||||||
|
#[serde(rename = "VariableAlias")]
|
||||||
|
pub variable_alias: String,
|
||||||
|
#[serde(rename = "Address")]
|
||||||
|
pub address: String,
|
||||||
|
#[serde(rename = "DataType")]
|
||||||
|
pub data_type: String,
|
||||||
|
#[serde(rename = "LastCalibrationDate")]
|
||||||
|
pub last_calibration_date: Option<chrono::NaiveDate>,
|
||||||
|
#[serde(rename = "NextCalibrationDate")]
|
||||||
|
pub next_calibration_date: Option<chrono::NaiveDate>,
|
||||||
|
#[serde(rename = "CalibrationCertificateUrl")]
|
||||||
|
pub calibration_certificate_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct S7Record {
|
||||||
|
#[serde(rename = "WellCode")]
|
||||||
|
pub well_code: String,
|
||||||
|
#[serde(rename = "DeviceName")]
|
||||||
|
pub device_name: String,
|
||||||
|
#[serde(rename = "Host")]
|
||||||
|
pub host: String,
|
||||||
|
#[serde(rename = "Port")]
|
||||||
|
pub port: i32,
|
||||||
|
#[serde(rename = "Rack")]
|
||||||
|
pub rack: u16,
|
||||||
|
#[serde(rename = "Slot")]
|
||||||
|
pub slot: u16,
|
||||||
|
#[serde(rename = "VariableAlias")]
|
||||||
|
pub variable_alias: String,
|
||||||
|
#[serde(rename = "Address")]
|
||||||
|
pub address: String,
|
||||||
|
#[serde(rename = "DataType")]
|
||||||
|
pub data_type: String,
|
||||||
|
#[serde(rename = "LastCalibrationDate")]
|
||||||
|
pub last_calibration_date: Option<chrono::NaiveDate>,
|
||||||
|
#[serde(rename = "NextCalibrationDate")]
|
||||||
|
pub next_calibration_date: Option<chrono::NaiveDate>,
|
||||||
|
#[serde(rename = "CalibrationCertificateUrl")]
|
||||||
|
pub calibration_certificate_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct OpcUaRecord {
|
||||||
|
#[serde(rename = "WellCode")]
|
||||||
|
pub well_code: String,
|
||||||
|
#[serde(rename = "DeviceName")]
|
||||||
|
pub device_name: String,
|
||||||
|
#[serde(rename = "Endpoint")]
|
||||||
|
pub endpoint: String,
|
||||||
|
#[serde(rename = "VariableAlias")]
|
||||||
|
pub variable_alias: String,
|
||||||
|
#[serde(rename = "NodeId")]
|
||||||
|
pub node_id: String,
|
||||||
|
#[serde(rename = "LastCalibrationDate")]
|
||||||
|
pub last_calibration_date: Option<chrono::NaiveDate>,
|
||||||
|
#[serde(rename = "NextCalibrationDate")]
|
||||||
|
pub next_calibration_date: Option<chrono::NaiveDate>,
|
||||||
|
#[serde(rename = "CalibrationCertificateUrl")]
|
||||||
|
pub calibration_certificate_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct MqttSparkplugBRecord {
|
||||||
|
#[serde(rename = "WellCode")]
|
||||||
|
pub well_code: String,
|
||||||
|
#[serde(rename = "DeviceName")]
|
||||||
|
pub device_name: String,
|
||||||
|
#[serde(rename = "BrokerHost")]
|
||||||
|
pub broker_host: String,
|
||||||
|
#[serde(rename = "BrokerPort")]
|
||||||
|
pub broker_port: i32,
|
||||||
|
#[serde(rename = "GroupId")]
|
||||||
|
pub group_id: String,
|
||||||
|
#[serde(rename = "EdgeNodeId")]
|
||||||
|
pub edge_node_id: String,
|
||||||
|
#[serde(rename = "DeviceId")]
|
||||||
|
pub device_id: String,
|
||||||
|
#[serde(rename = "VariableAlias")]
|
||||||
|
pub variable_alias: String,
|
||||||
|
#[serde(rename = "SparkplugMetric")]
|
||||||
|
pub sparkplug_metric: String,
|
||||||
|
#[serde(rename = "LastCalibrationDate")]
|
||||||
|
pub last_calibration_date: Option<chrono::NaiveDate>,
|
||||||
|
#[serde(rename = "NextCalibrationDate")]
|
||||||
|
pub next_calibration_date: Option<chrono::NaiveDate>,
|
||||||
|
#[serde(rename = "CalibrationCertificateUrl")]
|
||||||
|
pub calibration_certificate_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct SqlDbRecord {
|
||||||
|
#[serde(rename = "WellCode")]
|
||||||
|
pub well_code: String,
|
||||||
|
#[serde(rename = "DeviceName")]
|
||||||
|
pub device_name: String,
|
||||||
|
#[serde(rename = "ConnectionString")]
|
||||||
|
pub connection_string: String,
|
||||||
|
#[serde(rename = "Query")]
|
||||||
|
pub query: String,
|
||||||
|
#[serde(rename = "VariableAlias")]
|
||||||
|
pub variable_alias: String,
|
||||||
|
#[serde(rename = "ColumnName")]
|
||||||
|
pub column_name: String,
|
||||||
|
#[serde(rename = "LastCalibrationDate")]
|
||||||
|
pub last_calibration_date: Option<chrono::NaiveDate>,
|
||||||
|
#[serde(rename = "NextCalibrationDate")]
|
||||||
|
pub next_calibration_date: Option<chrono::NaiveDate>,
|
||||||
|
#[serde(rename = "CalibrationCertificateUrl")]
|
||||||
|
pub calibration_certificate_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_data_type(
|
||||||
|
s: &str,
|
||||||
|
) -> Result<shared_lib::edge_models::variables::VariableDataType, String> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"bool" | "boolean" => Ok(shared_lib::edge_models::variables::VariableDataType::Bool),
|
||||||
|
"int16" => Ok(shared_lib::edge_models::variables::VariableDataType::Int16),
|
||||||
|
"uint16" => Ok(shared_lib::edge_models::variables::VariableDataType::Uint16),
|
||||||
|
"int32" => Ok(shared_lib::edge_models::variables::VariableDataType::Int32),
|
||||||
|
"uint32" => Ok(shared_lib::edge_models::variables::VariableDataType::Uint32),
|
||||||
|
"float32" | "float" => Ok(shared_lib::edge_models::variables::VariableDataType::Float32),
|
||||||
|
"float64" | "double" => Ok(shared_lib::edge_models::variables::VariableDataType::Float64),
|
||||||
|
"string" | "text" => Ok(shared_lib::edge_models::variables::VariableDataType::StringType),
|
||||||
|
_ => Err(format!("Tipo de dato no soportado: {}", s)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_endpoint_host_port(endpoint: &str) -> (String, i32) {
|
||||||
|
if let Some(stripped) = endpoint.strip_prefix("opc.tcp://") {
|
||||||
|
let parts: Vec<&str> = stripped
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.split(':')
|
||||||
|
.collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
let host = parts[0].to_string();
|
||||||
|
let port = parts[1].parse::<i32>().unwrap_or(4840);
|
||||||
|
return (host, port);
|
||||||
|
} else if parts.len() == 1 {
|
||||||
|
return (parts[0].to_string(), 4840);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(endpoint.to_string(), 4840)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint for template download
|
||||||
|
pub async fn download_import_template(
|
||||||
|
Path((_project_id, protocol)): Path<(Uuid, String)>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let (filename, content) = match protocol.to_uppercase().as_str() {
|
||||||
|
"MODBUS_TCP" => (
|
||||||
|
"template_modbus_tcp.csv",
|
||||||
|
"WellCode,DeviceName,Host,Port,UnitId,VariableAlias,Address,DataType,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl\nPOZO-X1,FlowMeter1,192.168.1.100,502,1,Temperature,HR:40001,float32,2026-01-15,2027-01-15,http://example.com/cert.pdf\n",
|
||||||
|
),
|
||||||
|
"S7" => (
|
||||||
|
"template_s7.csv",
|
||||||
|
"WellCode,DeviceName,Host,Port,Rack,Slot,VariableAlias,Address,DataType,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl\nPOZO-X1,PLC_S7,192.168.1.50,102,0,1,Level,DB1.DBW0,int16,2026-01-15,2027-01-15,http://example.com/cert.pdf\n",
|
||||||
|
),
|
||||||
|
"OPC_UA" => (
|
||||||
|
"template_opc_ua.csv",
|
||||||
|
"WellCode,DeviceName,Endpoint,VariableAlias,NodeId,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl\nPOZO-X1,SCADA_OPC,opc.tcp://192.168.1.10:4840,Pressure,ns=2;s=Well1.Pressure,2026-01-15,2027-01-15,http://example.com/cert.pdf\n",
|
||||||
|
),
|
||||||
|
"MQTT_SPARKPLUG_B" => (
|
||||||
|
"template_mqtt_sparkplug_b.csv",
|
||||||
|
"WellCode,DeviceName,BrokerHost,BrokerPort,GroupId,EdgeNodeId,DeviceId,VariableAlias,SparkplugMetric,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl\nPOZO-X1,SparkDevice,192.168.1.80,1883,FieldGroup,Node1,Meter1,FlowRate,Outputs/FlowRate,2026-01-15,2027-01-15,http://example.com/cert.pdf\n",
|
||||||
|
),
|
||||||
|
"SQL_DB" => (
|
||||||
|
"template_sql_db.csv",
|
||||||
|
"WellCode,DeviceName,ConnectionString,Query,VariableAlias,ColumnName,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl\nPOZO-X1,HistDB,postgresql://user:pass@192.168.1.90/db,SELECT temp FROM logs LIMIT 1,Temperature,temp,2026-01-15,2027-01-15,http://example.com/cert.pdf\n",
|
||||||
|
),
|
||||||
|
_ => return (StatusCode::BAD_REQUEST, "Protocolo no soportado").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
("Content-Type", "text/csv"),
|
||||||
|
(
|
||||||
|
"Content-Disposition",
|
||||||
|
&format!("attachment; filename=\"{}\"", filename),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_or_create_well(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
project_id: Uuid,
|
||||||
|
well_code: &str,
|
||||||
|
last_calibration_date: Option<chrono::NaiveDate>,
|
||||||
|
next_calibration_date: Option<chrono::NaiveDate>,
|
||||||
|
calibration_certificate_url: Option<String>,
|
||||||
|
cache: &mut HashMap<String, Uuid>,
|
||||||
|
imported_count: &mut i32,
|
||||||
|
) -> Result<Uuid, (StatusCode, String)> {
|
||||||
|
let trimmed = well_code.trim();
|
||||||
|
if let Some(&id) = cache.get(trimmed) {
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = sqlx::query("SELECT id FROM edge_assets WHERE code = $1 AND project_id = $2")
|
||||||
|
.bind(trimmed)
|
||||||
|
.bind(project_id)
|
||||||
|
.fetch_optional(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al buscar pozo: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(r) = row {
|
||||||
|
let id: Uuid = r
|
||||||
|
.try_get("id")
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
cache.insert(trimmed.to_string(), id);
|
||||||
|
|
||||||
|
// Actualizar calibración si se provee en el CSV para pozo existente
|
||||||
|
if last_calibration_date.is_some()
|
||||||
|
|| next_calibration_date.is_some()
|
||||||
|
|| calibration_certificate_url.is_some()
|
||||||
|
{
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE edge_assets
|
||||||
|
SET last_calibration_date = COALESCE($1, last_calibration_date),
|
||||||
|
next_calibration_date = COALESCE($2, next_calibration_date),
|
||||||
|
calibration_certificate_url = COALESCE($3, calibration_certificate_url),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $4"#,
|
||||||
|
)
|
||||||
|
.bind(last_calibration_date)
|
||||||
|
.bind(next_calibration_date)
|
||||||
|
.bind(calibration_certificate_url)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al actualizar calibración de pozo: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
|
} else {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let well_name = format!("Pozo {}", trimmed);
|
||||||
|
sqlx::query(
|
||||||
|
r#"INSERT INTO edge_assets (id, project_id, code, name, asset_type, is_active, last_calibration_date, next_calibration_date, calibration_certificate_url)
|
||||||
|
VALUES ($1, $2, $3, $4, 'POZO', true, $5, $6, $7)"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(trimmed)
|
||||||
|
.bind(&well_name)
|
||||||
|
.bind(last_calibration_date)
|
||||||
|
.bind(next_calibration_date)
|
||||||
|
.bind(calibration_certificate_url)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al insertar pozo: {}", e)))?;
|
||||||
|
|
||||||
|
*imported_count += 1;
|
||||||
|
cache.insert(trimmed.to_string(), id);
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_or_create_device(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
asset_id: Uuid,
|
||||||
|
device_name: &str,
|
||||||
|
protocol: &str,
|
||||||
|
host: &str,
|
||||||
|
port: i32,
|
||||||
|
protocol_config: Value,
|
||||||
|
cache: &mut HashMap<(Uuid, String), Uuid>,
|
||||||
|
imported_count: &mut i32,
|
||||||
|
) -> Result<Uuid, (StatusCode, String)> {
|
||||||
|
let key = (asset_id, device_name.trim().to_string());
|
||||||
|
if let Some(&id) = cache.get(&key) {
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = sqlx::query("SELECT id FROM edge_devices WHERE asset_id = $1 AND name = $2")
|
||||||
|
.bind(asset_id)
|
||||||
|
.bind(&key.1)
|
||||||
|
.fetch_optional(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al buscar dispositivo: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(r) = row {
|
||||||
|
let id: Uuid = r
|
||||||
|
.try_get("id")
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
cache.insert(key, id);
|
||||||
|
Ok(id)
|
||||||
|
} else {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
sqlx::query(
|
||||||
|
r#"INSERT INTO edge_devices (id, asset_id, name, protocol, host, port, protocol_config, is_enabled)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, true)"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(asset_id)
|
||||||
|
.bind(&key.1)
|
||||||
|
.bind(protocol)
|
||||||
|
.bind(host)
|
||||||
|
.bind(port)
|
||||||
|
.bind(protocol_config)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al insertar dispositivo: {}", e)))?;
|
||||||
|
|
||||||
|
*imported_count += 1;
|
||||||
|
cache.insert(key, id);
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_variable(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
device_id: Uuid,
|
||||||
|
address: &str,
|
||||||
|
data_type: shared_lib::edge_models::variables::VariableDataType,
|
||||||
|
alias: &str,
|
||||||
|
unit: Option<&str>,
|
||||||
|
metadata: Value,
|
||||||
|
) -> Result<(), (StatusCode, String)> {
|
||||||
|
let dt_str = match data_type {
|
||||||
|
shared_lib::edge_models::variables::VariableDataType::Bool => "bool",
|
||||||
|
shared_lib::edge_models::variables::VariableDataType::Int16 => "int16",
|
||||||
|
shared_lib::edge_models::variables::VariableDataType::Uint16 => "uint16",
|
||||||
|
shared_lib::edge_models::variables::VariableDataType::Int32 => "int32",
|
||||||
|
shared_lib::edge_models::variables::VariableDataType::Uint32 => "uint32",
|
||||||
|
shared_lib::edge_models::variables::VariableDataType::Float32 => "float32",
|
||||||
|
shared_lib::edge_models::variables::VariableDataType::Float64 => "float64",
|
||||||
|
shared_lib::edge_models::variables::VariableDataType::StringType => "string",
|
||||||
|
};
|
||||||
|
|
||||||
|
let exists: bool = sqlx::query_scalar(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM edge_variables WHERE device_id = $1 AND alias = $2)",
|
||||||
|
)
|
||||||
|
.bind(device_id)
|
||||||
|
.bind(alias.trim())
|
||||||
|
.fetch_one(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al verificar existencia de variable: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE edge_variables
|
||||||
|
SET address = $3, data_type = $4, unit = $5, metadata = $6, updated_at = NOW()
|
||||||
|
WHERE device_id = $1 AND alias = $2"#,
|
||||||
|
)
|
||||||
|
.bind(device_id)
|
||||||
|
.bind(alias.trim())
|
||||||
|
.bind(address.trim())
|
||||||
|
.bind(dt_str)
|
||||||
|
.bind(unit)
|
||||||
|
.bind(metadata)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al actualizar variable: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
sqlx::query(
|
||||||
|
r#"INSERT INTO edge_variables (device_id, address, data_type, alias, unit, metadata, is_enabled)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, true)"#,
|
||||||
|
)
|
||||||
|
.bind(device_id)
|
||||||
|
.bind(address.trim())
|
||||||
|
.bind(dt_str)
|
||||||
|
.bind(alias.trim())
|
||||||
|
.bind(unit)
|
||||||
|
.bind(metadata)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al insertar variable: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/edge/projects/{project_id}/import-csv/{protocol}
|
||||||
|
pub async fn import_csv_by_protocol(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: Claims,
|
||||||
|
Path((project_id, protocol)): Path<(Uuid, String)>,
|
||||||
|
body_csv: String,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
if claims.role != "admin" {
|
||||||
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid user ID".to_string()))?;
|
||||||
|
let access = sqlx::query(
|
||||||
|
"SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true"
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(project_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if access.is_none() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Access denied to this project".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = state.pool.begin().await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to start transaction: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut rdr = ReaderBuilder::new()
|
||||||
|
.has_headers(true)
|
||||||
|
.flexible(false)
|
||||||
|
.from_reader(body_csv.as_bytes());
|
||||||
|
|
||||||
|
let mut line_num = 1;
|
||||||
|
let protocol_upper = protocol.to_uppercase();
|
||||||
|
|
||||||
|
let mut wells_cache: HashMap<String, Uuid> = HashMap::new();
|
||||||
|
let mut devices_cache: HashMap<(Uuid, String), Uuid> = HashMap::new();
|
||||||
|
|
||||||
|
let mut imported_wells = 0;
|
||||||
|
let mut imported_devices = 0;
|
||||||
|
let mut imported_variables = 0;
|
||||||
|
|
||||||
|
let headers = rdr
|
||||||
|
.headers()
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("Fila de cabecera CSV inválida: {}", e),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
for result in rdr.records() {
|
||||||
|
line_num += 1;
|
||||||
|
let record = result.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("Error en línea {}: CSV mal formateado ({})", line_num, e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match protocol_upper.as_str() {
|
||||||
|
"MODBUS_TCP" => {
|
||||||
|
let rec: ModbusTcpRecord = record.deserialize(Some(&headers)).map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!(
|
||||||
|
"Error en línea {}: no coincide con el formato Modbus TCP ({})",
|
||||||
|
line_num, e
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if rec.well_code.trim().is_empty()
|
||||||
|
|| rec.device_name.trim().is_empty()
|
||||||
|
|| rec.variable_alias.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!(
|
||||||
|
"Error en línea {}: WellCode, DeviceName y VariableAlias son campos requeridos",
|
||||||
|
line_num
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let asset_id = get_or_create_well(
|
||||||
|
&mut tx,
|
||||||
|
project_id,
|
||||||
|
&rec.well_code,
|
||||||
|
rec.last_calibration_date,
|
||||||
|
rec.next_calibration_date,
|
||||||
|
rec.calibration_certificate_url.clone(),
|
||||||
|
&mut wells_cache,
|
||||||
|
&mut imported_wells,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let device_config = json!({ "unit_id": rec.unit_id });
|
||||||
|
let device_id = get_or_create_device(
|
||||||
|
&mut tx,
|
||||||
|
asset_id,
|
||||||
|
&rec.device_name,
|
||||||
|
"MODBUS_TCP",
|
||||||
|
&rec.host,
|
||||||
|
rec.port,
|
||||||
|
device_config,
|
||||||
|
&mut devices_cache,
|
||||||
|
&mut imported_devices,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let dt = parse_data_type(&rec.data_type).map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("Error en línea {}: {}", line_num, e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
create_variable(
|
||||||
|
&mut tx,
|
||||||
|
device_id,
|
||||||
|
&rec.address,
|
||||||
|
dt,
|
||||||
|
&rec.variable_alias,
|
||||||
|
None,
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
imported_variables += 1;
|
||||||
|
}
|
||||||
|
"S7" => {
|
||||||
|
let rec: S7Record = record.deserialize(Some(&headers)).map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!(
|
||||||
|
"Error en línea {}: no coincide con el formato Siemens S7 ({})",
|
||||||
|
line_num, e
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if rec.well_code.trim().is_empty()
|
||||||
|
|| rec.device_name.trim().is_empty()
|
||||||
|
|| rec.variable_alias.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!(
|
||||||
|
"Error en línea {}: WellCode, DeviceName y VariableAlias son campos requeridos",
|
||||||
|
line_num
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let asset_id = get_or_create_well(
|
||||||
|
&mut tx,
|
||||||
|
project_id,
|
||||||
|
&rec.well_code,
|
||||||
|
rec.last_calibration_date,
|
||||||
|
rec.next_calibration_date,
|
||||||
|
rec.calibration_certificate_url.clone(),
|
||||||
|
&mut wells_cache,
|
||||||
|
&mut imported_wells,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let device_config = json!({ "rack": rec.rack, "slot": rec.slot });
|
||||||
|
let device_id = get_or_create_device(
|
||||||
|
&mut tx,
|
||||||
|
asset_id,
|
||||||
|
&rec.device_name,
|
||||||
|
"S7",
|
||||||
|
&rec.host,
|
||||||
|
rec.port,
|
||||||
|
device_config,
|
||||||
|
&mut devices_cache,
|
||||||
|
&mut imported_devices,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let dt = parse_data_type(&rec.data_type).map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("Error en línea {}: {}", line_num, e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
create_variable(
|
||||||
|
&mut tx,
|
||||||
|
device_id,
|
||||||
|
&rec.address,
|
||||||
|
dt,
|
||||||
|
&rec.variable_alias,
|
||||||
|
None,
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
imported_variables += 1;
|
||||||
|
}
|
||||||
|
"OPC_UA" => {
|
||||||
|
let rec: OpcUaRecord = record.deserialize(Some(&headers)).map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!(
|
||||||
|
"Error en línea {}: no coincide con el formato OPC UA ({})",
|
||||||
|
line_num, e
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if rec.well_code.trim().is_empty()
|
||||||
|
|| rec.device_name.trim().is_empty()
|
||||||
|
|| rec.variable_alias.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!(
|
||||||
|
"Error en línea {}: WellCode, DeviceName y VariableAlias son campos requeridos",
|
||||||
|
line_num
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let asset_id = get_or_create_well(
|
||||||
|
&mut tx,
|
||||||
|
project_id,
|
||||||
|
&rec.well_code,
|
||||||
|
rec.last_calibration_date,
|
||||||
|
rec.next_calibration_date,
|
||||||
|
rec.calibration_certificate_url.clone(),
|
||||||
|
&mut wells_cache,
|
||||||
|
&mut imported_wells,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let device_config = json!({ "endpoint": rec.endpoint });
|
||||||
|
let (host, port) = parse_endpoint_host_port(&rec.endpoint);
|
||||||
|
let device_id = get_or_create_device(
|
||||||
|
&mut tx,
|
||||||
|
asset_id,
|
||||||
|
&rec.device_name,
|
||||||
|
"OPC_UA",
|
||||||
|
&host,
|
||||||
|
port,
|
||||||
|
device_config,
|
||||||
|
&mut devices_cache,
|
||||||
|
&mut imported_devices,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
create_variable(
|
||||||
|
&mut tx,
|
||||||
|
device_id,
|
||||||
|
&rec.node_id,
|
||||||
|
shared_lib::edge_models::variables::VariableDataType::Float32,
|
||||||
|
&rec.variable_alias,
|
||||||
|
None,
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
imported_variables += 1;
|
||||||
|
}
|
||||||
|
"MQTT_SPARKPLUG_B" => {
|
||||||
|
let rec: MqttSparkplugBRecord = record.deserialize(Some(&headers)).map_err(|e| {
|
||||||
|
(StatusCode::BAD_REQUEST, format!("Error en línea {}: no coincide con el formato MQTT Sparkplug B ({})", line_num, e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if rec.well_code.trim().is_empty()
|
||||||
|
|| rec.device_name.trim().is_empty()
|
||||||
|
|| rec.variable_alias.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!(
|
||||||
|
"Error en línea {}: WellCode, DeviceName y VariableAlias son campos requeridos",
|
||||||
|
line_num
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let asset_id = get_or_create_well(
|
||||||
|
&mut tx,
|
||||||
|
project_id,
|
||||||
|
&rec.well_code,
|
||||||
|
rec.last_calibration_date,
|
||||||
|
rec.next_calibration_date,
|
||||||
|
rec.calibration_certificate_url.clone(),
|
||||||
|
&mut wells_cache,
|
||||||
|
&mut imported_wells,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let device_config = json!({
|
||||||
|
"group_id": rec.group_id,
|
||||||
|
"edge_node_id": rec.edge_node_id,
|
||||||
|
"device_id": rec.device_id
|
||||||
|
});
|
||||||
|
let device_id = get_or_create_device(
|
||||||
|
&mut tx,
|
||||||
|
asset_id,
|
||||||
|
&rec.device_name,
|
||||||
|
"MQTT_SPARKPLUG_B",
|
||||||
|
&rec.broker_host,
|
||||||
|
rec.broker_port,
|
||||||
|
device_config,
|
||||||
|
&mut devices_cache,
|
||||||
|
&mut imported_devices,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
create_variable(
|
||||||
|
&mut tx,
|
||||||
|
device_id,
|
||||||
|
&rec.sparkplug_metric,
|
||||||
|
shared_lib::edge_models::variables::VariableDataType::Float32,
|
||||||
|
&rec.variable_alias,
|
||||||
|
None,
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
imported_variables += 1;
|
||||||
|
}
|
||||||
|
"SQL_DB" => {
|
||||||
|
let rec: SqlDbRecord = record.deserialize(Some(&headers)).map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!(
|
||||||
|
"Error en línea {}: no coincide con el formato SQL Database ({})",
|
||||||
|
line_num, e
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if rec.well_code.trim().is_empty()
|
||||||
|
|| rec.device_name.trim().is_empty()
|
||||||
|
|| rec.variable_alias.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!(
|
||||||
|
"Error en línea {}: WellCode, DeviceName y VariableAlias son campos requeridos",
|
||||||
|
line_num
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let asset_id = get_or_create_well(
|
||||||
|
&mut tx,
|
||||||
|
project_id,
|
||||||
|
&rec.well_code,
|
||||||
|
rec.last_calibration_date,
|
||||||
|
rec.next_calibration_date,
|
||||||
|
rec.calibration_certificate_url.clone(),
|
||||||
|
&mut wells_cache,
|
||||||
|
&mut imported_wells,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let device_config = json!({ "connection_string": rec.connection_string });
|
||||||
|
let device_id = get_or_create_device(
|
||||||
|
&mut tx,
|
||||||
|
asset_id,
|
||||||
|
&rec.device_name,
|
||||||
|
"SQL_DB",
|
||||||
|
&rec.connection_string,
|
||||||
|
0,
|
||||||
|
device_config,
|
||||||
|
&mut devices_cache,
|
||||||
|
&mut imported_devices,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let metadata = json!({ "query": rec.query });
|
||||||
|
create_variable(
|
||||||
|
&mut tx,
|
||||||
|
device_id,
|
||||||
|
&rec.column_name,
|
||||||
|
shared_lib::edge_models::variables::VariableDataType::Float32,
|
||||||
|
&rec.variable_alias,
|
||||||
|
None,
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
imported_variables += 1;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Protocolo no soportado para importación".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to commit transaction: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"status": "success",
|
||||||
|
"wells_imported": imported_wells,
|
||||||
|
"devices_imported": imported_devices,
|
||||||
|
"variables_imported": imported_variables,
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ struct PlatformInvitationActivationRow {
|
|||||||
user_email: String,
|
user_email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn invitation_can_activate(
|
fn invitation_can_activate(
|
||||||
expires_at: DateTime<Utc>,
|
expires_at: DateTime<Utc>,
|
||||||
consumed_at: Option<DateTime<Utc>>,
|
consumed_at: Option<DateTime<Utc>>,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::auth::Claims;
|
use crate::auth::Claims;
|
||||||
use crate::handlers::audit_helper;
|
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
|
use crate::handlers::audit_helper;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
@@ -44,7 +44,9 @@ pub async fn list_lab_results(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin permiso para ver resultados de este activo".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para ver resultados de este activo".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let records: Vec<LabResult> = sqlx::query_as::<Postgres, LabResult>(
|
let records: Vec<LabResult> = sqlx::query_as::<Postgres, LabResult>(
|
||||||
@@ -78,7 +80,11 @@ pub async fn create_lab_result(
|
|||||||
|
|
||||||
let project_id: Uuid = match project_row {
|
let project_id: Uuid = match project_row {
|
||||||
Some(row) => row.get("project_id"),
|
Some(row) => row.get("project_id"),
|
||||||
None => return Err(AppError::Forbidden("Sin permiso para registrar resultados en este activo".to_string())),
|
None => {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para registrar resultados en este activo".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let record: LabResult = sqlx::query_as::<Postgres, LabResult>(
|
let record: LabResult = sqlx::query_as::<Postgres, LabResult>(
|
||||||
@@ -107,7 +113,8 @@ pub async fn create_lab_result(
|
|||||||
None,
|
None,
|
||||||
Some(serde_json::to_value(&record).unwrap_or_default()),
|
Some(serde_json::to_value(&record).unwrap_or_default()),
|
||||||
None,
|
None,
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(record)))
|
Ok((StatusCode::CREATED, Json(record)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ pub async fn list_meter_readings(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin permiso para ver lecturas de este activo".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para ver lecturas de este activo".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let records: Vec<MeterReading> = sqlx::query_as::<Postgres, MeterReading>(
|
let records: Vec<MeterReading> = sqlx::query_as::<Postgres, MeterReading>(
|
||||||
@@ -79,7 +81,9 @@ pub async fn create_meter_reading(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin permiso para registrar lecturas en este activo".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para registrar lecturas en este activo".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let record: MeterReading = sqlx::query_as::<Postgres, MeterReading>(
|
let record: MeterReading = sqlx::query_as::<Postgres, MeterReading>(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod anh_reports;
|
|||||||
pub mod audit_helper;
|
pub mod audit_helper;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod billing;
|
pub mod billing;
|
||||||
|
pub mod billing_webhook;
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
pub mod docs;
|
pub mod docs;
|
||||||
pub mod downtime;
|
pub mod downtime;
|
||||||
@@ -14,6 +15,7 @@ pub mod edge_devices;
|
|||||||
pub mod edge_projects;
|
pub mod edge_projects;
|
||||||
pub mod edge_variables;
|
pub mod edge_variables;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod import;
|
||||||
pub mod invitations;
|
pub mod invitations;
|
||||||
pub mod lab_results;
|
pub mod lab_results;
|
||||||
pub mod meters;
|
pub mod meters;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::auth::Claims;
|
use crate::auth::Claims;
|
||||||
use crate::handlers::audit_helper;
|
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
|
use crate::handlers::audit_helper;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
@@ -48,7 +48,9 @@ pub async fn list_movements(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin permiso para ver movimientos de este activo".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para ver movimientos de este activo".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let movements: Vec<OperationMovement> = sqlx::query_as::<Postgres, OperationMovement>(
|
let movements: Vec<OperationMovement> = sqlx::query_as::<Postgres, OperationMovement>(
|
||||||
@@ -83,7 +85,11 @@ pub async fn create_movement(
|
|||||||
|
|
||||||
let project_id: Uuid = match project_row {
|
let project_id: Uuid = match project_row {
|
||||||
Some(row) => row.get("project_id"),
|
Some(row) => row.get("project_id"),
|
||||||
None => return Err(AppError::Forbidden("Sin permiso para registrar movimientos en este activo".to_string())),
|
None => {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para registrar movimientos en este activo".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let details = serde_json::json!({
|
let details = serde_json::json!({
|
||||||
@@ -119,8 +125,8 @@ pub async fn create_movement(
|
|||||||
None,
|
None,
|
||||||
Some(serde_json::to_value(&movement).unwrap_or_default()),
|
Some(serde_json::to_value(&movement).unwrap_or_default()),
|
||||||
None, // Podríamos extraer IP del extractor ConnectInfo de axum
|
None, // Podríamos extraer IP del extractor ConnectInfo de axum
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(movement)))
|
Ok((StatusCode::CREATED, Json(movement)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ pub async fn create_platform_operator(
|
|||||||
to: &email,
|
to: &email,
|
||||||
subject: &email_body.subject,
|
subject: &email_body.subject,
|
||||||
body: &email_body.body,
|
body: &email_body.body,
|
||||||
|
html_body: email_body.html_body.as_deref(),
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
//! 5. Limpia el usuario MQTT temporal.
|
//! 5. Limpia el usuario MQTT temporal.
|
||||||
//! 6. Solo el agente puede descifrar la respuesta con su clave privada.
|
//! 6. Solo el agente puede descifrar la respuesta con su clave privada.
|
||||||
|
|
||||||
|
use axum::{Json, extract::State, http::StatusCode};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use axum::{Json, extract::State, http::StatusCode};
|
|
||||||
|
|
||||||
// ─── Mensajes MQTT ────────────────────────────────────────────────────────────
|
// ─── Mensajes MQTT ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -63,7 +63,12 @@ pub async fn handle_mqtt_provisioning(
|
|||||||
.bind(project_id)
|
.bind(project_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("No hay token de provisioning activo para proyecto {}", project_id))?;
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"No hay token de provisioning activo para proyecto {}",
|
||||||
|
project_id
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let token_id: Uuid = sqlx::Row::try_get(&token_row, "id")?;
|
let token_id: Uuid = sqlx::Row::try_get(&token_row, "id")?;
|
||||||
|
|
||||||
@@ -86,9 +91,8 @@ pub async fn handle_mqtt_provisioning(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 5. Obtener config TOML del proyecto
|
// 5. Obtener config TOML del proyecto
|
||||||
let config_toml: Option<String> = sqlx::query_scalar(
|
let config_toml: Option<String> =
|
||||||
"SELECT agent_config_toml FROM edge_projects WHERE id = $1",
|
sqlx::query_scalar("SELECT agent_config_toml FROM edge_projects WHERE id = $1")
|
||||||
)
|
|
||||||
.bind(project_id)
|
.bind(project_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
@@ -167,9 +171,8 @@ pub async fn rotate_provisioning_token(
|
|||||||
let _ = claims;
|
let _ = claims;
|
||||||
let db_err = |e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string());
|
let db_err = |e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string());
|
||||||
|
|
||||||
let exists: Option<bool> = sqlx::query_scalar(
|
let exists: Option<bool> =
|
||||||
"SELECT EXISTS(SELECT 1 FROM edge_projects WHERE id = $1)",
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM edge_projects WHERE id = $1)")
|
||||||
)
|
|
||||||
.bind(project_id)
|
.bind(project_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -4,17 +4,17 @@
|
|||||||
//! - `telemetry_raw` → Datos crudos, retención 31 días. Usado para gráfica en tiempo real.
|
//! - `telemetry_raw` → Datos crudos, retención 31 días. Usado para gráfica en tiempo real.
|
||||||
//! - `telemetry_5min` → Agregado cada 5 min, retención 5 años. Usado para historial ANH.
|
//! - `telemetry_5min` → Agregado cada 5 min, retención 5 años. Usado para historial ANH.
|
||||||
|
|
||||||
|
use crate::auth::Claims;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Json,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
|
||||||
};
|
};
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use shared_lib::models::{PostTelemetryRequest, TelemetryRecord, TelemetryResponse};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use shared_lib::models::{PostTelemetryRequest, TelemetryRecord, TelemetryResponse};
|
|
||||||
use crate::auth::Claims;
|
|
||||||
|
|
||||||
/// POST /api/telemetry — Ingresar una medición de campo (solo frontend/admin con JWT).
|
/// POST /api/telemetry — Ingresar una medición de campo (solo frontend/admin con JWT).
|
||||||
///
|
///
|
||||||
@@ -83,16 +83,24 @@ pub async fn get_asset_telemetry(
|
|||||||
let access = sqlx::query(
|
let access = sqlx::query(
|
||||||
r#"SELECT 1 FROM edge_assets ea
|
r#"SELECT 1 FROM edge_assets ea
|
||||||
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
)
|
)
|
||||||
.bind(asset_id)
|
.bind(asset_id)
|
||||||
.bind(Uuid::parse_str(&claims.sub).map_err(|_| (StatusCode::UNAUTHORIZED, "User ID inválido en token".to_string()))?)
|
.bind(Uuid::parse_str(&claims.sub).map_err(|_| {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"User ID inválido en token".to_string(),
|
||||||
|
)
|
||||||
|
})?)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Access denied to this asset's telemetry".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Access denied to this asset's telemetry".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let limit = params.limit.unwrap_or(10).min(100);
|
let limit = params.limit.unwrap_or(10).min(100);
|
||||||
|
|
||||||
@@ -128,14 +136,18 @@ pub async fn get_asset_telemetry_history(
|
|||||||
Path(asset_id): Path<Uuid>,
|
Path(asset_id): Path<Uuid>,
|
||||||
Query(params): Query<TelemetryHistoryParams>,
|
Query(params): Query<TelemetryHistoryParams>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
let user_id = Uuid::parse_str(&claims.sub)
|
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
|
||||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "User ID inválido en token".to_string()))?;
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"User ID inválido en token".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Validar acceso
|
// Validar acceso
|
||||||
let access = sqlx::query(
|
let access = sqlx::query(
|
||||||
r#"SELECT 1 FROM edge_assets ea
|
r#"SELECT 1 FROM edge_assets ea
|
||||||
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
)
|
)
|
||||||
.bind(asset_id)
|
.bind(asset_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@@ -144,7 +156,10 @@ pub async fn get_asset_telemetry_history(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Access denied to this asset's telemetry".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Access denied to this asset's telemetry".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calcular ventana de tiempo: máximo 5 años (retención del agregado)
|
// Calcular ventana de tiempo: máximo 5 años (retención del agregado)
|
||||||
@@ -195,4 +210,3 @@ pub async fn get_asset_telemetry_history(
|
|||||||
"count": records.len()
|
"count": records.len()
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -272,7 +272,8 @@ pub async fn status(
|
|||||||
PlatformClaims(claims): PlatformClaims,
|
PlatformClaims(claims): PlatformClaims,
|
||||||
) -> Result<Json<TotpStatusResponse>, AuthError> {
|
) -> Result<Json<TotpStatusResponse>, AuthError> {
|
||||||
let user_id = uuid::Uuid::parse_str(&claims.sub).map_err(|_| AuthError::InvalidCredentials)?;
|
let user_id = uuid::Uuid::parse_str(&claims.sub).map_err(|_| AuthError::InvalidCredentials)?;
|
||||||
let two_factor_enabled: bool = sqlx::query_scalar("SELECT two_factor_enabled FROM users WHERE id = $1")
|
let two_factor_enabled: bool =
|
||||||
|
sqlx::query_scalar("SELECT two_factor_enabled FROM users WHERE id = $1")
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -16,11 +16,11 @@
|
|||||||
//! Todo el tráfico pasa por el backend API que valida autenticación y puede auditar.
|
//! Todo el tráfico pasa por el backend API que valida autenticación y puede auditar.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Json,
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
Json,
|
|
||||||
};
|
};
|
||||||
use semver::Version;
|
use semver::Version;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -82,7 +82,7 @@ pub async fn get_update_info(
|
|||||||
return err(
|
return err(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
&format!("Invalid version format: '{}'", params.version),
|
&format!("Invalid version format: '{}'", params.version),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,8 +129,8 @@ pub async fn get_update_info(
|
|||||||
let sha256 = fetch_sha256_internal(&latest.to_string(), "msi").await;
|
let sha256 = fetch_sha256_internal(&latest.to_string(), "msi").await;
|
||||||
|
|
||||||
// 5. download_url apunta al endpoint del BACKEND, no a MinIO directamente
|
// 5. download_url apunta al endpoint del BACKEND, no a MinIO directamente
|
||||||
let api_base = std::env::var("API_BASE_URL")
|
let api_base =
|
||||||
.unwrap_or_else(|_| "https://api.omnioil.com".to_string());
|
std::env::var("API_BASE_URL").unwrap_or_else(|_| "https://api.omnioil.com".to_string());
|
||||||
let download_url = format!(
|
let download_url = format!(
|
||||||
"{}/api/edge/download-agent?version={}&asset_type=msi",
|
"{}/api/edge/download-agent?version={}&asset_type=msi",
|
||||||
api_base.trim_end_matches('/'),
|
api_base.trim_end_matches('/'),
|
||||||
@@ -160,10 +160,9 @@ pub async fn download_agent(
|
|||||||
let asset_type = params.asset_type.as_deref().unwrap_or("msi");
|
let asset_type = params.asset_type.as_deref().unwrap_or("msi");
|
||||||
let version = params.version.trim_start_matches('v');
|
let version = params.version.trim_start_matches('v');
|
||||||
|
|
||||||
let minio_internal = std::env::var("MINIO_INTERNAL_URL")
|
let minio_internal =
|
||||||
.unwrap_or_else(|_| "http://anh_minio:9000".to_string());
|
std::env::var("MINIO_INTERNAL_URL").unwrap_or_else(|_| "http://anh_minio:9000".to_string());
|
||||||
let bucket = std::env::var("MINIO_BUCKET")
|
let bucket = std::env::var("MINIO_BUCKET").unwrap_or_else(|_| "omnioil-releases".to_string());
|
||||||
.unwrap_or_else(|_| "omnioil-releases".to_string());
|
|
||||||
|
|
||||||
// Convención de paths en MinIO:
|
// Convención de paths en MinIO:
|
||||||
// releases/edge-agent/v0.2.1/omnioil-edge-agent-windows-amd64.msi
|
// releases/edge-agent/v0.2.1/omnioil-edge-agent-windows-amd64.msi
|
||||||
@@ -172,9 +171,17 @@ pub async fn download_agent(
|
|||||||
_ => format!("omnioil-edge-agent-windows-amd64.msi"),
|
_ => format!("omnioil-edge-agent-windows-amd64.msi"),
|
||||||
};
|
};
|
||||||
let object_key = format!("releases/edge-agent/v{}/{}", version, filename);
|
let object_key = format!("releases/edge-agent/v{}/{}", version, filename);
|
||||||
let minio_url = format!("{}/{}/{}", minio_internal.trim_end_matches('/'), bucket, object_key);
|
let minio_url = format!(
|
||||||
|
"{}/{}/{}",
|
||||||
|
minio_internal.trim_end_matches('/'),
|
||||||
|
bucket,
|
||||||
|
object_key
|
||||||
|
);
|
||||||
|
|
||||||
info!("📦 Proxying agent download: {} → {}", params.version, object_key);
|
info!(
|
||||||
|
"📦 Proxying agent download: {} → {}",
|
||||||
|
params.version, object_key
|
||||||
|
);
|
||||||
|
|
||||||
// Descargar desde MinIO (red interna — sin autenticación pública)
|
// Descargar desde MinIO (red interna — sin autenticación pública)
|
||||||
let minio_token = std::env::var("MINIO_ACCESS_KEY").ok();
|
let minio_token = std::env::var("MINIO_ACCESS_KEY").ok();
|
||||||
@@ -214,7 +221,10 @@ pub async fn download_agent(
|
|||||||
return if status.as_u16() == 404 {
|
return if status.as_u16() == 404 {
|
||||||
err(
|
err(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
&format!("Agent binary v{} ({}) not found in storage", version, asset_type),
|
&format!(
|
||||||
|
"Agent binary v{} ({}) not found in storage",
|
||||||
|
version, asset_type
|
||||||
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
err(StatusCode::BAD_GATEWAY, "Asset storage error")
|
err(StatusCode::BAD_GATEWAY, "Asset storage error")
|
||||||
@@ -243,27 +253,26 @@ pub async fn download_agent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(
|
(StatusCode::OK, headers, Body::from_stream(byte_stream)).into_response()
|
||||||
StatusCode::OK,
|
|
||||||
headers,
|
|
||||||
Body::from_stream(byte_stream),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Obtiene el SHA-256 del asset desde MinIO internamente.
|
/// Obtiene el SHA-256 del asset desde MinIO internamente.
|
||||||
pub async fn fetch_sha256_internal(version: &str, asset_type: &str) -> Option<String> {
|
pub async fn fetch_sha256_internal(version: &str, asset_type: &str) -> Option<String> {
|
||||||
let minio_internal = std::env::var("MINIO_INTERNAL_URL")
|
let minio_internal =
|
||||||
.unwrap_or_else(|_| "http://anh_minio:9000".to_string());
|
std::env::var("MINIO_INTERNAL_URL").unwrap_or_else(|_| "http://anh_minio:9000".to_string());
|
||||||
let bucket = std::env::var("MINIO_BUCKET")
|
let bucket = std::env::var("MINIO_BUCKET").unwrap_or_else(|_| "omnioil-releases".to_string());
|
||||||
.unwrap_or_else(|_| "omnioil-releases".to_string());
|
|
||||||
|
|
||||||
let ext = if asset_type == "exe" { "exe" } else { "msi" };
|
let ext = if asset_type == "exe" { "exe" } else { "msi" };
|
||||||
let sha_key = format!(
|
let sha_key = format!(
|
||||||
"releases/edge-agent/v{}/omnioil-edge-agent-windows-amd64.{}.sha256",
|
"releases/edge-agent/v{}/omnioil-edge-agent-windows-amd64.{}.sha256",
|
||||||
version, ext
|
version, ext
|
||||||
);
|
);
|
||||||
let url = format!("{}/{}/{}", minio_internal.trim_end_matches('/'), bucket, sha_key);
|
let url = format!(
|
||||||
|
"{}/{}/{}",
|
||||||
|
minio_internal.trim_end_matches('/'),
|
||||||
|
bucket,
|
||||||
|
sha_key
|
||||||
|
);
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
@@ -280,11 +289,13 @@ pub async fn fetch_sha256_internal(version: &str, asset_type: &str) -> Option<St
|
|||||||
|
|
||||||
let resp = req.send().await.ok()?;
|
let resp = req.send().await.ok()?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
warn!("SHA-256 not found in MinIO for agent v{} ({})", version, asset_type);
|
warn!(
|
||||||
|
"SHA-256 not found in MinIO for agent v{} ({})",
|
||||||
|
version, asset_type
|
||||||
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let text = resp.text().await.ok()?;
|
let text = resp.text().await.ok()?;
|
||||||
Some(text.split_whitespace().next()?.to_lowercase())
|
Some(text.split_whitespace().next()?.to_lowercase())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
use crate::auth::Claims;
|
use crate::auth::Claims;
|
||||||
use crate::handlers::audit_helper;
|
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
|
use crate::handlers::audit_helper;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
@@ -52,7 +52,9 @@ pub async fn list_well_tests(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin permiso para ver pruebas de este activo".to_string()));
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para ver pruebas de este activo".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let records: Vec<WellTest> = sqlx::query_as::<Postgres, WellTest>(
|
let records: Vec<WellTest> = sqlx::query_as::<Postgres, WellTest>(
|
||||||
@@ -86,7 +88,11 @@ pub async fn create_well_test(
|
|||||||
|
|
||||||
let project_id: Uuid = match project_row {
|
let project_id: Uuid = match project_row {
|
||||||
Some(row) => row.get("project_id"),
|
Some(row) => row.get("project_id"),
|
||||||
None => return Err(AppError::Forbidden("Sin permiso para registrar pruebas en este activo".to_string())),
|
None => {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para registrar pruebas en este activo".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let record: WellTest = sqlx::query_as::<Postgres, WellTest>(
|
let record: WellTest = sqlx::query_as::<Postgres, WellTest>(
|
||||||
@@ -122,7 +128,8 @@ pub async fn create_well_test(
|
|||||||
None,
|
None,
|
||||||
Some(serde_json::to_value(&record).unwrap_or_default()),
|
Some(serde_json::to_value(&record).unwrap_or_default()),
|
||||||
None,
|
None,
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(record)))
|
Ok((StatusCode::CREATED, Json(record)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ use backend_api::{
|
|||||||
rate_limiter, watchdog,
|
rate_limiter, watchdog,
|
||||||
};
|
};
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use sqlx::Row;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
@@ -289,9 +291,17 @@ async fn main() {
|
|||||||
let pool_ref = prov_pool.clone();
|
let pool_ref = prov_pool.clone();
|
||||||
let payload_bytes = publish.payload.to_vec();
|
let payload_bytes = publish.payload.to_vec();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Ok(result) = serde_json::from_slice::<serde_json::Value>(&payload_bytes) {
|
if let Ok(result) =
|
||||||
let scan_id_str = result.get("scan_id").and_then(|v| v.as_str()).unwrap_or("");
|
serde_json::from_slice::<serde_json::Value>(&payload_bytes)
|
||||||
let status = result.get("status").and_then(|v| v.as_str()).unwrap_or("failed");
|
{
|
||||||
|
let scan_id_str = result
|
||||||
|
.get("scan_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let status = result
|
||||||
|
.get("status")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("failed");
|
||||||
let error_msg = result.get("error").and_then(|v| v.as_str());
|
let error_msg = result.get("error").and_then(|v| v.as_str());
|
||||||
if let Ok(scan_id) = uuid::Uuid::parse_str(scan_id_str) {
|
if let Ok(scan_id) = uuid::Uuid::parse_str(scan_id_str) {
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
@@ -305,7 +315,11 @@ async fn main() {
|
|||||||
.bind(scan_id)
|
.bind(scan_id)
|
||||||
.execute(&pool_ref)
|
.execute(&pool_ref)
|
||||||
.await;
|
.await;
|
||||||
tracing::info!("✅ OPC UA scan result stored: {} ({})", scan_id, status);
|
tracing::info!(
|
||||||
|
"✅ OPC UA scan result stored: {} ({})",
|
||||||
|
scan_id,
|
||||||
|
status
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -424,10 +438,22 @@ async fn main() {
|
|||||||
"/projects/{id}/rebuild",
|
"/projects/{id}/rebuild",
|
||||||
post(handlers::edge_projects::rebuild_installer),
|
post(handlers::edge_projects::rebuild_installer),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/projects/{id}/import-template/{protocol}",
|
||||||
|
get(handlers::import::download_import_template),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/projects/{id}/import-csv/{protocol}",
|
||||||
|
post(handlers::import::import_csv_by_protocol),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/projects/{id}/linux-install",
|
"/projects/{id}/linux-install",
|
||||||
get(handlers::edge_projects::linux_install_script),
|
get(handlers::edge_projects::linux_install_script),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/projects/{id}/linux-install-token",
|
||||||
|
post(handlers::edge_projects::generate_download_token),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/agent/linux-binary/{arch}",
|
"/agent/linux-binary/{arch}",
|
||||||
get(handlers::edge_projects::download_linux_binary),
|
get(handlers::edge_projects::download_linux_binary),
|
||||||
@@ -622,6 +648,14 @@ async fn main() {
|
|||||||
"/users",
|
"/users",
|
||||||
get(handlers::users::list_users).post(handlers::users::create_user),
|
get(handlers::users::list_users).post(handlers::users::create_user),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/users/me",
|
||||||
|
get(handlers::users::get_current_user).put(handlers::users::update_current_user),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/me/reset-password",
|
||||||
|
post(handlers::users::reset_current_user_password),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/users/{id}",
|
"/users/{id}",
|
||||||
get(handlers::users::get_user).put(handlers::users::update_user),
|
get(handlers::users::get_user).put(handlers::users::update_user),
|
||||||
@@ -724,6 +758,10 @@ async fn main() {
|
|||||||
// Swagger UI y spec OpenAPI (públicos, sin JWT)
|
// Swagger UI y spec OpenAPI (públicos, sin JWT)
|
||||||
.route("/docs", get(handlers::docs::swagger_ui))
|
.route("/docs", get(handlers::docs::swagger_ui))
|
||||||
.route("/docs/openapi.yaml", get(handlers::docs::openapi_yaml))
|
.route("/docs/openapi.yaml", get(handlers::docs::openapi_yaml))
|
||||||
|
.route(
|
||||||
|
"/api/billing/webhook",
|
||||||
|
post(handlers::billing_webhook::stripe_webhook),
|
||||||
|
)
|
||||||
// Metrics endpoint (internal only — Prometheus scrapes this; not JWT-protected)
|
// Metrics endpoint (internal only — Prometheus scrapes this; not JWT-protected)
|
||||||
.route(
|
.route(
|
||||||
"/metrics",
|
"/metrics",
|
||||||
@@ -749,10 +787,37 @@ async fn main() {
|
|||||||
get(handlers::telemetry::get_asset_telemetry_history),
|
get(handlers::telemetry::get_asset_telemetry_history),
|
||||||
)
|
)
|
||||||
// Reportes ANH (Resolución 0651)
|
// Reportes ANH (Resolución 0651)
|
||||||
.route("/api/anh/reports", get(handlers::anh_reports::list_reports))
|
.route(
|
||||||
|
"/api/anh/reports",
|
||||||
|
get(handlers::anh_reports::list_reports).post(handlers::anh_reports::generate_report),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/anh/reports/{id}",
|
"/api/anh/reports/{id}",
|
||||||
get(handlers::anh_reports::get_report),
|
get(handlers::anh_reports::get_report).put(handlers::anh_reports::update_report),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/anh/reports/{id}/approve",
|
||||||
|
post(handlers::anh_reports::approve_report),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/anh/reports/{id}/versions",
|
||||||
|
get(handlers::anh_reports::get_report_versions),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/anh/reports/{id}/rejection",
|
||||||
|
post(handlers::anh_reports::register_report_rejection),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/anh/reports/{id}/corrections",
|
||||||
|
post(handlers::anh_reports::create_report_correction),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/anh/reports/{id}/available-for-anh",
|
||||||
|
post(handlers::anh_reports::mark_report_available_for_anh),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/anh/reports/{id}/audit-logs",
|
||||||
|
get(handlers::anh_reports::get_report_audit_logs),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/anh/reports/{id}/download",
|
"/api/anh/reports/{id}/download",
|
||||||
@@ -762,6 +827,14 @@ async fn main() {
|
|||||||
"/api/anh/reports/{id}/hash",
|
"/api/anh/reports/{id}/hash",
|
||||||
get(handlers::anh_reports::download_report_hash),
|
get(handlers::anh_reports::download_report_hash),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/anh/report-schedules",
|
||||||
|
get(handlers::anh_reports::list_report_schedules),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/anh/report-schedules/{id}",
|
||||||
|
put(handlers::anh_reports::update_report_schedule),
|
||||||
|
)
|
||||||
// Alert Rules & Alarm history
|
// Alert Rules & Alarm history
|
||||||
.route(
|
.route(
|
||||||
"/api/alert-rules",
|
"/api/alert-rules",
|
||||||
@@ -778,6 +851,14 @@ async fn main() {
|
|||||||
patch(handlers::alert_rules::toggle_alert_rule),
|
patch(handlers::alert_rules::toggle_alert_rule),
|
||||||
)
|
)
|
||||||
.route("/api/alarms", get(handlers::alert_rules::list_alarms))
|
.route("/api/alarms", get(handlers::alert_rules::list_alarms))
|
||||||
|
.route(
|
||||||
|
"/api/alarms/acknowledge",
|
||||||
|
patch(handlers::alert_rules::acknowledge_alarms),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/alarms/{id}/acknowledge",
|
||||||
|
patch(handlers::alert_rules::acknowledge_alarm),
|
||||||
|
)
|
||||||
// Billing & Usage Metering
|
// Billing & Usage Metering
|
||||||
.route(
|
.route(
|
||||||
"/api/billing/usage",
|
"/api/billing/usage",
|
||||||
@@ -834,6 +915,7 @@ async fn main() {
|
|||||||
// Edge System API
|
// Edge System API
|
||||||
.nest("/api/edge", edge_routes)
|
.nest("/api/edge", edge_routes)
|
||||||
// WebSocket Stream (Relay de Telemetría)
|
// WebSocket Stream (Relay de Telemetría)
|
||||||
|
.route("/api/ws/telemetry/ticket", post(create_ws_telemetry_ticket))
|
||||||
.route("/api/ws/telemetry", get(ws_telemetry_handler))
|
.route("/api/ws/telemetry", get(ws_telemetry_handler))
|
||||||
// Global rate limiter: 60 req/min per IP
|
// Global rate limiter: 60 req/min per IP
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
@@ -861,9 +943,96 @@ async fn root() -> axum::Json<serde_json::Value> {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct WsParams {
|
struct WsParams {
|
||||||
project_id: Option<String>,
|
project_id: Option<String>,
|
||||||
/// JWT bearer token — los browsers no pueden enviar Authorization header en WS,
|
/// Short-lived opaque one-time ticket. Never pass the primary JWT in the URL.
|
||||||
/// por eso se acepta como query param.
|
ticket: Option<String>,
|
||||||
token: Option<String>,
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateWsTicketRequest {
|
||||||
|
project_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CreateWsTicketResponse {
|
||||||
|
ticket: String,
|
||||||
|
expires_in: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS_TICKET_TTL_SECONDS: u64 = 60;
|
||||||
|
|
||||||
|
async fn create_ws_telemetry_ticket(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: auth::Claims,
|
||||||
|
axum::Json(req): axum::Json<CreateWsTicketRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let user_id = match uuid::Uuid::parse_str(&claims.sub) {
|
||||||
|
Ok(user_id) => user_id,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::UNAUTHORIZED,
|
||||||
|
axum::Json(serde_json::json!({"error": "Usuario inválido"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let project_id = match uuid::Uuid::parse_str(&req.project_id) {
|
||||||
|
Ok(project_id) => project_id,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::BAD_REQUEST,
|
||||||
|
axum::Json(serde_json::json!({"error": "project_id inválido"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match user_can_access_project(&state.pool, user_id, project_id).await {
|
||||||
|
Ok(true) => {}
|
||||||
|
Ok(false) => {
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::FORBIDDEN,
|
||||||
|
axum::Json(serde_json::json!({"error": "Access denied to this project"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(error = %error, "Failed to check WebSocket project access");
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
axum::Json(serde_json::json!({"error": "Ticket creation failed"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ticket = format!("wst_{}", uuid::Uuid::new_v4().simple());
|
||||||
|
let token_hash = hash_ws_ticket(&ticket);
|
||||||
|
let expires_in = WS_TICKET_TTL_SECONDS as i64;
|
||||||
|
|
||||||
|
if let Err(error) = sqlx::query(
|
||||||
|
r#"INSERT INTO websocket_tickets (token_hash, user_id, project_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3, NOW() + ($4::text)::interval)"#,
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(format!("{} seconds", expires_in))
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(error = %error, "Failed to persist WebSocket ticket");
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
axum::Json(serde_json::json!({"error": "Ticket creation failed"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
axum::Json(CreateWsTicketResponse {
|
||||||
|
ticket,
|
||||||
|
expires_in: WS_TICKET_TTL_SECONDS,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ws_telemetry_handler(
|
async fn ws_telemetry_handler(
|
||||||
@@ -871,26 +1040,33 @@ async fn ws_telemetry_handler(
|
|||||||
query: Query<WsParams>,
|
query: Query<WsParams>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// Validar token JWT antes de upgrading la conexión WebSocket (HIGH-3)
|
let ticket = match &query.ticket {
|
||||||
let token = match &query.token {
|
Some(ticket) => ticket.as_str(),
|
||||||
Some(t) => t.as_str(),
|
|
||||||
None => {
|
None => {
|
||||||
return (
|
return (
|
||||||
axum::http::StatusCode::UNAUTHORIZED,
|
axum::http::StatusCode::UNAUTHORIZED,
|
||||||
axum::Json(serde_json::json!({"error": "Token requerido para conexión WebSocket"})),
|
axum::Json(
|
||||||
|
serde_json::json!({"error": "Ticket requerido para conexión WebSocket"}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if auth::verify_jwt(token).is_err() {
|
|
||||||
return (
|
|
||||||
axum::http::StatusCode::UNAUTHORIZED,
|
|
||||||
axum::Json(serde_json::json!({"error": "Token inválido o expirado"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let project_id = query.project_id.clone();
|
let project_id = match consume_ws_ticket(&state.pool, ticket, query.project_id.as_deref()).await
|
||||||
|
{
|
||||||
|
Ok(project_id) => project_id,
|
||||||
|
Err(status) => {
|
||||||
|
let message = if status == axum::http::StatusCode::FORBIDDEN {
|
||||||
|
"Ticket no corresponde al proyecto solicitado"
|
||||||
|
} else {
|
||||||
|
"Ticket inválido o expirado"
|
||||||
|
};
|
||||||
|
return (status, axum::Json(serde_json::json!({"error": message}))).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let project_id = Some(project_id.to_string());
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"New WebSocket upgrade request for telemetry. Filter: {:?}",
|
"New WebSocket upgrade request for telemetry. Filter: {:?}",
|
||||||
project_id
|
project_id
|
||||||
@@ -898,6 +1074,72 @@ async fn ws_telemetry_handler(
|
|||||||
ws.on_upgrade(move |socket| handle_socket(socket, state, project_id))
|
ws.on_upgrade(move |socket| handle_socket(socket, state, project_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hash_ws_ticket(ticket: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(ticket.as_bytes());
|
||||||
|
hasher
|
||||||
|
.finalize()
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn user_can_access_project(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
project_id: uuid::Uuid,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let access = sqlx::query(
|
||||||
|
r#"SELECT 1
|
||||||
|
FROM user_project_access
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND project_id = $2
|
||||||
|
AND is_active = true"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(project_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(access.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn consume_ws_ticket(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
ticket: &str,
|
||||||
|
requested_project_id: Option<&str>,
|
||||||
|
) -> Result<uuid::Uuid, axum::http::StatusCode> {
|
||||||
|
let token_hash = hash_ws_ticket(ticket);
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"UPDATE websocket_tickets
|
||||||
|
SET used_at = NOW()
|
||||||
|
WHERE token_hash = $1
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND expires_at > NOW()
|
||||||
|
RETURNING project_id"#,
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
tracing::error!(error = %error, "Failed to consume WebSocket ticket");
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?
|
||||||
|
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let project_id: uuid::Uuid = row.get("project_id");
|
||||||
|
|
||||||
|
if let Some(requested_project_id) = requested_project_id {
|
||||||
|
let requested = uuid::Uuid::parse_str(requested_project_id)
|
||||||
|
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
if requested != project_id {
|
||||||
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(project_id)
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_socket(mut socket: WebSocket, state: AppState, filter_project_id: Option<String>) {
|
async fn handle_socket(mut socket: WebSocket, state: AppState, filter_project_id: Option<String>) {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"🔌 WebSocket connection upgraded and active for project: {:?}",
|
"🔌 WebSocket connection upgraded and active for project: {:?}",
|
||||||
@@ -967,3 +1209,28 @@ async fn handle_socket(mut socket: WebSocket, state: AppState, filter_project_id
|
|||||||
filter_project_id
|
filter_project_id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_ticket_hash_never_stores_raw_ticket() {
|
||||||
|
let ticket = "wst_example-ticket";
|
||||||
|
let hash = hash_ws_ticket(ticket);
|
||||||
|
|
||||||
|
assert_ne!(hash, ticket);
|
||||||
|
assert_eq!(hash.len(), 64);
|
||||||
|
assert!(hash.chars().all(|ch| ch.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_ticket_migration_enforces_expiry_and_single_use_state() {
|
||||||
|
let migration = include_str!("../migrations/20260621000000_websocket_tickets.sql");
|
||||||
|
|
||||||
|
assert!(migration.contains("token_hash TEXT PRIMARY KEY"));
|
||||||
|
assert!(migration.contains("expires_at TIMESTAMPTZ NOT NULL"));
|
||||||
|
assert!(migration.contains("used_at TIMESTAMPTZ NULL"));
|
||||||
|
assert!(migration.contains("REFERENCES edge_projects(id) ON DELETE CASCADE"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
use metrics::{counter, describe_counter, describe_gauge, gauge};
|
use metrics::{counter, describe_counter, describe_gauge, gauge};
|
||||||
|
|
||||||
pub fn register_metrics() {
|
pub fn register_metrics() {
|
||||||
describe_gauge!("omnioil_active_agents", "Number of MQTT-connected edge agents");
|
describe_gauge!(
|
||||||
|
"omnioil_active_agents",
|
||||||
|
"Number of MQTT-connected edge agents"
|
||||||
|
);
|
||||||
describe_gauge!("omnioil_active_projects", "Number of active edge projects");
|
describe_gauge!("omnioil_active_projects", "Number of active edge projects");
|
||||||
describe_gauge!("omnioil_active_tags", "Total active edge variables (tags)");
|
describe_gauge!("omnioil_active_tags", "Total active edge variables (tags)");
|
||||||
describe_counter!(
|
describe_counter!(
|
||||||
@@ -33,9 +36,8 @@ pub async fn update_system_gauges(pool: &sqlx::PgPool) {
|
|||||||
{
|
{
|
||||||
gauge!("omnioil_active_projects").set(count as f64);
|
gauge!("omnioil_active_projects").set(count as f64);
|
||||||
}
|
}
|
||||||
if let Ok(count) = sqlx::query_scalar::<_, i64>(
|
if let Ok(count) =
|
||||||
"SELECT COUNT(*) FROM edge_variables WHERE is_enabled = true",
|
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM edge_variables WHERE is_enabled = true")
|
||||||
)
|
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
//! Soporta: Email (SMTP), Webhook (HTTP POST), WebSocket (in-app).
|
//! Soporta: Email (SMTP), Webhook (HTTP POST), WebSocket (in-app).
|
||||||
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::sync::Arc;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::time::Instant;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -61,6 +63,7 @@ pub async fn dispatch(
|
|||||||
let result = match cfg.channel_type.as_str() {
|
let result = match cfg.channel_type.as_str() {
|
||||||
"email" => send_email(&cfg.destination, alarm).await,
|
"email" => send_email(&cfg.destination, alarm).await,
|
||||||
"webhook" => send_webhook(&cfg.destination, alarm).await,
|
"webhook" => send_webhook(&cfg.destination, alarm).await,
|
||||||
|
"telegram" => send_telegram_alarm(&cfg.destination, alarm).await,
|
||||||
other => {
|
other => {
|
||||||
tracing::warn!("Canal de notificación no soportado: {}", other);
|
tracing::warn!("Canal de notificación no soportado: {}", other);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -91,7 +94,10 @@ pub async fn dispatch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_email(to: &str, alarm: &AlarmNotification) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn send_email(
|
||||||
|
to: &str,
|
||||||
|
alarm: &AlarmNotification,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
use lettre::{Message, SmtpTransport, Transport, transport::smtp::authentication::Credentials};
|
use lettre::{Message, SmtpTransport, Transport, transport::smtp::authentication::Credentials};
|
||||||
|
|
||||||
let smtp_host = std::env::var("SMTP_HOST").unwrap_or_default();
|
let smtp_host = std::env::var("SMTP_HOST").unwrap_or_default();
|
||||||
@@ -134,7 +140,10 @@ async fn send_email(to: &str, alarm: &AlarmNotification) -> Result<(), Box<dyn s
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_webhook(url: &str, alarm: &AlarmNotification) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn send_webhook(
|
||||||
|
url: &str,
|
||||||
|
alarm: &AlarmNotification,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.build()?;
|
.build()?;
|
||||||
@@ -159,3 +168,191 @@ async fn send_webhook(url: &str, alarm: &AlarmNotification) -> Result<(), Box<dy
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn send_telegram_alarm(
|
||||||
|
destination: &str,
|
||||||
|
alarm: &AlarmNotification,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let token = std::env::var("TELEGRAM_BOT_TOKEN")
|
||||||
|
.unwrap_or_else(|_| "8626426348:AAFNXkEMA1jgj8Z3QFeptpY8mYYg_6BHFxQ".to_string());
|
||||||
|
|
||||||
|
let chat_id = if destination.trim().is_empty() {
|
||||||
|
std::env::var("TELEGRAM_CHAT_ID").unwrap_or_else(|_| "-4336874579".to_string())
|
||||||
|
} else {
|
||||||
|
destination.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
if token.is_empty() || chat_id.is_empty() {
|
||||||
|
tracing::warn!("Telegram no configurado para alarma");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("https://api.telegram.org/bot{}/sendMessage", token);
|
||||||
|
let message = format!(
|
||||||
|
"🚨 *Alarma OmniOil — {}*\n\n\
|
||||||
|
• *Variable:* {}\n\
|
||||||
|
• *Valor:* {:.2}\n\
|
||||||
|
• *Tipo:* {}\n\
|
||||||
|
• *Mensaje:* {}\n\
|
||||||
|
• *Timestamp:* {}",
|
||||||
|
alarm.variable, alarm.variable, alarm.value, alarm.alarm_type, alarm.message, alarm.timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": message,
|
||||||
|
"parse_mode": "Markdown"
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let resp = client.post(&url).json(&payload).send().await?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("Telegram error: {}", body).into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
static LAST_SYSTEM_ALERTS: OnceLock<Mutex<HashMap<String, Instant>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Envía una alerta inmediata por Correo Electrónico y Telegram si ocurre una falla en el servidor.
|
||||||
|
/// Limita la frecuencia a una vez cada 10 minutos por contexto para evitar spam.
|
||||||
|
pub async fn send_system_alert(context: &str, error: &anyhow::Error) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let cooldown = std::time::Duration::from_secs(600); // 10 minutos
|
||||||
|
|
||||||
|
{
|
||||||
|
let map_mutex = LAST_SYSTEM_ALERTS.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
let mut map = map_mutex.lock().unwrap();
|
||||||
|
if let Some(&last_sent) = map.get(context) {
|
||||||
|
if now.duration_since(last_sent) < cooldown {
|
||||||
|
// Cooldown activo, no enviar
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.insert(context.to_string(), now);
|
||||||
|
}
|
||||||
|
|
||||||
|
let subject = format!("🚨 ERROR CRÍTICO OMNIOIL: {}", context);
|
||||||
|
let message = format!(
|
||||||
|
"Se ha detectado un problema crítico en el servidor de OmniOil.\n\n\
|
||||||
|
Contexto: {}\n\
|
||||||
|
Error: {:?}\n\
|
||||||
|
Timestamp: {}",
|
||||||
|
context,
|
||||||
|
error,
|
||||||
|
chrono::Utc::now()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Enviar a Telegram
|
||||||
|
if let Err(e) = send_telegram_direct(&message).await {
|
||||||
|
tracing::error!("Fallo al enviar alerta de sistema a Telegram: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Enviar por Correo Electrónico
|
||||||
|
let admin_email = std::env::var("ADMIN_EMAIL")
|
||||||
|
.or_else(|_| std::env::var("SMTP_FROM"))
|
||||||
|
.or_else(|_| std::env::var("SMTP_USER"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if !admin_email.is_empty() {
|
||||||
|
let msg = crate::smtp_mailer::EmailMessage {
|
||||||
|
to: &admin_email,
|
||||||
|
subject: &subject,
|
||||||
|
body: &message,
|
||||||
|
html_body: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = crate::smtp_mailer::send_email(msg).await {
|
||||||
|
tracing::error!("Fallo al enviar alerta de sistema por correo: {}", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("No se pudo enviar alerta de sistema por correo: ADMIN_EMAIL, SMTP_FROM y SMTP_USER no están configurados");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_telegram_direct(message: &str) -> anyhow::Result<()> {
|
||||||
|
let token = std::env::var("TELEGRAM_BOT_TOKEN")
|
||||||
|
.unwrap_or_else(|_| "8626426348:AAFNXkEMA1jgj8Z3QFeptpY8mYYg_6BHFxQ".to_string());
|
||||||
|
let chat_id = std::env::var("TELEGRAM_CHAT_ID")
|
||||||
|
.unwrap_or_else(|_| "-4336874579".to_string());
|
||||||
|
|
||||||
|
if token.is_empty() || chat_id.is_empty() {
|
||||||
|
tracing::warn!("Telegram no configurado: TOKEN o CHAT_ID faltantes");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("https://api.telegram.org/bot{}/sendMessage", token);
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": message
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let resp = client.post(&url).json(&payload).send().await?;
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("Telegram HTTP error {}: {}", status, body);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_send_system_alert_rate_limiting() {
|
||||||
|
let context = "test-rate-limit";
|
||||||
|
let error = anyhow::anyhow!("Test error message");
|
||||||
|
|
||||||
|
// Asegurarse de que el mapa está inicializado y limpio para el test
|
||||||
|
let map_mutex = LAST_SYSTEM_ALERTS.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
{
|
||||||
|
let mut map = map_mutex.lock().unwrap();
|
||||||
|
map.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primer envío (debería registrarse)
|
||||||
|
send_system_alert(context, &error).await;
|
||||||
|
|
||||||
|
let first_instant = {
|
||||||
|
let map = map_mutex.lock().unwrap();
|
||||||
|
let inst = map.get(context).cloned();
|
||||||
|
assert!(inst.is_some(), "Debería haberse registrado el envío de la alerta");
|
||||||
|
inst.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Segundo envío inmediato (debería ser ignorado por el cooldown, manteniendo el instante original)
|
||||||
|
send_system_alert(context, &error).await;
|
||||||
|
|
||||||
|
{
|
||||||
|
let map = map_mutex.lock().unwrap();
|
||||||
|
let second_instant = map.get(context).cloned().unwrap();
|
||||||
|
assert_eq!(first_instant, second_instant, "El segundo envío inmediato no debería actualizar el timestamp debido al cooldown");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simular que el cooldown ya pasó (retrocediendo el tiempo de envío en el mapa)
|
||||||
|
{
|
||||||
|
let mut map = map_mutex.lock().unwrap();
|
||||||
|
map.insert(context.to_string(), Instant::now() - Duration::from_secs(650));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tercer envío después del cooldown (debería registrarse y actualizar el timestamp)
|
||||||
|
send_system_alert(context, &error).await;
|
||||||
|
|
||||||
|
{
|
||||||
|
let map = map_mutex.lock().unwrap();
|
||||||
|
let third_instant = map.get(context).cloned().unwrap();
|
||||||
|
assert!(third_instant > first_instant, "El envío después del cooldown debería actualizar el timestamp");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
use lettre::{Message, SmtpTransport, Transport, transport::smtp::authentication::Credentials};
|
use lettre::{
|
||||||
|
Message, SmtpTransport, Transport,
|
||||||
|
message::{Mailbox, MultiPart, SinglePart, header::ContentType},
|
||||||
|
transport::smtp::authentication::Credentials,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum SmtpTlsMode {
|
enum SmtpTlsMode {
|
||||||
@@ -10,6 +14,7 @@ pub struct EmailMessage<'a> {
|
|||||||
pub to: &'a str,
|
pub to: &'a str,
|
||||||
pub subject: &'a str,
|
pub subject: &'a str,
|
||||||
pub body: &'a str,
|
pub body: &'a str,
|
||||||
|
pub html_body: Option<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_tls_mode(value: Option<&str>) -> anyhow::Result<SmtpTlsMode> {
|
fn parse_tls_mode(value: Option<&str>) -> anyhow::Result<SmtpTlsMode> {
|
||||||
@@ -28,6 +33,15 @@ fn should_apply_credentials(user: &str, pass: &str) -> bool {
|
|||||||
!user.trim().is_empty() || !pass.trim().is_empty()
|
!user.trim().is_empty() || !pass.trim().is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn smtp_from_mailbox(value: &str) -> anyhow::Result<Mailbox> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.contains('<') {
|
||||||
|
return Ok(trimmed.parse()?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(format!("OmniOil <{trimmed}>").parse()?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn send_email(message: EmailMessage<'_>) -> anyhow::Result<()> {
|
pub async fn send_email(message: EmailMessage<'_>) -> anyhow::Result<()> {
|
||||||
let smtp_host = std::env::var("SMTP_HOST").unwrap_or_default();
|
let smtp_host = std::env::var("SMTP_HOST").unwrap_or_default();
|
||||||
let smtp_host = smtp_host.trim().to_string();
|
let smtp_host = smtp_host.trim().to_string();
|
||||||
@@ -49,11 +63,28 @@ pub async fn send_email(message: EmailMessage<'_>) -> anyhow::Result<()> {
|
|||||||
let smtp_from = std::env::var("SMTP_FROM").unwrap_or_else(|_| smtp_user.clone());
|
let smtp_from = std::env::var("SMTP_FROM").unwrap_or_else(|_| smtp_user.clone());
|
||||||
let tls_mode = parse_tls_mode(std::env::var("SMTP_TLS_MODE").ok().as_deref())?;
|
let tls_mode = parse_tls_mode(std::env::var("SMTP_TLS_MODE").ok().as_deref())?;
|
||||||
|
|
||||||
let email = Message::builder()
|
let email_builder = Message::builder()
|
||||||
.from(format!("OmniOil <{}>", smtp_from).parse()?)
|
.from(smtp_from_mailbox(&smtp_from)?)
|
||||||
.to(message.to.parse()?)
|
.to(message.to.parse()?)
|
||||||
.subject(message.subject)
|
.subject(message.subject);
|
||||||
.body(message.body.to_string())?;
|
|
||||||
|
let email = if let Some(html_body) = message.html_body {
|
||||||
|
email_builder.multipart(
|
||||||
|
MultiPart::alternative()
|
||||||
|
.singlepart(
|
||||||
|
SinglePart::builder()
|
||||||
|
.header(ContentType::TEXT_PLAIN)
|
||||||
|
.body(message.body.to_string()),
|
||||||
|
)
|
||||||
|
.singlepart(
|
||||||
|
SinglePart::builder()
|
||||||
|
.header(ContentType::TEXT_HTML)
|
||||||
|
.body(html_body.to_string()),
|
||||||
|
),
|
||||||
|
)?
|
||||||
|
} else {
|
||||||
|
email_builder.body(message.body.to_string())?
|
||||||
|
};
|
||||||
|
|
||||||
let mut mailer_builder = match tls_mode {
|
let mut mailer_builder = match tls_mode {
|
||||||
SmtpTlsMode::StartTls => SmtpTransport::starttls_relay(&smtp_host)?,
|
SmtpTlsMode::StartTls => SmtpTransport::starttls_relay(&smtp_host)?,
|
||||||
@@ -74,7 +105,10 @@ pub async fn send_email(message: EmailMessage<'_>) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{EmailMessage, SmtpTlsMode, parse_tls_mode, send_email, should_apply_credentials};
|
use super::{
|
||||||
|
EmailMessage, SmtpTlsMode, parse_tls_mode, send_email, should_apply_credentials,
|
||||||
|
smtp_from_mailbox,
|
||||||
|
};
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
fn env_lock() -> &'static Mutex<()> {
|
fn env_lock() -> &'static Mutex<()> {
|
||||||
@@ -93,6 +127,7 @@ mod tests {
|
|||||||
to: "customer@example.com",
|
to: "customer@example.com",
|
||||||
subject: "Tu acceso a OmniOil fue aprobado",
|
subject: "Tu acceso a OmniOil fue aprobado",
|
||||||
body: "Activation instructions",
|
body: "Activation instructions",
|
||||||
|
html_body: None,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -114,6 +149,7 @@ mod tests {
|
|||||||
to: "customer@example.com",
|
to: "customer@example.com",
|
||||||
subject: "Nuevo proyecto habilitado en OmniOil",
|
subject: "Nuevo proyecto habilitado en OmniOil",
|
||||||
body: "Access is ready",
|
body: "Access is ready",
|
||||||
|
html_body: None,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -153,4 +189,16 @@ mod tests {
|
|||||||
assert!(should_apply_credentials("user", ""));
|
assert!(should_apply_credentials("user", ""));
|
||||||
assert!(should_apply_credentials("", "password"));
|
assert!(should_apply_credentials("", "password"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smtp_from_accepts_raw_email_or_full_mailbox() {
|
||||||
|
let raw = smtp_from_mailbox("soporte@omnioil.app").expect("raw sender should parse");
|
||||||
|
let full =
|
||||||
|
smtp_from_mailbox("OmniOil <soporte@omnioil.app>").expect("full sender should parse");
|
||||||
|
|
||||||
|
assert_eq!(raw.email.to_string(), "soporte@omnioil.app");
|
||||||
|
assert_eq!(raw.name.as_deref(), Some("OmniOil"));
|
||||||
|
assert_eq!(full.email.to_string(), "soporte@omnioil.app");
|
||||||
|
assert_eq!(full.name.as_deref(), Some("OmniOil"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,8 @@ where
|
|||||||
{
|
{
|
||||||
type Rejection = Response;
|
type Rejection = Response;
|
||||||
|
|
||||||
async fn from_request(
|
async fn from_request(req: axum::extract::Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
req: axum::extract::Request,
|
let Json(value) = Json::<T>::from_request(req, state).await.map_err(|e| {
|
||||||
state: &S,
|
|
||||||
) -> Result<Self, Self::Rejection> {
|
|
||||||
let Json(value) = Json::<T>::from_request(req, state)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
(
|
(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(serde_json::json!({ "error": e.to_string() })),
|
Json(serde_json::json!({ "error": e.to_string() })),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use crate::notifier::{self, AlarmNotification};
|
|
||||||
use crate::handlers::billing::take_daily_snapshots;
|
use crate::handlers::billing::take_daily_snapshots;
|
||||||
|
use crate::notifier::{self, AlarmNotification};
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use chrono::Utc;
|
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -18,27 +18,43 @@ pub async fn run_watchdog(pool: PgPool, tx: broadcast::Sender<crate::TelemetryEv
|
|||||||
|
|
||||||
if let Err(e) = check_telemetry_silence(&pool, &tx).await {
|
if let Err(e) = check_telemetry_silence(&pool, &tx).await {
|
||||||
tracing::error!("Watchdog error (silence check): {}", e);
|
tracing::error!("Watchdog error (silence check): {}", e);
|
||||||
|
notifier::send_system_alert("silence check", &e).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = check_unhealthy_agents(&pool, &tx).await {
|
if let Err(e) = check_unhealthy_agents(&pool, &tx).await {
|
||||||
tracing::error!("Watchdog error (health check): {}", e);
|
tracing::error!("Watchdog error (health check): {}", e);
|
||||||
|
notifier::send_system_alert("health check", &e).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = check_report_review_deadlines(&pool).await {
|
||||||
|
tracing::error!("Watchdog error (report review deadline check): {}", e);
|
||||||
|
notifier::send_system_alert("report review deadline check", &e).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::metrics::update_system_gauges(&pool).await;
|
crate::metrics::update_system_gauges(&pool).await;
|
||||||
|
|
||||||
// Limpieza diaria: borrar logs de agente con más de 6 meses (tick cada 60s → 1440 = 24h)
|
// Limpieza diaria: borrar logs de agente con más de 6 meses (tick cada 60s → 1440 = 24h)
|
||||||
if tick_count % 1440 == 0 {
|
if tick_count % 1440 == 1 {
|
||||||
if let Err(e) = cleanup_old_agent_logs(&pool).await {
|
if let Err(e) = cleanup_old_agent_logs(&pool).await {
|
||||||
tracing::error!("Watchdog error (log cleanup): {}", e);
|
tracing::error!("Watchdog error (log cleanup): {}", e);
|
||||||
|
notifier::send_system_alert("log cleanup", &anyhow::Error::new(e)).await;
|
||||||
}
|
}
|
||||||
if let Err(e) = take_daily_snapshots(&pool).await {
|
if let Err(e) = take_daily_snapshots(&pool).await {
|
||||||
tracing::error!("Watchdog error (usage snapshots): {}", e);
|
tracing::error!("Watchdog error (usage snapshots): {}", e);
|
||||||
|
notifier::send_system_alert("usage snapshots", &e).await;
|
||||||
|
}
|
||||||
|
if let Err(e) = check_calibration_expirations(&pool, &tx).await {
|
||||||
|
tracing::error!("Watchdog error (calibration check): {}", e);
|
||||||
|
notifier::send_system_alert("calibration check", &e).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_telemetry_silence(pool: &PgPool, tx: &broadcast::Sender<crate::TelemetryEvent>) -> anyhow::Result<()> {
|
async fn check_telemetry_silence(
|
||||||
|
pool: &PgPool,
|
||||||
|
tx: &broadcast::Sender<crate::TelemetryEvent>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
// Buscar proyectos activos que NO hayan tenido telemetría en los últimos 10 minutos
|
// Buscar proyectos activos que NO hayan tenido telemetría en los últimos 10 minutos
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -53,7 +69,7 @@ async fn check_telemetry_silence(pool: &PgPool, tx: &broadcast::Sender<crate::Te
|
|||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1 FROM edge_assets a WHERE a.project_id = p.id
|
SELECT 1 FROM edge_assets a WHERE a.project_id = p.id
|
||||||
)
|
)
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -61,7 +77,10 @@ async fn check_telemetry_silence(pool: &PgPool, tx: &broadcast::Sender<crate::Te
|
|||||||
for row in rows {
|
for row in rows {
|
||||||
let proj_id: Uuid = row.get("id");
|
let proj_id: Uuid = row.get("id");
|
||||||
let proj_name: String = row.get("name");
|
let proj_name: String = row.get("name");
|
||||||
let msg = format!("ALERTA ANH: No se recibe telemetría del proyecto {} hace más de 10 min.", proj_name);
|
let msg = format!(
|
||||||
|
"ALERTA ANH: No se recibe telemetría del proyecto {} hace más de 10 min.",
|
||||||
|
proj_name
|
||||||
|
);
|
||||||
|
|
||||||
let already_alerted = sqlx::query(
|
let already_alerted = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -70,7 +89,7 @@ async fn check_telemetry_silence(pool: &PgPool, tx: &broadcast::Sender<crate::Te
|
|||||||
AND alarm_type = 'TELEMETRY_SILENCE'
|
AND alarm_type = 'TELEMETRY_SILENCE'
|
||||||
AND timestamp > NOW() - INTERVAL '1 hour'
|
AND timestamp > NOW() - INTERVAL '1 hour'
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.bind(proj_id)
|
.bind(proj_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
@@ -105,27 +124,25 @@ async fn check_telemetry_silence(pool: &PgPool, tx: &broadcast::Sender<crate::Te
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_unhealthy_agents(pool: &PgPool, tx: &broadcast::Sender<crate::TelemetryEvent>) -> anyhow::Result<()> {
|
async fn check_unhealthy_agents(
|
||||||
// Proyectos con heartbeats perdidos (> 5 minutos)
|
pool: &PgPool,
|
||||||
let lost_agents = sqlx::query(
|
tx: &broadcast::Sender<crate::TelemetryEvent>,
|
||||||
r#"
|
) -> anyhow::Result<()> {
|
||||||
SELECT id, name
|
let lost_agents = sqlx::query(unhealthy_agents_select_sql())
|
||||||
FROM edge_projects
|
|
||||||
WHERE is_active = true
|
|
||||||
AND (last_health_report->>'timestamp')::TIMESTAMPTZ < NOW() - INTERVAL '5 minutes'
|
|
||||||
"#
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for row in lost_agents {
|
for row in lost_agents {
|
||||||
let agent_id: Uuid = row.get("id");
|
let agent_id: Uuid = row.get("id");
|
||||||
let agent_name: String = row.get("name");
|
let agent_name: String = row.get("name");
|
||||||
let msg = format!("Falla de Comunicación: El agente del proyecto {} no responde.", agent_name);
|
let msg = format!(
|
||||||
|
"Falla de Comunicación: El agente del proyecto {} no responde.",
|
||||||
|
agent_name
|
||||||
|
);
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
|
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
|
||||||
VALUES ($1, 'SYSTEM_WATCHDOG', 'AGENT_OFFLINE', $2, $3)"
|
VALUES ($1, 'SYSTEM_WATCHDOG', 'AGENT_OFFLINE', $2, $3)",
|
||||||
)
|
)
|
||||||
.bind(agent_id)
|
.bind(agent_id)
|
||||||
.bind(&msg)
|
.bind(&msg)
|
||||||
@@ -148,11 +165,37 @@ async fn check_unhealthy_agents(pool: &PgPool, tx: &broadcast::Sender<crate::Tel
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unhealthy_agents_select_sql() -> &'static str {
|
||||||
|
r#"
|
||||||
|
SELECT p.id, p.name
|
||||||
|
FROM edge_projects p
|
||||||
|
WHERE p.is_active = true
|
||||||
|
AND p.installer_status = 'COMPLETED'
|
||||||
|
AND btrim(p.contract_number) <> ''
|
||||||
|
AND upper(btrim(p.contract_number)) NOT LIKE 'PENDING-%'
|
||||||
|
AND upper(btrim(p.contract_number)) NOT LIKE 'TRIAL-%'
|
||||||
|
AND p.last_health_report ? 'timestamp'
|
||||||
|
AND jsonb_typeof(p.last_health_report->'timestamp') = 'string'
|
||||||
|
AND (p.last_health_report->>'timestamp') ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T'
|
||||||
|
AND (p.last_health_report->>'timestamp')::TIMESTAMPTZ < NOW() - INTERVAL '5 minutes'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM edge_assets a
|
||||||
|
WHERE a.project_id = p.id
|
||||||
|
AND a.is_active = true
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM telemetry_alarms ta
|
||||||
|
WHERE ta.project_id = p.id
|
||||||
|
AND ta.alarm_type = 'AGENT_OFFLINE'
|
||||||
|
AND ta.timestamp > NOW() - INTERVAL '1 hour'
|
||||||
|
)
|
||||||
|
"#
|
||||||
|
}
|
||||||
|
|
||||||
/// Elimina logs de agente más antiguos de 6 meses (retención máxima para visualización en tiempo real).
|
/// Elimina logs de agente más antiguos de 6 meses (retención máxima para visualización en tiempo real).
|
||||||
async fn cleanup_old_agent_logs(pool: &PgPool) -> Result<(), sqlx::Error> {
|
async fn cleanup_old_agent_logs(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||||
let result = sqlx::query(
|
let result =
|
||||||
"DELETE FROM agent_logs WHERE timestamp < NOW() - INTERVAL '6 months'"
|
sqlx::query("DELETE FROM agent_logs WHERE timestamp < NOW() - INTERVAL '6 months'")
|
||||||
)
|
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -165,3 +208,321 @@ async fn cleanup_old_agent_logs(pool: &PgPool) -> Result<(), sqlx::Error> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn check_report_review_deadlines(pool: &PgPool) -> anyhow::Result<()> {
|
||||||
|
use shared_lib::models::ANHReport;
|
||||||
|
|
||||||
|
// Buscar reportes generados que siguen pendientes de revisión manual.
|
||||||
|
let pending_reports = sqlx::query_as::<_, ANHReport>(report_review_deadline_select_sql())
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for report in pending_reports {
|
||||||
|
if let Some(start) = report.period_start {
|
||||||
|
// Default: 06:00 AM COT (Bogota), matching official daily ANH generation.
|
||||||
|
let mut local_time = default_review_cutoff_time().format("%H:%M").to_string();
|
||||||
|
let mut timezone = "America/Bogota".to_string();
|
||||||
|
|
||||||
|
// Si el reporte tiene una programación asignada, cargamos su hora y zona horaria
|
||||||
|
if let Some(sched_id) = report.schedule_id {
|
||||||
|
if let Ok(Some(sched)) = sqlx::query_as::<_, shared_lib::models::ANHReportSchedule>(
|
||||||
|
"SELECT id, operator_name, contract_number, to_char(local_time, 'HH24:MI') as local_time, timezone, is_active, next_run_at, last_run_at, last_status, last_error, claim_token, claimed_at, created_at, updated_at, created_by, updated_by FROM anh_report_schedules WHERE id = $1"
|
||||||
|
)
|
||||||
|
.bind(sched_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await {
|
||||||
|
local_time = sched.local_time;
|
||||||
|
timezone = sched.timezone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsear zona horaria
|
||||||
|
let tz = timezone
|
||||||
|
.parse::<chrono_tz::Tz>()
|
||||||
|
.unwrap_or(chrono_tz::America::Bogota);
|
||||||
|
let local_start = start.with_timezone(&tz);
|
||||||
|
let local_date = local_start.date_naive();
|
||||||
|
|
||||||
|
// Límite de revisión (cutoff) es al día siguiente (D+1) a la hora configurada (local_time)
|
||||||
|
let cutoff_date = local_date.succ_opt().unwrap_or(local_date);
|
||||||
|
let time = parse_review_cutoff_time(&local_time);
|
||||||
|
|
||||||
|
let local_cutoff = cutoff_date.and_time(time);
|
||||||
|
let deadline = match tz.from_local_datetime(&local_cutoff) {
|
||||||
|
chrono::LocalResult::Single(dt) => dt.with_timezone(&chrono::Utc),
|
||||||
|
chrono::LocalResult::Ambiguous(earliest, _) => earliest.with_timezone(&chrono::Utc),
|
||||||
|
chrono::LocalResult::None => {
|
||||||
|
start + chrono::Duration::days(1) + chrono::Duration::hours(12)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if chrono::Utc::now() >= deadline {
|
||||||
|
tracing::warn!(
|
||||||
|
"{}",
|
||||||
|
review_deadline_reached_message(&report.filename, report.id, deadline)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-sellado
|
||||||
|
let sealed = if report.object_key.is_some() {
|
||||||
|
report
|
||||||
|
} else {
|
||||||
|
let final_json_bytes =
|
||||||
|
crate::handlers::anh_reports::final_json_bytes_for_sealing(&report.content)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Error de formato JSON: {:?}", e))?;
|
||||||
|
|
||||||
|
crate::handlers::anh_reports::seal_report_artifact(
|
||||||
|
pool,
|
||||||
|
report.id,
|
||||||
|
&report.filename,
|
||||||
|
&final_json_bytes,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Error al sellar reporte: {:?}", e))?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-aprobación (status = 'APPROVED')
|
||||||
|
let updated = sqlx::query_as::<_, ANHReport>(
|
||||||
|
r#"UPDATE anh_reports
|
||||||
|
SET status = 'APPROVED',
|
||||||
|
approved_at = NOW(),
|
||||||
|
approved_by = NULL
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *"#,
|
||||||
|
)
|
||||||
|
.bind(sealed.id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Registrar en la auditoría
|
||||||
|
crate::audit::log(
|
||||||
|
pool,
|
||||||
|
crate::audit::AuditEntry::new("anh_report.auto_approve")
|
||||||
|
.with_resource(updated.id.to_string())
|
||||||
|
.with_metadata(serde_json::json!({
|
||||||
|
"report_id": updated.id,
|
||||||
|
"filename": updated.filename,
|
||||||
|
"hash_sha384": updated.hash_sha384,
|
||||||
|
"reason": automatic_approval_audit_reason(deadline),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"✅ Reporte {} (ID: {}) aprobado automáticamente por el sistema.",
|
||||||
|
updated.filename,
|
||||||
|
updated.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn report_review_deadline_select_sql() -> &'static str {
|
||||||
|
"SELECT * FROM anh_reports WHERE status = 'GENERATED' AND correction_version = 0"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn review_deadline_reached_message(
|
||||||
|
filename: &str,
|
||||||
|
report_id: Uuid,
|
||||||
|
deadline: chrono::DateTime<Utc>,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
"⏰ Límite de revisión alcanzado (cutoff {}) para el reporte de {} (ID: {}). Se procederá con el sellado y aprobación preventiva.",
|
||||||
|
deadline, filename, report_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn automatic_approval_audit_reason(deadline: chrono::DateTime<Utc>) -> String {
|
||||||
|
format!(
|
||||||
|
"Aprobación preventiva por límite de tiempo de corte ({}) alcanzado",
|
||||||
|
deadline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_review_cutoff_time() -> chrono::NaiveTime {
|
||||||
|
chrono::NaiveTime::from_hms_opt(6, 0, 0).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_review_cutoff_time(local_time: &str) -> chrono::NaiveTime {
|
||||||
|
chrono::NaiveTime::parse_from_str(local_time, "%H:%M")
|
||||||
|
.unwrap_or_else(|_| default_review_cutoff_time())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_review_deadline_select_only_targets_base_generated_drafts() {
|
||||||
|
let sql = report_review_deadline_select_sql();
|
||||||
|
|
||||||
|
assert!(sql.contains("status = 'GENERATED'"));
|
||||||
|
assert!(sql.contains("correction_version = 0"));
|
||||||
|
assert!(!sql.contains("CORRECTION_DRAFT"));
|
||||||
|
assert!(!sql.contains("CORRECTED_APPROVED"));
|
||||||
|
assert!(!sql.contains("AVAILABLE_FOR_ANH"));
|
||||||
|
assert!(!sql.contains("SENT"));
|
||||||
|
assert!(!sql.contains("DISPOSED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watchdog_review_deadline_copy_uses_approval_not_outbound_send_terms() {
|
||||||
|
let deadline = chrono::DateTime::parse_from_rfc3339("2026-06-16T12:00:00Z")
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&Utc);
|
||||||
|
let message = review_deadline_reached_message(
|
||||||
|
"report.json",
|
||||||
|
Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
|
||||||
|
deadline,
|
||||||
|
);
|
||||||
|
let reason = automatic_approval_audit_reason(deadline);
|
||||||
|
let combined = format!("{} {}", message, reason).to_lowercase();
|
||||||
|
|
||||||
|
assert!(combined.contains("aprob"));
|
||||||
|
assert!(combined.contains("sell"));
|
||||||
|
assert!(!combined.contains("auto-env"));
|
||||||
|
assert!(!combined.contains("envío"));
|
||||||
|
assert!(!combined.contains("enviar"));
|
||||||
|
assert!(!combined.contains("send"));
|
||||||
|
assert!(!combined.contains("sent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watchdog_default_review_cutoff_aligns_with_six_am_bogota() {
|
||||||
|
assert_eq!(
|
||||||
|
default_review_cutoff_time(),
|
||||||
|
chrono::NaiveTime::from_hms_opt(6, 0, 0).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_review_cutoff_time("invalid"),
|
||||||
|
chrono::NaiveTime::from_hms_opt(6, 0, 0).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unhealthy_agents_select_requires_operational_projects_with_prior_health() {
|
||||||
|
let sql = unhealthy_agents_select_sql();
|
||||||
|
|
||||||
|
assert!(sql.contains("p.is_active = true"));
|
||||||
|
assert!(sql.contains("p.installer_status = 'COMPLETED'"));
|
||||||
|
assert!(sql.contains("last_health_report ? 'timestamp'"));
|
||||||
|
assert!(sql.contains("jsonb_typeof(p.last_health_report->'timestamp') = 'string'"));
|
||||||
|
assert!(sql.contains("~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T'"));
|
||||||
|
assert!(sql.contains(
|
||||||
|
"(p.last_health_report->>'timestamp')::TIMESTAMPTZ < NOW() - INTERVAL '5 minutes'"
|
||||||
|
));
|
||||||
|
assert!(sql.contains("EXISTS ("));
|
||||||
|
assert!(sql.contains("FROM edge_assets a"));
|
||||||
|
assert!(sql.contains("a.project_id = p.id"));
|
||||||
|
assert!(sql.contains("a.is_active = true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unhealthy_agents_select_skips_pending_trial_and_recent_offline_noise() {
|
||||||
|
let sql = unhealthy_agents_select_sql();
|
||||||
|
|
||||||
|
assert!(sql.contains("upper(btrim(p.contract_number)) NOT LIKE 'PENDING-%'"));
|
||||||
|
assert!(sql.contains("upper(btrim(p.contract_number)) NOT LIKE 'TRIAL-%'"));
|
||||||
|
assert!(sql.contains("NOT EXISTS ("));
|
||||||
|
assert!(sql.contains("FROM telemetry_alarms ta"));
|
||||||
|
assert!(sql.contains("ta.project_id = p.id"));
|
||||||
|
assert!(sql.contains("ta.alarm_type = 'AGENT_OFFLINE'"));
|
||||||
|
assert!(sql.contains("ta.timestamp > NOW() - INTERVAL '1 hour'"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_calibration_expirations(
|
||||||
|
pool: &PgPool,
|
||||||
|
tx: &broadcast::Sender<crate::TelemetryEvent>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT id, project_id, name, code, next_calibration_date
|
||||||
|
FROM edge_assets
|
||||||
|
WHERE is_active = true
|
||||||
|
AND next_calibration_date <= NOW() + INTERVAL '30 days'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let asset_id: Uuid = row.get("id");
|
||||||
|
let project_id: Uuid = row.get("project_id");
|
||||||
|
let name: String = row.get("name");
|
||||||
|
let code: String = row.get("code");
|
||||||
|
let next_cal: chrono::NaiveDate = row.get("next_calibration_date");
|
||||||
|
|
||||||
|
let today = Utc::now().date_naive();
|
||||||
|
let days_left = (next_cal - today).num_days();
|
||||||
|
|
||||||
|
let alarm_type = if days_left < 0 {
|
||||||
|
"CALIBRATION_EXPIRED"
|
||||||
|
} else {
|
||||||
|
"CALIBRATION_WARNING"
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = if days_left < 0 {
|
||||||
|
format!(
|
||||||
|
"ALERTA METROLOGÍA: La calibración para el activo {} ({}) venció hace {} días el {}.",
|
||||||
|
name, code, -days_left, next_cal
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"ALERTA METROLOGÍA: La calibración para el activo {} ({}) vencerá en {} días el {}.",
|
||||||
|
name, code, days_left, next_cal
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Evitar duplicar alertas recientes del mismo tipo en las últimas 24 horas para este activo
|
||||||
|
let already_alerted = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT 1 FROM telemetry_alarms
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND device_name = $2
|
||||||
|
AND alarm_type = $3
|
||||||
|
AND timestamp > NOW() - INTERVAL '24 hours'
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(&code)
|
||||||
|
.bind(alarm_type)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if already_alerted.is_none() {
|
||||||
|
tracing::warn!(
|
||||||
|
"⚠️ Calibración vencida/por vencer en activo {}: {}",
|
||||||
|
code,
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)"
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(&code)
|
||||||
|
.bind(alarm_type)
|
||||||
|
.bind(&msg)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let alarm = AlarmNotification {
|
||||||
|
project_id,
|
||||||
|
asset_id: Some(asset_id),
|
||||||
|
variable: "next_calibration_date".to_string(),
|
||||||
|
value: days_left as f64,
|
||||||
|
alarm_type: alarm_type.to_string(),
|
||||||
|
message: msg,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
};
|
||||||
|
notifier::dispatch(pool, &alarm, tx).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -283,6 +283,10 @@ async fn approve_pending_and_in_review_requests_provision_customer_access() {
|
|||||||
assert!(row.provisioned_user_id.is_some());
|
assert!(row.provisioned_user_id.is_some());
|
||||||
assert!(row.provisioned_project_id.is_some());
|
assert!(row.provisioned_project_id.is_some());
|
||||||
assert!(row.provisioned_at.is_some());
|
assert!(row.provisioned_at.is_some());
|
||||||
|
assert!(
|
||||||
|
row.trial_ends_at.is_some(),
|
||||||
|
"approved access request response should expose subscription trial expiry"
|
||||||
|
);
|
||||||
assert_eq!(row.invitation_email_status.as_deref(), Some("failed"));
|
assert_eq!(row.invitation_email_status.as_deref(), Some("failed"));
|
||||||
|
|
||||||
let counts = sqlx::query(
|
let counts = sqlx::query(
|
||||||
@@ -337,6 +341,10 @@ async fn approve_pending_and_in_review_requests_provision_customer_access() {
|
|||||||
let active_request_id = seed_access_request(&pool, "pending", Some(active_email.clone())).await;
|
let active_request_id = seed_access_request(&pool, "pending", Some(active_email.clone())).await;
|
||||||
let active_row = approve_request(&pool, operator_id, active_request_id).await;
|
let active_row = approve_request(&pool, operator_id, active_request_id).await;
|
||||||
assert_eq!(active_row.provisioned_user_id, Some(active_user_id));
|
assert_eq!(active_row.provisioned_user_id, Some(active_user_id));
|
||||||
|
assert!(
|
||||||
|
active_row.trial_ends_at.is_some(),
|
||||||
|
"active existing users still receive a provisioned project trial expiry"
|
||||||
|
);
|
||||||
let active_user_state = sqlx::query("SELECT password_hash, is_active FROM users WHERE id = $1")
|
let active_user_state = sqlx::query("SELECT password_hash, is_active FROM users WHERE id = $1")
|
||||||
.bind(active_user_id)
|
.bind(active_user_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
|
|||||||
174
services/backend-api/tests/scada_protocols_import_integration.rs
Normal file
174
services/backend-api/tests/scada_protocols_import_integration.rs
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
//! DB-backed integration test for SCADA protocols and CSV import.
|
||||||
|
//!
|
||||||
|
//! Run with:
|
||||||
|
//! ```powershell
|
||||||
|
//! $env:DATABASE_URL='postgres://postgres:postgres@localhost:5432/omnioil_access_request_test'
|
||||||
|
//! cargo test -p backend-api --test scada_protocols_import_integration -- --ignored --nocapture
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use backend_api::{
|
||||||
|
auth::Claims,
|
||||||
|
handlers::auth::{PublicRegisterPayload, register},
|
||||||
|
handlers::import::import_csv_by_protocol,
|
||||||
|
};
|
||||||
|
use sqlx::{PgPool, Row, postgres::PgPoolOptions};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn required_database_url() -> String {
|
||||||
|
std::env::var("DATABASE_URL")
|
||||||
|
.expect("DATABASE_URL must be set explicitly for integration tests.")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn migrated_pool() -> PgPool {
|
||||||
|
let database_url = required_database_url();
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&database_url)
|
||||||
|
.await
|
||||||
|
.expect("DATABASE_URL must point to a reachable disposable PostgreSQL database");
|
||||||
|
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.expect("migrations must apply cleanly");
|
||||||
|
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires explicit DATABASE_URL pointing at a disposable migrated PostgreSQL database"]
|
||||||
|
async fn test_public_registration_and_csv_import() {
|
||||||
|
let pool = migrated_pool().await;
|
||||||
|
|
||||||
|
// Create AppState stub
|
||||||
|
let (mqtt_client, _eventloop) = rumqttc::AsyncClient::new(
|
||||||
|
rumqttc::MqttOptions::new("test-client", "localhost", 1883),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
let (tx, _) = tokio::sync::broadcast::channel(100);
|
||||||
|
let state = backend_api::AppState {
|
||||||
|
pool: pool.clone(),
|
||||||
|
mqtt_client,
|
||||||
|
tx,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Test registration
|
||||||
|
let unique_email = format!("client-{}@example.test", Uuid::new_v4().simple());
|
||||||
|
let payload = PublicRegisterPayload {
|
||||||
|
email: unique_email.clone(),
|
||||||
|
password: "SuperSecurePassword123!".to_string(),
|
||||||
|
full_name: "John Doe".to_string(),
|
||||||
|
company: "Acme Oil Corp".to_string(),
|
||||||
|
nit: "123456789-0".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let headers = axum::http::HeaderMap::new();
|
||||||
|
let register_result = register(
|
||||||
|
State(state.clone()),
|
||||||
|
headers,
|
||||||
|
backend_api::validation::ValidatedJson(payload),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(register_result.is_ok(), "Registration should succeed");
|
||||||
|
|
||||||
|
// Fetch created project ID
|
||||||
|
let project_row =
|
||||||
|
sqlx::query("SELECT id, name FROM edge_projects WHERE client = 'Acme Oil Corp'")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("Project should be created");
|
||||||
|
let project_id: Uuid = project_row.try_get("id").unwrap();
|
||||||
|
|
||||||
|
// Verify trial subscription
|
||||||
|
let sub_row = sqlx::query("SELECT status FROM subscriptions WHERE project_id = $1")
|
||||||
|
.bind(project_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("Subscription should be created");
|
||||||
|
let sub_status: String = sub_row.try_get("status").unwrap();
|
||||||
|
assert_eq!(sub_status, "trial");
|
||||||
|
|
||||||
|
// 2. Test CSV Import
|
||||||
|
let claims = Claims {
|
||||||
|
sub: Uuid::new_v4().to_string(),
|
||||||
|
role: "admin".to_string(),
|
||||||
|
email: unique_email.clone(),
|
||||||
|
projects: vec![project_id],
|
||||||
|
exp: 9999999999,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Import Modbus TCP CSV
|
||||||
|
let csv_content = "WellCode,DeviceName,Host,Port,UnitId,VariableAlias,Address,DataType\n\
|
||||||
|
WELL-A1,FlowMeter1,192.168.1.100,502,1,Temperature,HR:40001,float32\n\
|
||||||
|
WELL-A1,FlowMeter1,192.168.1.100,502,1,Pressure,HR:40002,float32\n";
|
||||||
|
|
||||||
|
let import_res = import_csv_by_protocol(
|
||||||
|
State(state.clone()),
|
||||||
|
claims.clone(),
|
||||||
|
Path((project_id, "MODBUS_TCP".to_string())),
|
||||||
|
csv_content.to_string(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(import_res.is_ok(), "CSV Import should succeed");
|
||||||
|
let import_json = import_res.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
import_json.0.get("status").unwrap().as_str().unwrap(),
|
||||||
|
"success"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
import_json
|
||||||
|
.0
|
||||||
|
.get("wells_imported")
|
||||||
|
.unwrap()
|
||||||
|
.as_i64()
|
||||||
|
.unwrap(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
import_json
|
||||||
|
.0
|
||||||
|
.get("devices_imported")
|
||||||
|
.unwrap()
|
||||||
|
.as_i64()
|
||||||
|
.unwrap(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
import_json
|
||||||
|
.0
|
||||||
|
.get("variables_imported")
|
||||||
|
.unwrap()
|
||||||
|
.as_i64()
|
||||||
|
.unwrap(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify DB entries
|
||||||
|
let asset_row =
|
||||||
|
sqlx::query("SELECT id FROM edge_assets WHERE code = 'WELL-A1' AND project_id = $1")
|
||||||
|
.bind(project_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("Asset should exist");
|
||||||
|
let asset_id: Uuid = asset_row.try_get("id").unwrap();
|
||||||
|
|
||||||
|
let device_row = sqlx::query("SELECT id, protocol FROM edge_devices WHERE asset_id = $1")
|
||||||
|
.bind(asset_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("Device should exist");
|
||||||
|
let device_id: Uuid = device_row.try_get("id").unwrap();
|
||||||
|
let protocol: String = device_row.try_get("protocol").unwrap();
|
||||||
|
assert_eq!(protocol, "MODBUS_TCP");
|
||||||
|
|
||||||
|
let var_count: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM edge_variables WHERE device_id = $1")
|
||||||
|
.bind(device_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(var_count, 2);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ FROM lukemathwalker/cargo-chef:latest-rust-1.93-slim-bookworm AS base
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Instala dependencias del sistema necesarias para compilar/linkear
|
# Instala dependencias del sistema necesarias para compilar/linkear
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
pkg-config libssl-dev libglib2.0-dev libatk1.0-dev libgtk-3-dev build-essential ca-certificates lld cmake perl \
|
pkg-config libssl-dev build-essential ca-certificates lld \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
# Linker LLD para reducir el tiempo de linkeado significativamente
|
# Linker LLD para reducir el tiempo de linkeado significativamente
|
||||||
ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld"
|
ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld"
|
||||||
|
|||||||
@@ -84,7 +84,13 @@ pub async fn handle_project_created(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
tracing::info!("🔔 MSI Worker: Notifying UI about INITIALIZING state via MQTT");
|
tracing::info!("🔔 MSI Worker: Notifying UI about INITIALIZING state via MQTT");
|
||||||
let _ = crate::publisher::publish_build_status(&mqtt_client, project_id, "INITIALIZING", "Iniciando generación de instalador...").await;
|
let _ = crate::publisher::publish_build_status(
|
||||||
|
&mqtt_client,
|
||||||
|
project_id,
|
||||||
|
"INITIALIZING",
|
||||||
|
"Iniciando generación de instalador...",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
// 1b. Obtener o generar Credenciales MQTT para este Agente
|
// 1b. Obtener o generar Credenciales MQTT para este Agente
|
||||||
let (mqtt_user, mqtt_password) = {
|
let (mqtt_user, mqtt_password) = {
|
||||||
@@ -109,9 +115,7 @@ pub async fn handle_project_created(
|
|||||||
"🔑 Generando nuevas credenciales MQTT para proyecto: {}",
|
"🔑 Generando nuevas credenciales MQTT para proyecto: {}",
|
||||||
project_id
|
project_id
|
||||||
);
|
);
|
||||||
sqlx::query(
|
sqlx::query("UPDATE edge_projects SET mqtt_password = $1 WHERE id = $2")
|
||||||
"UPDATE edge_projects SET mqtt_password = $1 WHERE id = $2",
|
|
||||||
)
|
|
||||||
.bind(&new_pass)
|
.bind(&new_pass)
|
||||||
.bind(project_id)
|
.bind(project_id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
@@ -185,9 +189,7 @@ pub async fn handle_project_created(
|
|||||||
let agent_config_toml = config_json_to_toml(&agent_config_json)
|
let agent_config_toml = config_json_to_toml(&agent_config_json)
|
||||||
.map_err(|e| format!("Error convirtiendo config a TOML: {}", e))?;
|
.map_err(|e| format!("Error convirtiendo config a TOML: {}", e))?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query("UPDATE edge_projects SET agent_config_toml = $1 WHERE id = $2")
|
||||||
"UPDATE edge_projects SET agent_config_toml = $1 WHERE id = $2",
|
|
||||||
)
|
|
||||||
.bind(&agent_config_toml)
|
.bind(&agent_config_toml)
|
||||||
.bind(project_id)
|
.bind(project_id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
@@ -249,8 +251,7 @@ pub async fn handle_project_created(
|
|||||||
|
|
||||||
// Stub de provisioning: lo único que va en el binario.
|
// Stub de provisioning: lo único que va en el binario.
|
||||||
// Apunta al broker MQTT público — el backend nunca es accesible desde el exterior.
|
// Apunta al broker MQTT público — el backend nunca es accesible desde el exterior.
|
||||||
let mqtt_public_host = std::env::var("MQTT_PUBLIC_HOST")
|
let mqtt_public_host = std::env::var("MQTT_PUBLIC_HOST").expect("MQTT_PUBLIC_HOST must be set");
|
||||||
.expect("MQTT_PUBLIC_HOST must be set");
|
|
||||||
let mqtt_public_port: u16 = std::env::var("MQTT_PUBLIC_PORT")
|
let mqtt_public_port: u16 = std::env::var("MQTT_PUBLIC_PORT")
|
||||||
.expect("MQTT_PUBLIC_PORT must be set")
|
.expect("MQTT_PUBLIC_PORT must be set")
|
||||||
.parse()
|
.parse()
|
||||||
@@ -268,7 +269,8 @@ pub async fn handle_project_created(
|
|||||||
project_id: project_id.to_string(),
|
project_id: project_id.to_string(),
|
||||||
provisioning_token,
|
provisioning_token,
|
||||||
};
|
};
|
||||||
// 2. Extraer dinámicamente la versión del edge-agent desde su Cargo.toml
|
// 2. Obtener la versión de edge-agent (prioridad: variable de entorno AGENT_LATEST_VERSION, fallback: Cargo.toml)
|
||||||
|
let version = std::env::var("AGENT_LATEST_VERSION").unwrap_or_else(|_| {
|
||||||
let edge_agent_toml_path = std::env::var("EDGE_AGENT_CARGO_TOML")
|
let edge_agent_toml_path = std::env::var("EDGE_AGENT_CARGO_TOML")
|
||||||
.unwrap_or_else(|_| "services/edge-agent/Cargo.toml".to_string());
|
.unwrap_or_else(|_| "services/edge-agent/Cargo.toml".to_string());
|
||||||
|
|
||||||
@@ -285,6 +287,8 @@ pub async fn handle_project_created(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
version
|
||||||
|
});
|
||||||
|
|
||||||
let exe_name = format!(
|
let exe_name = format!(
|
||||||
"edge-agent-v{}-{}-{}.exe",
|
"edge-agent-v{}-{}-{}.exe",
|
||||||
@@ -324,22 +328,52 @@ pub async fn handle_project_created(
|
|||||||
tracing::info!("♻️ Borrando EXE personalizado antiguo: {:?}", exe_name);
|
tracing::info!("♻️ Borrando EXE personalizado antiguo: {:?}", exe_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🛠️ COMPILACIÓN AUTOMÁTICA DEL AGENTE
|
// 🛠️ COMPILACIÓN AUTOMÁTICA DEL AGENTE (Solo si Cargo está disponible en el PATH)
|
||||||
// Nota: std::process::Command es bloqueante — se ejecuta en spawn_blocking
|
let cargo_available = std::process::Command::new("cargo")
|
||||||
// para no bloquear el runtime de tokio durante los minutos que tarda el build.
|
.arg("--version")
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if cargo_available {
|
||||||
tracing::info!("🛠️ Compilando binario base del agente (cargo build --release)...");
|
tracing::info!("🛠️ Compilando binario base del agente (cargo build --release)...");
|
||||||
let build_result = tokio::task::spawn_blocking(|| {
|
let build_result = tokio::task::spawn_blocking(|| {
|
||||||
std::process::Command::new("cargo")
|
std::process::Command::new("cargo")
|
||||||
.args(["build", "-p", "edge-agent", "--release", "--features", "edge-agent/opcua-support"])
|
.args([
|
||||||
|
"build",
|
||||||
|
"-p",
|
||||||
|
"edge-agent",
|
||||||
|
"--release",
|
||||||
|
"--features",
|
||||||
|
"edge-agent/opcua-support",
|
||||||
|
])
|
||||||
.status()
|
.status()
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match build_result {
|
match build_result {
|
||||||
Ok(Ok(s)) if s.success() => tracing::info!("✅ Compilación del agente completada con éxito."),
|
Ok(Ok(s)) if s.success() => {
|
||||||
Ok(Ok(s)) => tracing::warn!("⚠️ cargo build salió con código {:?}. Intentando usar binario existente...", s.code()),
|
tracing::info!("✅ Compilación del agente completada con éxito.")
|
||||||
Ok(Err(e)) => tracing::warn!("⚠️ No se pudo ejecutar cargo build: {}. Intentando usar binario existente...", e),
|
}
|
||||||
Err(e) => tracing::warn!("⚠️ spawn_blocking falló: {}. Intentando usar binario existente...", e),
|
Ok(Ok(s)) => tracing::warn!(
|
||||||
|
"⚠️ cargo build salió con código {:?}. Intentando usar binario existente...",
|
||||||
|
s.code()
|
||||||
|
),
|
||||||
|
Ok(Err(e)) => tracing::warn!(
|
||||||
|
"⚠️ No se pudo ejecutar cargo build: {}. Intentando usar binario existente...",
|
||||||
|
e
|
||||||
|
),
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
"⚠️ spawn_blocking falló: {}. Intentando usar binario existente...",
|
||||||
|
e
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
"ℹ️ Cargo no está disponible en este entorno (modo contenedor). Se omitirá la compilación en caliente y se usará la plantilla pre-compilada."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intentar encontrar el binario recién compilado (ahora preferimos release)
|
// Intentar encontrar el binario recién compilado (ahora preferimos release)
|
||||||
@@ -349,7 +383,8 @@ pub async fn handle_project_created(
|
|||||||
"target/debug/edge-agent.exe",
|
"target/debug/edge-agent.exe",
|
||||||
];
|
];
|
||||||
|
|
||||||
let template_path_found = template_paths.iter()
|
let template_path_found = template_paths
|
||||||
|
.iter()
|
||||||
.map(std::path::Path::new)
|
.map(std::path::Path::new)
|
||||||
.find(|p| p.exists() && p.is_file())
|
.find(|p| p.exists() && p.is_file())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -358,9 +393,13 @@ pub async fn handle_project_created(
|
|||||||
err
|
err
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
tracing::info!("📂 Usando plantilla de agente fresca desde: {:?}", template_path_found);
|
tracing::info!(
|
||||||
|
"📂 Usando plantilla de agente fresca desde: {:?}",
|
||||||
|
template_path_found
|
||||||
|
);
|
||||||
|
|
||||||
if let Err(e) = generate_provisioning_binary(template_path_found, &exe_path, &provisioning_stub) {
|
if let Err(e) = generate_provisioning_binary(template_path_found, &exe_path, &provisioning_stub)
|
||||||
|
{
|
||||||
tracing::error!("❌ Error FATAL en inyección de stub de provisioning: {}", e);
|
tracing::error!("❌ Error FATAL en inyección de stub de provisioning: {}", e);
|
||||||
let _ = update_deployment_status(
|
let _ = update_deployment_status(
|
||||||
&pool,
|
&pool,
|
||||||
@@ -386,7 +425,13 @@ pub async fn handle_project_created(
|
|||||||
|
|
||||||
tracing::info!("✅ Overlay COMPLETADO en {:?}. Esperando MSI...", exe_path);
|
tracing::info!("✅ Overlay COMPLETADO en {:?}. Esperando MSI...", exe_path);
|
||||||
tracing::info!("🔔 MSI Worker: Starting WIX_GENERATION phase");
|
tracing::info!("🔔 MSI Worker: Starting WIX_GENERATION phase");
|
||||||
let _ = crate::publisher::publish_build_status(&mqtt_client, project_id, "WIX_GENERATION", "Generando paquete MSI (WiX)...").await;
|
let _ = crate::publisher::publish_build_status(
|
||||||
|
&mqtt_client,
|
||||||
|
project_id,
|
||||||
|
"WIX_GENERATION",
|
||||||
|
"Generando paquete MSI (WiX)...",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
// Esperar a que el worker MSI procese el archivo (máx 60 segundos)
|
// Esperar a que el worker MSI procese el archivo (máx 60 segundos)
|
||||||
let mut retries = 0;
|
let mut retries = 0;
|
||||||
@@ -418,14 +463,19 @@ pub async fn handle_project_created(
|
|||||||
msi_path
|
msi_path
|
||||||
);
|
);
|
||||||
tracing::info!("🔔 MSI Worker: Starting UPLOADING phase");
|
tracing::info!("🔔 MSI Worker: Starting UPLOADING phase");
|
||||||
let _ = crate::publisher::publish_build_status(&mqtt_client, project_id, "UPLOADING", "Subiendo instalador a la nube...").await;
|
let _ = crate::publisher::publish_build_status(
|
||||||
|
&mqtt_client,
|
||||||
|
project_id,
|
||||||
|
"UPLOADING",
|
||||||
|
"Subiendo instalador a la nube...",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
// ─── Subida a MinIO ───────
|
// ─── Subida a MinIO ───────
|
||||||
let region_provider = RegionProviderChain::default_provider().or_else(Region::new("us-east-1"));
|
let region_provider = RegionProviderChain::default_provider().or_else(Region::new("us-east-1"));
|
||||||
let endpoint_url =
|
let endpoint_url = std::env::var("MINIO_ENDPOINT_URL").expect("MINIO_ENDPOINT_URL must be set");
|
||||||
std::env::var("MINIO_ENDPOINT_URL").expect("MINIO_ENDPOINT_URL must be set");
|
|
||||||
|
|
||||||
let config = aws_config::from_env()
|
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
||||||
.region(region_provider)
|
.region(region_provider)
|
||||||
.endpoint_url(&endpoint_url)
|
.endpoint_url(&endpoint_url)
|
||||||
.load()
|
.load()
|
||||||
@@ -436,8 +486,8 @@ pub async fn handle_project_created(
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let client = Client::from_conf(s3_config);
|
let client = Client::from_conf(s3_config);
|
||||||
let bucket_name = std::env::var("S3_INSTALLERS_BUCKET")
|
let bucket_name =
|
||||||
.unwrap_or_else(|_| "omnioil-releases".to_string());
|
std::env::var("S3_INSTALLERS_BUCKET").unwrap_or_else(|_| "omnioil-releases".to_string());
|
||||||
|
|
||||||
// Intentamos crear el bucket por si no existe
|
// Intentamos crear el bucket por si no existe
|
||||||
let _ = client.create_bucket().bucket(&bucket_name).send().await;
|
let _ = client.create_bucket().bucket(&bucket_name).send().await;
|
||||||
@@ -472,7 +522,13 @@ pub async fn handle_project_created(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
tracing::info!("🔔 MSI Worker: FINALIZING - Setting status to READY");
|
tracing::info!("🔔 MSI Worker: FINALIZING - Setting status to READY");
|
||||||
let _ = crate::publisher::publish_build_status(&mqtt_client, project_id, "READY", "Instalador listo para descargar").await;
|
let _ = crate::publisher::publish_build_status(
|
||||||
|
&mqtt_client,
|
||||||
|
project_id,
|
||||||
|
"READY",
|
||||||
|
"Instalador listo para descargar",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"UPDATE edge_projects SET installer_status = 'READY', installer_url = $1 WHERE id = $2"
|
"UPDATE edge_projects SET installer_status = 'READY', installer_url = $1 WHERE id = $2"
|
||||||
@@ -498,3 +554,85 @@ pub async fn handle_project_created(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn upload_generic_linux_binary() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let binary_path = std::path::Path::new("target/x86_64-unknown-linux-musl/release/edge-agent");
|
||||||
|
if !binary_path.exists() {
|
||||||
|
tracing::warn!("⚠️ No se encontró el binario genérico de Linux para el agente en: {:?}", binary_path);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("📥 Detectado binario genérico de Linux. Preparando subida a MinIO...");
|
||||||
|
|
||||||
|
// 1. Obtener la versión
|
||||||
|
let version = std::env::var("AGENT_LATEST_VERSION").unwrap_or_else(|_| {
|
||||||
|
let edge_agent_toml_path = std::env::var("EDGE_AGENT_CARGO_TOML")
|
||||||
|
.unwrap_or_else(|_| "services/edge-agent/Cargo.toml".to_string());
|
||||||
|
let edge_agent_toml = std::fs::read_to_string(&edge_agent_toml_path).unwrap_or_default();
|
||||||
|
let mut version = "0.0.0".to_string();
|
||||||
|
for line in edge_agent_toml.lines() {
|
||||||
|
if line.starts_with("version =") {
|
||||||
|
version = line
|
||||||
|
.replace("version =", "")
|
||||||
|
.replace("\"", "")
|
||||||
|
.replace("'", "")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
version
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Configurar cliente S3/MinIO
|
||||||
|
let region_provider = RegionProviderChain::default_provider().or_else(Region::new("us-east-1"));
|
||||||
|
let endpoint_url = std::env::var("MINIO_ENDPOINT_URL").expect("MINIO_ENDPOINT_URL must be set");
|
||||||
|
|
||||||
|
let access_key = std::env::var("AWS_ACCESS_KEY_ID")
|
||||||
|
.or_else(|_| std::env::var("MINIO_USER"))
|
||||||
|
.expect("S3/MinIO credentials (AWS_ACCESS_KEY_ID or MINIO_USER) must be set");
|
||||||
|
let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")
|
||||||
|
.or_else(|_| std::env::var("MINIO_PASSWORD"))
|
||||||
|
.expect("S3/MinIO credentials (AWS_SECRET_ACCESS_KEY or MINIO_PASSWORD) must be set");
|
||||||
|
|
||||||
|
let credentials = aws_sdk_s3::config::Credentials::new(
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"FromEnvOrFallback",
|
||||||
|
);
|
||||||
|
|
||||||
|
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
||||||
|
.region(region_provider)
|
||||||
|
.endpoint_url(&endpoint_url)
|
||||||
|
.credentials_provider(credentials)
|
||||||
|
.load()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let s3_config = aws_sdk_s3::config::Builder::from(&config)
|
||||||
|
.force_path_style(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let client = Client::from_conf(s3_config);
|
||||||
|
let bucket_name =
|
||||||
|
std::env::var("S3_INSTALLERS_BUCKET").unwrap_or_else(|_| "omnioil-releases".to_string());
|
||||||
|
|
||||||
|
// Crear el bucket si no existe
|
||||||
|
let _ = client.create_bucket().bucket(&bucket_name).send().await;
|
||||||
|
|
||||||
|
let body = aws_sdk_s3::primitives::ByteStream::from_path(binary_path).await?;
|
||||||
|
let key = "agents/linux/x86_64/edge-agent".to_string();
|
||||||
|
|
||||||
|
tracing::info!("🚀 Subiendo binario Linux del agente v{} a MinIO (bucket: {}, key: {})...", version, bucket_name, key);
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket_name)
|
||||||
|
.key(&key)
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!("✅ Binario Linux del agente subido exitosamente.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
mod publisher;
|
|
||||||
mod installer_worker;
|
mod installer_worker;
|
||||||
|
mod publisher;
|
||||||
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -47,7 +47,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if attempt >= 10 {
|
if attempt >= 10 {
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
tracing::warn!("⏳ DB no disponible (intento {}/10), reintentando en 3s...", attempt);
|
tracing::warn!(
|
||||||
|
"⏳ DB no disponible (intento {}/10), reintentando en 3s...",
|
||||||
|
attempt
|
||||||
|
);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,8 +66,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.expect("MQTT_PORT must be a valid number");
|
.expect("MQTT_PORT must be a valid number");
|
||||||
|
|
||||||
let client_id = format!("omnioil-build-orchestrator-{}", uuid::Uuid::new_v4());
|
let client_id = format!("omnioil-build-orchestrator-{}", uuid::Uuid::new_v4());
|
||||||
let mut mqtt_options =
|
let mut mqtt_options = rumqttc::MqttOptions::new(client_id, &mqtt_host, mqtt_port);
|
||||||
rumqttc::MqttOptions::new(client_id, &mqtt_host, mqtt_port);
|
|
||||||
mqtt_options.set_keep_alive(std::time::Duration::from_secs(30));
|
mqtt_options.set_keep_alive(std::time::Duration::from_secs(30));
|
||||||
// 256KB: suficiente para binarios de instalador por partes y payloads comprimidos
|
// 256KB: suficiente para binarios de instalador por partes y payloads comprimidos
|
||||||
mqtt_options.set_max_packet_size(256 * 1024, 256 * 1024);
|
mqtt_options.set_max_packet_size(256 * 1024, 256 * 1024);
|
||||||
@@ -101,27 +103,54 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if topic == "omnioil/events/project_created" {
|
if topic == "omnioil/events/project_created" {
|
||||||
let payload_str = String::from_utf8_lossy(&payload);
|
let payload_str = String::from_utf8_lossy(&payload);
|
||||||
tracing::info!("📥 Recibido Project Created Event: {}", payload_str);
|
tracing::info!("📥 Recibido Project Created Event: {}", payload_str);
|
||||||
if let Ok(event) = serde_json::from_str::<shared_lib::mqtt_messages::ProjectCreatedEvent>(&payload_str) {
|
if let Ok(event) = serde_json::from_str::<
|
||||||
|
shared_lib::mqtt_messages::ProjectCreatedEvent,
|
||||||
|
>(&payload_str)
|
||||||
|
{
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = installer_worker::handle_project_created(state.db_pool.clone(), state.mqtt_client.clone(), event).await {
|
if let Err(e) = installer_worker::handle_project_created(
|
||||||
|
state.db_pool.clone(),
|
||||||
|
state.mqtt_client.clone(),
|
||||||
|
event,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::error!("Worker de instalador falló: {}", e);
|
tracing::error!("Worker de instalador falló: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if topic.starts_with("omnioil/control/deploy/") {
|
} else if topic.starts_with("omnioil/control/deploy/") {
|
||||||
let payload_str = String::from_utf8_lossy(&payload);
|
let payload_str = String::from_utf8_lossy(&payload);
|
||||||
tracing::info!("📥 Recibido Deployment Request. Payload crudo: {}", payload_str);
|
tracing::info!(
|
||||||
|
"📥 Recibido Deployment Request. Payload crudo: {}",
|
||||||
|
payload_str
|
||||||
|
);
|
||||||
|
|
||||||
if let Ok(config) = serde_json::from_str::<shared_lib::edge_models::ProjectConfig>(&payload_str) {
|
if let Ok(config) = serde_json::from_str::<
|
||||||
|
shared_lib::edge_models::ProjectConfig,
|
||||||
|
>(&payload_str)
|
||||||
|
{
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let deployment_id = Uuid::new_v4();
|
let deployment_id = Uuid::new_v4();
|
||||||
tracing::info!("🚀 Proyecto deserializado: {}. Activos encontrados: {}", config.project.name, config.assets.len());
|
tracing::info!(
|
||||||
if let Err(e) = publisher::execute_build_pipeline(&state, &config, deployment_id).await {
|
"🚀 Proyecto deserializado: {}. Activos encontrados: {}",
|
||||||
|
config.project.name,
|
||||||
|
config.assets.len()
|
||||||
|
);
|
||||||
|
if let Err(e) = publisher::execute_build_pipeline(
|
||||||
|
&state,
|
||||||
|
&config,
|
||||||
|
deployment_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::error!("Pipeline de build falló: {}", e);
|
tracing::error!("Pipeline de build falló: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
tracing::error!("❌ Fallo al deserializar ProjectConfig. Revisa la estructura del JSON.");
|
tracing::error!(
|
||||||
|
"❌ Fallo al deserializar ProjectConfig. Revisa la estructura del JSON."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,6 +163,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Subir binario genérico de Linux a MinIO si existe en el contenedor
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = installer_worker::upload_generic_linux_binary().await {
|
||||||
|
tracing::error!("Error al subir el binario genérico de Linux: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
tracing::info!("✅ Build Orchestrator ready. Waiting for messages...");
|
tracing::info!("✅ Build Orchestrator ready. Waiting for messages...");
|
||||||
|
|
||||||
tokio::signal::ctrl_c().await?;
|
tokio::signal::ctrl_c().await?;
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ pub async fn publish_deploy_signal(
|
|||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"📡 Publishing deploy signal to topic: {} ({} → {} bytes)",
|
"📡 Publishing deploy signal to topic: {} ({} → {} bytes)",
|
||||||
topic, payload_json.len(), payload.len()
|
topic,
|
||||||
|
payload_json.len(),
|
||||||
|
payload.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
mqtt_client
|
mqtt_client
|
||||||
@@ -110,13 +112,17 @@ pub async fn execute_build_pipeline(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 2. Generar hash de configuración para control de versiones
|
// 2. Generar hash de configuración para control de versiones
|
||||||
use sha2::{Sha256, Digest};
|
use sha2::{Digest, Sha256};
|
||||||
let config_json = serde_json::to_string(config).unwrap_or_default();
|
let config_json = serde_json::to_string(config).unwrap_or_default();
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(config_json.as_bytes());
|
hasher.update(config_json.as_bytes());
|
||||||
let config_hash = hex::encode(hasher.finalize());
|
let config_hash = hex::encode(hasher.finalize());
|
||||||
|
|
||||||
tracing::info!("📦 Deploying project {} with {} assets", project_id, config.assets.len());
|
tracing::info!(
|
||||||
|
"📦 Deploying project {} with {} assets",
|
||||||
|
project_id,
|
||||||
|
config.assets.len()
|
||||||
|
);
|
||||||
|
|
||||||
// 3. Publicar señal de deploy directamente al agente nativo
|
// 3. Publicar señal de deploy directamente al agente nativo
|
||||||
publish_deploy_signal(
|
publish_deploy_signal(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ FROM lukemathwalker/cargo-chef:latest-rust-1.93-slim-bookworm AS base
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Instala dependencias del sistema necesarias para compilar crates de C y linkeo rápido
|
# Instala dependencias del sistema necesarias para compilar crates de C y linkeo rápido
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
pkg-config libssl-dev libglib2.0-dev libatk1.0-dev libgtk-3-dev build-essential ca-certificates lld \
|
pkg-config libssl-dev build-essential ca-certificates lld \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
# Linker LLD para reducir el tiempo de linkeado significativamente
|
# Linker LLD para reducir el tiempo de linkeado significativamente
|
||||||
ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld"
|
ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld"
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ impl TelemetryFetcher {
|
|||||||
return Ok(HashMap::new());
|
return Ok(HashMap::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let resp_res = self.client
|
let resp_res = self
|
||||||
|
.client
|
||||||
.get(api_url)
|
.get(api_url)
|
||||||
.header("X-API-KEY", api_key)
|
.header("X-API-KEY", api_key)
|
||||||
.send()
|
.send()
|
||||||
@@ -44,7 +45,12 @@ impl TelemetryFetcher {
|
|||||||
let resp: Vec<TagReading> = match serde_json::from_str(&body) {
|
let resp: Vec<TagReading> = match serde_json::from_str(&body) {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("❌ Failed to decode JSON from {}. Error: {}. Body: {}", api_url, e, body);
|
tracing::error!(
|
||||||
|
"❌ Failed to decode JSON from {}. Error: {}. Body: {}",
|
||||||
|
api_url,
|
||||||
|
e,
|
||||||
|
body
|
||||||
|
);
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ mod persistence;
|
|||||||
|
|
||||||
use crate::fetcher::TelemetryFetcher;
|
use crate::fetcher::TelemetryFetcher;
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
|
use shared_lib::models::EdgeAsset;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use shared_lib::models::EdgeAsset;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -33,8 +33,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
loop {
|
loop {
|
||||||
// 1. Buscar todos los Assets que tengan la integración activa
|
// 1. Buscar todos los Assets que tengan la integración activa
|
||||||
let active_assets = match sqlx::query_as::<_, EdgeAsset>(
|
let active_assets = match sqlx::query_as::<_, EdgeAsset>(
|
||||||
"SELECT * FROM edge_assets WHERE is_collector_enabled = true"
|
"SELECT * FROM edge_assets WHERE is_collector_enabled = true",
|
||||||
).fetch_all(&pool).await {
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(assets) => assets,
|
Ok(assets) => assets,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("❌ Error querying active assets: {}", e);
|
error!("❌ Error querying active assets: {}", e);
|
||||||
@@ -59,7 +62,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
};
|
};
|
||||||
let key = asset.external_api_key.as_deref().unwrap_or("");
|
let key = asset.external_api_key.as_deref().unwrap_or("");
|
||||||
|
|
||||||
info!("🔍 Polling external API for asset: {} ({})", asset.name, asset.code);
|
info!(
|
||||||
|
"🔍 Polling external API for asset: {} ({})",
|
||||||
|
asset.name, asset.code
|
||||||
|
);
|
||||||
|
|
||||||
match fetcher.fetch_asset_telemetry(url, key).await {
|
match fetcher.fetch_asset_telemetry(url, key).await {
|
||||||
Ok(data_map) => {
|
Ok(data_map) => {
|
||||||
@@ -74,7 +80,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
payload.insert(tag_name, value);
|
payload.insert(tag_name, value);
|
||||||
let data_json = serde_json::Value::Object(payload);
|
let data_json = serde_json::Value::Object(payload);
|
||||||
|
|
||||||
if let Err(e) = persistence::ingest_telemetry_batch(&pool, &asset.code, time, data_json).await {
|
if let Err(e) =
|
||||||
|
persistence::ingest_telemetry_batch(&pool, &asset.code, time, data_json)
|
||||||
|
.await
|
||||||
|
{
|
||||||
error!("❌ Error ingesting data for asset {}: {}", asset.name, e);
|
error!("❌ Error ingesting data for asset {}: {}", asset.name, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
//! Implementa el patrón "Transactional Outbox" para asegurar que los datos
|
//! Implementa el patrón "Transactional Outbox" para asegurar que los datos
|
||||||
//! se guarden y los eventos se generen de forma atómica.
|
//! se guarden y los eventos se generen de forma atómica.
|
||||||
|
|
||||||
|
use shared_lib::events::{DomainEvent, TelemetryBatchPayload};
|
||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
use shared_lib::events::{DomainEvent, TelemetryBatchPayload};
|
|
||||||
|
|
||||||
/// Procesa un lote de telemetría proveniente de un activo.
|
/// Procesa un lote de telemetría proveniente de un activo.
|
||||||
///
|
///
|
||||||
@@ -69,10 +69,16 @@ pub async fn ingest_telemetry_batch(
|
|||||||
|
|
||||||
// 5. Commit
|
// 5. Commit
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
info!("✅ Datos + Evento procesados para {} en {}", asset_code, time);
|
info!(
|
||||||
|
"✅ Datos + Evento procesados para {} en {}",
|
||||||
|
asset_code, time
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
warn!("⚠️ Activo desconocido recibido: {}. Saltando ingesta.", asset_code);
|
warn!(
|
||||||
|
"⚠️ Activo desconocido recibido: {}. Saltando ingesta.",
|
||||||
|
asset_code
|
||||||
|
);
|
||||||
let _ = tx.rollback().await;
|
let _ = tx.rollback().await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ sha2 = { workspace = true }
|
|||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
tokio-modbus = { workspace = true }
|
tokio-modbus = { workspace = true }
|
||||||
s7 = { workspace = true }
|
s7 = { workspace = true }
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||||
async-trait = "0.1.86"
|
async-trait = "0.1.86"
|
||||||
opcua = { version = "0.12", features = ["client"], optional = true }
|
opcua = { version = "0.12", features = ["client"], optional = true }
|
||||||
# vendored compiles OpenSSL from source — no external OpenSSL installation needed.
|
# vendored compiles OpenSSL from source — no external OpenSSL installation needed.
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# MULTI-STAGE Dockerfile: Edge Agent MSI Builder (Optimized with cargo-chef)
|
# MULTI-STAGE Dockerfile: Edge Agent MSI Builder (Optimized with cargo-chef)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# --- ETAPA 1: PLANNER: Genera el archivo recipe.json para cachear dependencias ---
|
# --- ETAPA 0: BASE IMAGE ---
|
||||||
FROM lukemathwalker/cargo-chef:latest-rust-1.93-slim-bookworm AS planner
|
FROM lukemathwalker/cargo-chef:latest-rust-1.93-slim-bookworm AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Instalar toolchain de Windows (GNU) y Linux musl en un solo paso
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
mingw-w64 \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
perl \
|
||||||
|
make \
|
||||||
|
musl-tools \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN rustup target add x86_64-pc-windows-gnu && \
|
||||||
|
rustup target add x86_64-unknown-linux-musl
|
||||||
|
|
||||||
|
# --- ETAPA 1: PLANNER: Genera el archivo recipe.json para cachear dependencias ---
|
||||||
|
FROM base AS planner
|
||||||
# Copiamos solo los archivos que definen dependencias (workspace y miembros)
|
# Copiamos solo los archivos que definen dependencias (workspace y miembros)
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
|
COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
|
||||||
@@ -18,35 +35,18 @@ COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.to
|
|||||||
|
|
||||||
# Creamos estructuras dummy para que cargo metadata no falle (muy importante para la caché)
|
# Creamos estructuras dummy para que cargo metadata no falle (muy importante para la caché)
|
||||||
RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
|
RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
|
||||||
mkdir -p services/flow-generator/src && touch services/flow-generator/src/lib.rs && \
|
mkdir -p services/backend-api/src && touch services/backend-api/src/lib.rs && echo "fn main() {}" > services/backend-api/src/main.rs && \
|
||||||
mkdir -p services/backend-api/src && echo "fn main() {}" > services/backend-api/src/main.rs && \
|
|
||||||
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
|
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
|
||||||
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
|
mkdir -p services/json-generator/src && touch services/json-generator/src/lib.rs && echo "fn main() {}" > services/json-generator/src/main.rs && \
|
||||||
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
|
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
|
||||||
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \
|
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \
|
||||||
mkdir -p services/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \
|
mkdir -p services/edge-agent/src/bin && echo "fn main() {}" > services/edge-agent/src/bin/edge-tray.rs && echo "fn main() {}" > services/edge-agent/src/main.rs && \
|
||||||
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
|
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
|
||||||
|
|
||||||
RUN cargo chef prepare --recipe-path recipe.json --bin edge-agent
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
# --- ETAPA 2: CACHER (Cross-Compilation) ---
|
# --- ETAPA 2: CACHER (Cross-Compilation) ---
|
||||||
FROM lukemathwalker/cargo-chef:latest-rust-1.93-slim-bookworm AS cacher
|
FROM base AS cacher
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Instalar toolchain de Windows (GNU)
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
mingw-w64 \
|
|
||||||
pkg-config \
|
|
||||||
libssl-dev \
|
|
||||||
libglib2.0-dev \
|
|
||||||
cmake \
|
|
||||||
perl \
|
|
||||||
make \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN rustup target add x86_64-pc-windows-gnu
|
|
||||||
RUN rustup target add x86_64-unknown-linux-musl
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends musl-tools && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Variables de entorno para cross-compilation
|
# Variables de entorno para cross-compilation
|
||||||
ENV CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
|
ENV CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
|
||||||
@@ -54,47 +54,54 @@ ENV CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++
|
|||||||
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
|
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
|
||||||
ENV OPENSSL_NO_ASM=1
|
ENV OPENSSL_NO_ASM=1
|
||||||
|
|
||||||
# Compilar dependencias para el target de Windows
|
# Compilar dependencias para el target de Windows (con RUSTFLAGS idénticos)
|
||||||
COPY --from=planner /app/recipe.json recipe.json
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
RUN cargo chef cook --release --target x86_64-pc-windows-gnu --recipe-path recipe.json
|
RUN --mount=type=cache,target=/app/target \
|
||||||
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
|
RUSTFLAGS="-C target-feature=+crt-static -C link-args=-static" \
|
||||||
|
cargo chef cook --release --target x86_64-pc-windows-gnu --features edge-agent/opcua-support --recipe-path recipe.json
|
||||||
|
|
||||||
|
# Compilar dependencias para el target de Linux musl
|
||||||
|
RUN --mount=type=cache,target=/app/target \
|
||||||
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
|
cargo chef cook --release --target x86_64-unknown-linux-musl --features edge-agent/opcua-support --recipe-path recipe.json
|
||||||
|
|
||||||
|
|
||||||
# --- ETAPA 3: TOOLCHAIN & BUILDER ---
|
# --- ETAPA 3: TOOLCHAIN & BUILDER ---
|
||||||
FROM rust:1.93-slim-bookworm AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Instalar dependencias necesarias
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
mingw-w64 \
|
|
||||||
pkg-config \
|
|
||||||
libssl-dev \
|
|
||||||
libglib2.0-dev \
|
|
||||||
cmake \
|
|
||||||
perl \
|
|
||||||
make \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN rustup target add x86_64-pc-windows-gnu
|
|
||||||
RUN rustup target add x86_64-unknown-linux-musl
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends musl-tools && rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY . .
|
|
||||||
COPY --from=cacher /app/target target
|
|
||||||
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
|
||||||
|
|
||||||
# Variables de entorno para cross-compilation
|
# Variables de entorno para cross-compilation
|
||||||
ENV CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
|
ENV CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
|
||||||
ENV CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++
|
ENV CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++
|
||||||
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
|
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
|
||||||
# Disable OpenSSL ASM optimizations for cross-compilation (avoids needing nasm for Windows target)
|
|
||||||
ENV OPENSSL_NO_ASM=1
|
ENV OPENSSL_NO_ASM=1
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
||||||
|
|
||||||
|
# Parchear la versión del agente directamente en Cargo.toml antes de compilar.
|
||||||
|
ARG BUILD_VERSION=0.3.0
|
||||||
|
RUN CURRENT_VERSION=$(grep -m 1 "^version =" services/edge-agent/Cargo.toml | cut -d '"' -f 2) && \
|
||||||
|
if [ "$CURRENT_VERSION" != "$BUILD_VERSION" ]; then \
|
||||||
|
sed -i "0,/^version = /s/^version = \"[^\"]*\"/version = \"${BUILD_VERSION}\"/" services/edge-agent/Cargo.toml; \
|
||||||
|
fi
|
||||||
|
|
||||||
# Compilar binarios de producción para Windows GNU (Estático total) y Linux musl
|
# Compilar binarios de producción para Windows GNU (Estático total) y Linux musl
|
||||||
RUN RUSTFLAGS="-C target-feature=+crt-static -C link-args=-static" \
|
RUN --mount=type=cache,target=/app/target \
|
||||||
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
|
RUSTFLAGS="-C target-feature=+crt-static -C link-args=-static" \
|
||||||
cargo build --release --bin edge-agent --bin edge-tray \
|
cargo build --release --bin edge-agent --bin edge-tray \
|
||||||
--target x86_64-pc-windows-gnu \
|
--target x86_64-pc-windows-gnu \
|
||||||
--features edge-agent/opcua-support && \
|
--features edge-agent/opcua-support && \
|
||||||
cargo build --release --bin edge-agent \
|
cargo build --release --bin edge-agent \
|
||||||
--target x86_64-unknown-linux-musl \
|
--target x86_64-unknown-linux-musl \
|
||||||
--features edge-agent/opcua-support
|
--features edge-agent/opcua-support && \
|
||||||
|
mkdir -p /app/bin/windows && cp target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/bin/windows/ && \
|
||||||
|
cp target/x86_64-pc-windows-gnu/release/edge-tray.exe /app/bin/windows/ && \
|
||||||
|
mkdir -p /app/bin/linux && cp target/x86_64-unknown-linux-musl/release/edge-agent /app/bin/linux/
|
||||||
|
|
||||||
# --- ETAPA 4: EMPAQUETADOR (MSI) ---
|
# --- ETAPA 4: EMPAQUETADOR (MSI) ---
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
@@ -110,9 +117,9 @@ WORKDIR /app
|
|||||||
RUN mkdir -p /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/
|
RUN mkdir -p /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/
|
||||||
RUN mkdir -p /app/services/edge-agent/target/x86_64-unknown-linux-musl/release/
|
RUN mkdir -p /app/services/edge-agent/target/x86_64-unknown-linux-musl/release/
|
||||||
|
|
||||||
COPY --from=builder /app/target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/
|
COPY --from=builder /app/bin/windows/edge-agent.exe /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/
|
||||||
COPY --from=builder /app/target/x86_64-pc-windows-gnu/release/edge-tray.exe /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/
|
COPY --from=builder /app/bin/windows/edge-tray.exe /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/
|
||||||
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/edge-agent /app/services/edge-agent/target/x86_64-unknown-linux-musl/release/
|
COPY --from=builder /app/bin/linux/edge-agent /app/services/edge-agent/target/x86_64-unknown-linux-musl/release/
|
||||||
|
|
||||||
COPY ./services/edge-agent/Cargo.toml /app/services/edge-agent/Cargo.toml
|
COPY ./services/edge-agent/Cargo.toml /app/services/edge-agent/Cargo.toml
|
||||||
COPY ./services/edge-agent/wix /app/services/edge-agent/wix
|
COPY ./services/edge-agent/wix /app/services/edge-agent/wix
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ fn main() {
|
|||||||
if cfg!(unix) {
|
if cfg!(unix) {
|
||||||
res.set_windres_path("x86_64-w64-mingw32-windres");
|
res.set_windres_path("x86_64-w64-mingw32-windres");
|
||||||
if let Err(e) = res.compile() {
|
if let Err(e) = res.compile() {
|
||||||
println!("cargo:warning=Error al compilar recursos de Windows (Linux host): {}", e);
|
println!(
|
||||||
|
"cargo:warning=Error al compilar recursos de Windows (Linux host): {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let _ = res.compile();
|
let _ = res.compile();
|
||||||
@@ -21,7 +24,11 @@ fn main() {
|
|||||||
let config_path = std::path::Path::new("embedded_config.toml");
|
let config_path = std::path::Path::new("embedded_config.toml");
|
||||||
let baked_bytes = if config_path.exists() {
|
let baked_bytes = if config_path.exists() {
|
||||||
match std::fs::read_to_string(config_path) {
|
match std::fs::read_to_string(config_path) {
|
||||||
Ok(content) => content.into_bytes().iter().map(|b| b ^ 0x5A).collect::<Vec<u8>>(),
|
Ok(content) => content
|
||||||
|
.into_bytes()
|
||||||
|
.iter()
|
||||||
|
.map(|b| b ^ 0x5A)
|
||||||
|
.collect::<Vec<u8>>(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!("cargo:warning=No se pudo leer embedded_config.toml: {}", e);
|
println!("cargo:warning=No se pudo leer embedded_config.toml: {}", e);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -33,7 +40,7 @@ fn main() {
|
|||||||
|
|
||||||
let _ = std::fs::write(
|
let _ = std::fs::write(
|
||||||
&dest_path,
|
&dest_path,
|
||||||
format!("pub const BAKED_CONFIG_XOR: &[u8] = &{:?};", baked_bytes)
|
format!("pub const BAKED_CONFIG_XOR: &[u8] = &{:?};", baked_bytes),
|
||||||
);
|
);
|
||||||
|
|
||||||
println!("cargo:rerun-if-changed=embedded_config.toml");
|
println!("cargo:rerun-if-changed=embedded_config.toml");
|
||||||
|
|||||||
32
services/edge-agent/build_msi_linux.sh
Normal file → Executable file
32
services/edge-agent/build_msi_linux.sh
Normal file → Executable file
@@ -44,10 +44,40 @@ ls -lR target/x86_64-pc-windows-msvc/
|
|||||||
|
|
||||||
# 4. Construir el MSI usando wixl
|
# 4. Construir el MSI usando wixl
|
||||||
if command -v wixl &> /dev/null; then
|
if command -v wixl &> /dev/null; then
|
||||||
|
# Detectar la versión actual en Cargo.toml
|
||||||
|
VERSION=$(grep -m 1 "^version =" Cargo.toml | cut -d '"' -f 2)
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
VERSION=$(grep -m 1 "^version =" Cargo.toml | cut -d "'" -f 2)
|
||||||
|
fi
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
VERSION="0.3.0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📌 Versión de Edge Agent detectada desde Cargo.toml: $VERSION"
|
||||||
|
|
||||||
|
# Crear una copia temporal del archivo WXS para no destruir el original o causar conflictos en git
|
||||||
|
TEMP_WXS="wix/main_temp.wxs"
|
||||||
|
cp wix/main.wxs "$TEMP_WXS"
|
||||||
|
|
||||||
|
# Aplicar versión a la copia temporal
|
||||||
|
sed -i "s/ Version='[0-9.]*'/ Version='$VERSION'/" "$TEMP_WXS"
|
||||||
|
sed -i "s/{{VERSION}}/$VERSION/g" "$TEMP_WXS"
|
||||||
|
|
||||||
echo "🔨 Generando MSI usando wixl..."
|
echo "🔨 Generando MSI usando wixl..."
|
||||||
wixl -v -o OmniOilEdgeAgent.msi wix/main.wxs
|
wixl -v -o OmniOilEdgeAgent.msi "$TEMP_WXS"
|
||||||
|
WIXL_EXIT=$?
|
||||||
|
|
||||||
|
# Limpiar archivo temporal
|
||||||
|
rm -f "$TEMP_WXS"
|
||||||
|
|
||||||
|
if [ $WIXL_EXIT -eq 0 ]; then
|
||||||
echo "✅ MSI generado con éxito: OmniOilEdgeAgent.msi"
|
echo "✅ MSI generado con éxito: OmniOilEdgeAgent.msi"
|
||||||
|
else
|
||||||
|
echo "❌ Error al generar el MSI con wixl."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "❌ Error: 'wixl' no se encontró en el PATH."
|
echo "❌ Error: 'wixl' no se encontró en el PATH."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
0
services/edge-agent/msi_worker.sh
Normal file → Executable file
0
services/edge-agent/msi_worker.sh
Normal file → Executable file
@@ -6,19 +6,19 @@
|
|||||||
|
|
||||||
#![windows_subsystem = "windows"]
|
#![windows_subsystem = "windows"]
|
||||||
|
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tray_icon::{
|
use tray_icon::{
|
||||||
menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, MenuId},
|
TrayIcon, TrayIconBuilder,
|
||||||
TrayIconBuilder, TrayIcon,
|
menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem},
|
||||||
};
|
};
|
||||||
use winit::event_loop::{ControlFlow, EventLoop};
|
|
||||||
use winit::application::ApplicationHandler;
|
use winit::application::ApplicationHandler;
|
||||||
use winit::event_loop::ActiveEventLoop;
|
use winit::event_loop::ActiveEventLoop;
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use winit::event_loop::{ControlFlow, EventLoop};
|
||||||
|
|
||||||
// Protocolo compartido
|
// Protocolo compartido
|
||||||
#[path = "../ipc_protocol.rs"]
|
#[path = "../ipc_protocol.rs"]
|
||||||
mod ipc_protocol;
|
mod ipc_protocol;
|
||||||
use ipc_protocol::{IpcMessage, AgentCommand};
|
use ipc_protocol::{AgentCommand, IpcMessage};
|
||||||
|
|
||||||
const PIPE_NAME: &str = r"\\.\pipe\omnioil-edge-agent";
|
const PIPE_NAME: &str = r"\\.\pipe\omnioil-edge-agent";
|
||||||
|
|
||||||
@@ -39,7 +39,8 @@ impl ApplicationHandler for EdgeTrayApp {
|
|||||||
_event_loop: &ActiveEventLoop,
|
_event_loop: &ActiveEventLoop,
|
||||||
_window_id: winit::window::WindowId,
|
_window_id: winit::window::WindowId,
|
||||||
_event: winit::event::WindowEvent,
|
_event: winit::event::WindowEvent,
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
// Recibir actualizaciones de estado del backend
|
// Recibir actualizaciones de estado del backend
|
||||||
@@ -53,7 +54,9 @@ impl ApplicationHandler for EdgeTrayApp {
|
|||||||
self.tray_icon.take();
|
self.tray_icon.take();
|
||||||
event_loop.exit();
|
event_loop.exit();
|
||||||
} else if event.id == self.test_id {
|
} else if event.id == self.test_id {
|
||||||
let _ = self.command_tx.try_send(AgentCommand::Notify("¡Hola desde el Tray Icon!".to_string()));
|
let _ = self.command_tx.try_send(AgentCommand::Notify(
|
||||||
|
"¡Hola desde el Tray Icon!".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,12 +76,14 @@ async fn main() {
|
|||||||
let quit_id = quit_i.id().clone();
|
let quit_id = quit_i.id().clone();
|
||||||
let test_id = test_i.id().clone();
|
let test_id = test_i.id().clone();
|
||||||
|
|
||||||
tray_menu.append_items(&[
|
tray_menu
|
||||||
|
.append_items(&[
|
||||||
&status_label,
|
&status_label,
|
||||||
&test_i,
|
&test_i,
|
||||||
&PredefinedMenuItem::separator(),
|
&PredefinedMenuItem::separator(),
|
||||||
&quit_i,
|
&quit_i,
|
||||||
]).unwrap();
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let icon_path = std::env::current_exe()
|
let icon_path = std::env::current_exe()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -141,7 +146,9 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = status_tx.send("Estado: Agente no detectado".to_string()).await;
|
let _ = status_tx
|
||||||
|
.send("Estado: Agente no detectado".to_string())
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,13 @@ include!(concat!(env!("OUT_DIR"), "/baked_config.rs"));
|
|||||||
|
|
||||||
fn agent_data_dir() -> PathBuf {
|
fn agent_data_dir() -> PathBuf {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{ PathBuf::from(r"C:\ProgramData\OmniOil\agent") }
|
{
|
||||||
|
PathBuf::from(r"C:\ProgramData\OmniOil\agent")
|
||||||
|
}
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{ PathBuf::from("/var/lib/omnioil/agent") }
|
{
|
||||||
|
PathBuf::from("/var/lib/omnioil/agent")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn private_key_path() -> PathBuf {
|
fn private_key_path() -> PathBuf {
|
||||||
@@ -89,15 +93,55 @@ impl AgentConfig {
|
|||||||
/// la config viene de disco (credenciales permanentes válidas). Si es `false`,
|
/// la config viene de disco (credenciales permanentes válidas). Si es `false`,
|
||||||
/// la config viene del stub embebido y no se debe limpiar en caso de `NotAuthorized`.
|
/// la config viene del stub embebido y no se debe limpiar en caso de `NotAuthorized`.
|
||||||
pub async fn load_default(external_path: &Path) -> Result<(Self, bool)> {
|
pub async fn load_default(external_path: &Path) -> Result<(Self, bool)> {
|
||||||
|
// ── Validar coincidencia de Project ID (Clean Install en caso de Mismatch) ──
|
||||||
|
if let Some(stub) = shared_lib::overlay::find_provisioning_stub() {
|
||||||
|
let provisioned = provisioned_config_path();
|
||||||
|
if provisioned.exists() {
|
||||||
|
let old_config_opt = crate::credential_store::load_secret(&provisioned)
|
||||||
|
.ok()
|
||||||
|
.and_then(|bytes| {
|
||||||
|
let content = String::from_utf8(bytes).ok()?;
|
||||||
|
toml::from_str::<AgentConfig>(&content).ok()
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(old_config) = old_config_opt {
|
||||||
|
if old_config.agent.project_id != stub.project_id {
|
||||||
|
tracing::warn!(
|
||||||
|
"⚠️ [Project Mismatch] El ID del proyecto del nuevo instalador ({}) no coincide con la configuración existente ({}). Realizando limpieza para aprovisionamiento limpio...",
|
||||||
|
stub.project_id,
|
||||||
|
old_config.agent.project_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ruta base a limpiar (C:\ProgramData\OmniOil en Windows, /var/lib/omnioil en Linux)
|
||||||
|
let base_data_dir = if cfg!(windows) {
|
||||||
|
std::path::PathBuf::from(r"C:\ProgramData\OmniOil")
|
||||||
|
} else {
|
||||||
|
std::path::PathBuf::from("/var/lib/omnioil")
|
||||||
|
};
|
||||||
|
|
||||||
|
if base_data_dir.exists() {
|
||||||
|
if let Err(e) = std::fs::remove_dir_all(&base_data_dir) {
|
||||||
|
tracing::error!("❌ Error al limpiar datos antiguos: {}", e);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
"✅ Limpieza de datos antiguos completada con éxito."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Modo 1: Config ya aprovisionada (arranques posteriores al primero) ──
|
// ── Modo 1: Config ya aprovisionada (arranques posteriores al primero) ──
|
||||||
let provisioned = provisioned_config_path();
|
let provisioned = provisioned_config_path();
|
||||||
if provisioned.exists() {
|
if provisioned.exists() {
|
||||||
match crate::credential_store::load_secret(&provisioned)
|
match crate::credential_store::load_secret(&provisioned).and_then(|bytes| {
|
||||||
.and_then(|bytes| {
|
let content =
|
||||||
let content = String::from_utf8(bytes).map_err(|e| anyhow::anyhow!("UTF-8 error: {}", e))?;
|
String::from_utf8(bytes).map_err(|e| anyhow::anyhow!("UTF-8 error: {}", e))?;
|
||||||
toml::from_str::<AgentConfig>(&content).map_err(|e| anyhow::anyhow!("TOML error: {}", e))
|
toml::from_str::<AgentConfig>(&content)
|
||||||
})
|
.map_err(|e| anyhow::anyhow!("TOML error: {}", e))
|
||||||
{
|
}) {
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
tracing::info!("✅ [Provisioned] Config cargada desde disco.");
|
tracing::info!("✅ [Provisioned] Config cargada desde disco.");
|
||||||
return Ok((config, true));
|
return Ok((config, true));
|
||||||
@@ -130,10 +174,14 @@ impl AgentConfig {
|
|||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
// Eliminar el stub después de aprovisionamiento exitoso
|
// Eliminar el stub después de aprovisionamiento exitoso
|
||||||
let _ = std::fs::remove_file(&file_stub_path);
|
let _ = std::fs::remove_file(&file_stub_path);
|
||||||
tracing::info!("✅ [Provisioning] Agente aprovisionado. Stub eliminado.");
|
tracing::info!(
|
||||||
|
"✅ [Provisioning] Agente aprovisionado. Stub eliminado."
|
||||||
|
);
|
||||||
return Ok((config, true));
|
return Ok((config, true));
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!("⚠️ Provisioning desde archivo falló: {:#}", e),
|
Err(e) => {
|
||||||
|
tracing::warn!("⚠️ Provisioning desde archivo falló: {:#}", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!("⚠️ provisioning_stub.json inválido: {}", e),
|
Err(e) => tracing::warn!("⚠️ provisioning_stub.json inválido: {}", e),
|
||||||
@@ -144,18 +192,48 @@ impl AgentConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Modo 2: Provisioning asimétrico (primer arranque con stub en binario) ──
|
// ── Modo 2: Provisioning asimétrico (primer arranque con stub en binario) ──
|
||||||
|
// Si hay un stub embebido (instalador generado por el panel), el agente DEBE
|
||||||
|
// provisionar antes de poder funcionar. Si la red falla (proxy 502, timeout,
|
||||||
|
// DNS, etc.), reintentamos indefinidamente con backoff en lugar de caernos —
|
||||||
|
// caerse solo activa el reinicio del servicio Windows, que vuelve a fallar.
|
||||||
if let Some(stub) = shared_lib::overlay::find_provisioning_stub() {
|
if let Some(stub) = shared_lib::overlay::find_provisioning_stub() {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"🔑 [Provisioning] Stub detectado. Conectando al broker {}:{}…",
|
"🔑 [Provisioning] Stub detectado. Broker: {}:{} (use_ws={})",
|
||||||
stub.mqtt_host,
|
stub.mqtt_host,
|
||||||
stub.mqtt_port
|
stub.mqtt_port,
|
||||||
|
stub.use_ws
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let mut attempt: u32 = 0;
|
||||||
|
loop {
|
||||||
|
attempt += 1;
|
||||||
|
tracing::info!("🔄 [Provisioning] Intento #{}", attempt);
|
||||||
|
|
||||||
match Self::provision_from_server(&stub).await {
|
match Self::provision_from_server(&stub).await {
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
tracing::info!("✅ [Provisioning] Agente aprovisionado exitosamente.");
|
tracing::info!("✅ [Provisioning] Agente aprovisionado exitosamente.");
|
||||||
return Ok((config, true));
|
return Ok((config, true));
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!("⚠️ Provisioning falló: {:#}", e),
|
Err(e) => {
|
||||||
|
// Error visible inmediatamente en logs (error > warn para prioridad)
|
||||||
|
tracing::error!("❌ [Provisioning] Intento #{} falló: {:#}", attempt, e);
|
||||||
|
|
||||||
|
// Backoff exponencial: 10s, 20s, 40s… máx 5 min
|
||||||
|
let wait_secs =
|
||||||
|
std::cmp::min(10 * 2u64.pow(attempt.saturating_sub(1)), 300);
|
||||||
|
tracing::warn!(
|
||||||
|
"⏳ [Provisioning] Reintentando en {} segundos \
|
||||||
|
(verifica conectividad a {}:{})…",
|
||||||
|
wait_secs,
|
||||||
|
stub.mqtt_host,
|
||||||
|
stub.mqtt_port
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dar tiempo al escritor de logs para vaciar el buffer antes de dormir
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(wait_secs)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,16 +282,18 @@ impl AgentConfig {
|
|||||||
let client_id = format!("provision-{}", uuid::Uuid::new_v4());
|
let client_id = format!("provision-{}", uuid::Uuid::new_v4());
|
||||||
let prov_username = format!("provision:{}", stub.project_id);
|
let prov_username = format!("provision:{}", stub.project_id);
|
||||||
|
|
||||||
|
let use_tls = stub.mqtt_port == 443;
|
||||||
|
|
||||||
// rumqttc con WS transport requiere la URL completa como host (ws://host:port/mqtt).
|
// rumqttc con WS transport requiere la URL completa como host (ws://host:port/mqtt).
|
||||||
// TCP plain usa solo el hostname directamente.
|
// TCP plain usa solo el hostname directamente.
|
||||||
let mqtt_host_arg = if stub.use_ws {
|
let mqtt_host_arg = if stub.use_ws {
|
||||||
format!("ws://{}:{}/mqtt", stub.mqtt_host, stub.mqtt_port)
|
let scheme = if use_tls { "wss" } else { "ws" };
|
||||||
|
format!("{}://{}:{}/mqtt", scheme, stub.mqtt_host, stub.mqtt_port)
|
||||||
} else {
|
} else {
|
||||||
stub.mqtt_host.clone()
|
stub.mqtt_host.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut mqtt_opts =
|
let mut mqtt_opts = rumqttc::MqttOptions::new(client_id, mqtt_host_arg, stub.mqtt_port);
|
||||||
rumqttc::MqttOptions::new(client_id, mqtt_host_arg, stub.mqtt_port);
|
|
||||||
mqtt_opts.set_credentials(&prov_username, &stub.provisioning_token);
|
mqtt_opts.set_credentials(&prov_username, &stub.provisioning_token);
|
||||||
mqtt_opts.set_keep_alive(std::time::Duration::from_secs(15));
|
mqtt_opts.set_keep_alive(std::time::Duration::from_secs(15));
|
||||||
mqtt_opts.set_clean_session(true);
|
mqtt_opts.set_clean_session(true);
|
||||||
@@ -221,13 +301,26 @@ impl AgentConfig {
|
|||||||
mqtt_opts.set_max_packet_size(256 * 1024, 256 * 1024);
|
mqtt_opts.set_max_packet_size(256 * 1024, 256 * 1024);
|
||||||
|
|
||||||
// Aplicar el transporte correcto según el stub.
|
// Aplicar el transporte correcto según el stub.
|
||||||
// Puerto 9883 es WebSocket en Mosquitto — TCP plain fallaría el handshake.
|
|
||||||
if stub.use_ws {
|
if stub.use_ws {
|
||||||
|
if use_tls {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"🌐 [Provisioning] Conectando vía WebSocket (puerto {})",
|
"🌐 [Provisioning] Conectando vía WebSocket Seguro WSS (puerto {})",
|
||||||
|
stub.mqtt_port
|
||||||
|
);
|
||||||
|
mqtt_opts.set_transport(rumqttc::Transport::wss_with_default_config());
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
"🌐 [Provisioning] Conectando vía WebSocket WS (puerto {})",
|
||||||
stub.mqtt_port
|
stub.mqtt_port
|
||||||
);
|
);
|
||||||
mqtt_opts.set_transport(rumqttc::Transport::ws());
|
mqtt_opts.set_transport(rumqttc::Transport::ws());
|
||||||
|
}
|
||||||
|
} else if use_tls {
|
||||||
|
tracing::info!(
|
||||||
|
"🔒 [Provisioning] Conectando vía TCP Seguro TLS (puerto {})",
|
||||||
|
stub.mqtt_port
|
||||||
|
);
|
||||||
|
mqtt_opts.set_transport(rumqttc::Transport::tls_with_default_config());
|
||||||
} else {
|
} else {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"🔌 [Provisioning] Conectando vía TCP plain (puerto {})",
|
"🔌 [Provisioning] Conectando vía TCP plain (puerto {})",
|
||||||
@@ -240,14 +333,13 @@ impl AgentConfig {
|
|||||||
let response_topic = format!("provision/response/{}", stub.project_id);
|
let response_topic = format!("provision/response/{}", stub.project_id);
|
||||||
let request_topic = format!("provision/request/{}", stub.project_id);
|
let request_topic = format!("provision/request/{}", stub.project_id);
|
||||||
|
|
||||||
let request_payload =
|
let request_payload = serde_json::to_vec(&MqttProvisioningRequest {
|
||||||
serde_json::to_vec(&MqttProvisioningRequest { public_key_pem: &public_key_pem })
|
public_key_pem: &public_key_pem,
|
||||||
|
})
|
||||||
.context("Error serializando request de provisioning")?;
|
.context("Error serializando request de provisioning")?;
|
||||||
|
|
||||||
// 3. Event loop: conectar → suscribir → publicar pubkey → esperar respuesta
|
// 3. Event loop: conectar → suscribir → publicar pubkey → esperar respuesta
|
||||||
let encrypted_config = tokio::time::timeout(
|
let encrypted_config = tokio::time::timeout(std::time::Duration::from_secs(45), async {
|
||||||
std::time::Duration::from_secs(45),
|
|
||||||
async {
|
|
||||||
loop {
|
loop {
|
||||||
match eventloop.poll().await {
|
match eventloop.poll().await {
|
||||||
Ok(rumqttc::Event::Incoming(rumqttc::Packet::ConnAck(_))) => {
|
Ok(rumqttc::Event::Incoming(rumqttc::Packet::ConnAck(_))) => {
|
||||||
@@ -279,8 +371,7 @@ impl AgentConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.context("Timeout esperando respuesta de provisioning (45 s)")?
|
.context("Timeout esperando respuesta de provisioning (45 s)")?
|
||||||
.context("Error durante el provisioning MQTT")?;
|
.context("Error durante el provisioning MQTT")?;
|
||||||
@@ -289,16 +380,18 @@ impl AgentConfig {
|
|||||||
.context("Respuesta de provisioning no es JSON válido")?;
|
.context("Respuesta de provisioning no es JSON válido")?;
|
||||||
|
|
||||||
// 4. Descifrar con ECIES
|
// 4. Descifrar con ECIES
|
||||||
let config_bytes =
|
let config_bytes = shared_lib::provisioning::ecies_decrypt(
|
||||||
shared_lib::provisioning::ecies_decrypt(&private_key_pem, &prov_response.encrypted_config)
|
&private_key_pem,
|
||||||
|
&prov_response.encrypted_config,
|
||||||
|
)
|
||||||
.map_err(|e| anyhow::anyhow!("Error ECIES al descifrar config: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Error ECIES al descifrar config: {}", e))?;
|
||||||
|
|
||||||
let config_toml =
|
let config_toml =
|
||||||
String::from_utf8(config_bytes).context("Config descifrada no es UTF-8 válida")?;
|
String::from_utf8(config_bytes).context("Config descifrada no es UTF-8 válida")?;
|
||||||
|
|
||||||
// 5. Parsear para validar antes de guardar
|
// 5. Parsear para validar antes de guardar
|
||||||
let config: AgentConfig = toml::from_str(&config_toml)
|
let config: AgentConfig =
|
||||||
.context("Config descifrada no es TOML válido")?;
|
toml::from_str(&config_toml).context("Config descifrada no es TOML válido")?;
|
||||||
|
|
||||||
// 6. Guardar en disco para arranques posteriores
|
// 6. Guardar en disco para arranques posteriores
|
||||||
crate::credential_store::save_secret(&provisioned_config_path(), config_toml.as_bytes())
|
crate::credential_store::save_secret(&provisioned_config_path(), config_toml.as_bytes())
|
||||||
@@ -317,8 +410,8 @@ fn load_or_generate_keypair() -> Result<(String, String)> {
|
|||||||
if priv_path.exists() && pub_path.exists() {
|
if priv_path.exists() && pub_path.exists() {
|
||||||
let private_key_bytes = crate::credential_store::load_secret(&priv_path)
|
let private_key_bytes = crate::credential_store::load_secret(&priv_path)
|
||||||
.context("Error leyendo clave privada del agente")?;
|
.context("Error leyendo clave privada del agente")?;
|
||||||
let private_key_pem = String::from_utf8(private_key_bytes)
|
let private_key_pem =
|
||||||
.context("Clave privada no es UTF-8 válida")?;
|
String::from_utf8(private_key_bytes).context("Clave privada no es UTF-8 válida")?;
|
||||||
let public_key_pem = std::fs::read_to_string(&pub_path) // public key stays plain
|
let public_key_pem = std::fs::read_to_string(&pub_path) // public key stays plain
|
||||||
.context("Error leyendo clave pública del agente")?;
|
.context("Error leyendo clave pública del agente")?;
|
||||||
tracing::info!("🔐 Keypair P256 cargado desde disco.");
|
tracing::info!("🔐 Keypair P256 cargado desde disco.");
|
||||||
@@ -334,6 +427,9 @@ fn load_or_generate_keypair() -> Result<(String, String)> {
|
|||||||
std::fs::write(&pub_path, &public_key_pem)
|
std::fs::write(&pub_path, &public_key_pem)
|
||||||
.context("Error guardando clave pública del agente")?;
|
.context("Error guardando clave pública del agente")?;
|
||||||
|
|
||||||
tracing::info!("✅ Keypair P256 generado y guardado en {:?}", priv_path.parent());
|
tracing::info!(
|
||||||
|
"✅ Keypair P256 generado y guardado en {:?}",
|
||||||
|
priv_path.parent()
|
||||||
|
);
|
||||||
Ok((private_key_pem, public_key_pem))
|
Ok((private_key_pem, public_key_pem))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
//! Este módulo maneja todas las estructuras de datos y métodos de carga
|
//! Este módulo maneja todas las estructuras de datos y métodos de carga
|
||||||
//! necesarios para el funcionamiento del sistema en diversos entornos.
|
//! necesarios para el funcionamiento del sistema en diversos entornos.
|
||||||
|
|
||||||
pub mod models;
|
|
||||||
pub mod loader;
|
|
||||||
pub mod example;
|
pub mod example;
|
||||||
|
pub mod loader;
|
||||||
|
pub mod models;
|
||||||
|
|
||||||
pub use models::*;
|
|
||||||
pub use example::generate_example_config;
|
pub use example::generate_example_config;
|
||||||
|
pub use models::*;
|
||||||
|
|
||||||
impl models::AgentConfig {
|
impl models::AgentConfig {
|
||||||
/// Obtiene el hostname del dispositivo (o el configurado).
|
/// Obtiene el hostname del dispositivo (o el configurado).
|
||||||
@@ -23,9 +23,10 @@ impl models::AgentConfig {
|
|||||||
|
|
||||||
/// Obtiene el nombre del contenedor principal.
|
/// Obtiene el nombre del contenedor principal.
|
||||||
pub fn container_name(&self) -> String {
|
pub fn container_name(&self) -> String {
|
||||||
self.docker.container_name.clone().unwrap_or_else(|| {
|
self.docker
|
||||||
format!("omnioil-scada-{}", &self.agent.project_id[..8])
|
.container_name
|
||||||
})
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("omnioil-scada-{}", &self.agent.project_id[..8]))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Obtiene el intervalo de health check.
|
/// Obtiene el intervalo de health check.
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
//! La seguridad viene de los permisos del sistema de archivos. El servicio
|
//! La seguridad viene de los permisos del sistema de archivos. El servicio
|
||||||
//! corre como usuario dedicado `omnioil-agent` que es el único con acceso.
|
//! corre como usuario dedicado `omnioil-agent` que es el único con acceso.
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
/// Guarda datos secretos en disco con protección según la plataforma.
|
/// Guarda datos secretos en disco con protección según la plataforma.
|
||||||
/// Windows: cifra con DPAPI (machine-scope) antes de escribir.
|
/// Windows: cifra con DPAPI (machine-scope) antes de escribir.
|
||||||
@@ -18,11 +18,13 @@ pub fn save_secret(path: &Path, data: &[u8]) -> Result<()> {
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let encrypted = dpapi::protect(data).context("Error cifrando secreto con DPAPI")?;
|
let encrypted = dpapi::protect(data).context("Error cifrando secreto con DPAPI")?;
|
||||||
std::fs::write(path, &encrypted).with_context(|| format!("Error guardando secreto en {:?}", path))?;
|
std::fs::write(path, &encrypted)
|
||||||
|
.with_context(|| format!("Error guardando secreto en {:?}", path))?;
|
||||||
}
|
}
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
std::fs::write(path, data).with_context(|| format!("Error guardando secreto en {:?}", path))?;
|
std::fs::write(path, data)
|
||||||
|
.with_context(|| format!("Error guardando secreto en {:?}", path))?;
|
||||||
set_secret_permissions(path)?;
|
set_secret_permissions(path)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -30,7 +32,8 @@ pub fn save_secret(path: &Path, data: &[u8]) -> Result<()> {
|
|||||||
|
|
||||||
/// Carga datos secretos desde disco y los descifra si es necesario.
|
/// Carga datos secretos desde disco y los descifra si es necesario.
|
||||||
pub fn load_secret(path: &Path) -> Result<Vec<u8>> {
|
pub fn load_secret(path: &Path) -> Result<Vec<u8>> {
|
||||||
let raw = std::fs::read(path).with_context(|| format!("Error leyendo secreto desde {:?}", path))?;
|
let raw =
|
||||||
|
std::fs::read(path).with_context(|| format!("Error leyendo secreto desde {:?}", path))?;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
dpapi::unprotect(&raw).context("Error descifrando secreto con DPAPI")
|
dpapi::unprotect(&raw).context("Error descifrando secreto con DPAPI")
|
||||||
@@ -73,7 +76,7 @@ fn set_secret_permissions(path: &Path) -> Result<()> {
|
|||||||
mod dpapi {
|
mod dpapi {
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use windows_sys::Win32::Security::Cryptography::{
|
use windows_sys::Win32::Security::Cryptography::{
|
||||||
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_LOCAL_MACHINE,
|
CRYPTPROTECT_LOCAL_MACHINE, CryptProtectData, CryptUnprotectData,
|
||||||
};
|
};
|
||||||
|
|
||||||
// LocalFree is a legacy Win32 function — declare it directly to avoid feature-flag issues.
|
// LocalFree is a legacy Win32 function — declare it directly to avoid feature-flag issues.
|
||||||
@@ -88,11 +91,14 @@ mod dpapi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn protect(data: &[u8]) -> Result<Vec<u8>> {
|
pub fn protect(data: &[u8]) -> Result<Vec<u8>> {
|
||||||
let mut input = DataBlob {
|
let input = DataBlob {
|
||||||
cb_data: data.len() as u32,
|
cb_data: data.len() as u32,
|
||||||
pb_data: data.as_ptr() as *mut u8,
|
pb_data: data.as_ptr() as *mut u8,
|
||||||
};
|
};
|
||||||
let mut output = DataBlob { cb_data: 0, pb_data: std::ptr::null_mut() };
|
let mut output = DataBlob {
|
||||||
|
cb_data: 0,
|
||||||
|
pb_data: std::ptr::null_mut(),
|
||||||
|
};
|
||||||
|
|
||||||
let ok = unsafe {
|
let ok = unsafe {
|
||||||
CryptProtectData(
|
CryptProtectData(
|
||||||
@@ -112,19 +118,21 @@ mod dpapi {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = unsafe {
|
let result =
|
||||||
std::slice::from_raw_parts(output.pb_data, output.cb_data as usize).to_vec()
|
unsafe { std::slice::from_raw_parts(output.pb_data, output.cb_data as usize).to_vec() };
|
||||||
};
|
|
||||||
unsafe { LocalFree(output.pb_data as *mut std::ffi::c_void) };
|
unsafe { LocalFree(output.pb_data as *mut std::ffi::c_void) };
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unprotect(data: &[u8]) -> Result<Vec<u8>> {
|
pub fn unprotect(data: &[u8]) -> Result<Vec<u8>> {
|
||||||
let mut input = DataBlob {
|
let input = DataBlob {
|
||||||
cb_data: data.len() as u32,
|
cb_data: data.len() as u32,
|
||||||
pb_data: data.as_ptr() as *mut u8,
|
pb_data: data.as_ptr() as *mut u8,
|
||||||
};
|
};
|
||||||
let mut output = DataBlob { cb_data: 0, pb_data: std::ptr::null_mut() };
|
let mut output = DataBlob {
|
||||||
|
cb_data: 0,
|
||||||
|
pb_data: std::ptr::null_mut(),
|
||||||
|
};
|
||||||
|
|
||||||
let ok = unsafe {
|
let ok = unsafe {
|
||||||
CryptUnprotectData(
|
CryptUnprotectData(
|
||||||
@@ -144,9 +152,8 @@ mod dpapi {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = unsafe {
|
let result =
|
||||||
std::slice::from_raw_parts(output.pb_data, output.cb_data as usize).to_vec()
|
unsafe { std::slice::from_raw_parts(output.pb_data, output.cb_data as usize).to_vec() };
|
||||||
};
|
|
||||||
unsafe { LocalFree(output.pb_data as *mut std::ffi::c_void) };
|
unsafe { LocalFree(output.pb_data as *mut std::ffi::c_void) };
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
use crate::drivers::{modbus_tcp::ModbusTcpDriver, s7_comm::S7CommDriver, opc_ua::OpcUaDriver, ProtocolDriver};
|
|
||||||
use crate::persistence_manager::StoreAndForward;
|
|
||||||
use crate::deadband::DeadbandFilter;
|
use crate::deadband::DeadbandFilter;
|
||||||
|
use crate::drivers::{
|
||||||
|
ProtocolDriver, modbus_tcp::ModbusTcpDriver, mqtt_sparkplug_b::MqttSparkplugBDriver,
|
||||||
|
opc_ua::OpcUaDriver, s7_comm::S7CommDriver, sql_db::SqlDbDriver,
|
||||||
|
};
|
||||||
|
use crate::persistence_manager::StoreAndForward;
|
||||||
use rumqttc::{AsyncClient, QoS};
|
use rumqttc::{AsyncClient, QoS};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
|
use shared_lib::edge_models::config::AssetConfig;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio::time::{interval, Duration};
|
use tokio::time::{Duration, interval};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use shared_lib::edge_models::config::AssetConfig;
|
|
||||||
|
|
||||||
pub struct DataCollectorManager {
|
pub struct DataCollectorManager {
|
||||||
mqtt_client: AsyncClient,
|
mqtt_client: AsyncClient,
|
||||||
@@ -19,7 +22,12 @@ pub struct DataCollectorManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DataCollectorManager {
|
impl DataCollectorManager {
|
||||||
pub fn new(mqtt_client: AsyncClient, project_id: Uuid, storage: Arc<StoreAndForward>, deadband_threshold_pct: f64) -> Self {
|
pub fn new(
|
||||||
|
mqtt_client: AsyncClient,
|
||||||
|
project_id: Uuid,
|
||||||
|
storage: Arc<StoreAndForward>,
|
||||||
|
deadband_threshold_pct: f64,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
mqtt_client,
|
mqtt_client,
|
||||||
project_id,
|
project_id,
|
||||||
@@ -29,7 +37,11 @@ impl DataCollectorManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_config(&self, assets: Vec<AssetConfig>, log_reporter: Arc<crate::log_reporter::LogReporter>) {
|
pub async fn update_config(
|
||||||
|
&self,
|
||||||
|
assets: Vec<AssetConfig>,
|
||||||
|
log_reporter: Arc<crate::log_reporter::LogReporter>,
|
||||||
|
) {
|
||||||
let mut handles = self.handles.lock().await;
|
let mut handles = self.handles.lock().await;
|
||||||
|
|
||||||
let msg = format!("🔄 Processing configuration for {} assets...", assets.len());
|
let msg = format!("🔄 Processing configuration for {} assets...", assets.len());
|
||||||
@@ -64,32 +76,109 @@ impl DataCollectorManager {
|
|||||||
|
|
||||||
let var_count = variables.len();
|
let var_count = variables.len();
|
||||||
let deadband_pct = self.deadband_threshold_pct;
|
let deadband_pct = self.deadband_threshold_pct;
|
||||||
tracing::info!("📡 Starting collector for device '{}' ({} variables)", device.name, var_count);
|
tracing::info!(
|
||||||
|
"📡 Starting collector for device '{}' ({} variables)",
|
||||||
|
device.name,
|
||||||
|
var_count
|
||||||
|
);
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
let mut driver: Box<dyn ProtocolDriver> = match device.protocol {
|
let mut driver: Box<dyn ProtocolDriver> = match device.protocol {
|
||||||
shared_lib::edge_models::devices::DeviceProtocol::ModbusTcp => {
|
shared_lib::edge_models::devices::DeviceProtocol::ModbusTcp => {
|
||||||
let unit_id = device.protocol_config.get("unit_id").and_then(|v| v.as_u64()).unwrap_or(1) as u8;
|
let unit_id = device
|
||||||
tracing::debug!("🔧 Initializing Modbus driver for {} at {}:{}", device.name, device.host, device.port);
|
.protocol_config
|
||||||
Box::new(ModbusTcpDriver::new(&device.host, device.port as u16, unit_id))
|
.get("unit_id")
|
||||||
},
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(1) as u8;
|
||||||
|
tracing::debug!(
|
||||||
|
"🔧 Initializing Modbus driver for {} at {}:{}",
|
||||||
|
device.name,
|
||||||
|
device.host,
|
||||||
|
device.port
|
||||||
|
);
|
||||||
|
Box::new(ModbusTcpDriver::new(
|
||||||
|
&device.host,
|
||||||
|
device.port as u16,
|
||||||
|
unit_id,
|
||||||
|
))
|
||||||
|
}
|
||||||
shared_lib::edge_models::devices::DeviceProtocol::S7 => {
|
shared_lib::edge_models::devices::DeviceProtocol::S7 => {
|
||||||
let rack = device.protocol_config.get("rack").and_then(|v| v.as_u64()).unwrap_or(0) as u16;
|
let rack = device
|
||||||
let slot = device.protocol_config.get("slot").and_then(|v| v.as_u64()).unwrap_or(1) as u16;
|
.protocol_config
|
||||||
tracing::debug!("🔧 Initializing S7 driver for {} at {}:{}", device.name, device.host, device.port);
|
.get("rack")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u16;
|
||||||
|
let slot = device
|
||||||
|
.protocol_config
|
||||||
|
.get("slot")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(1) as u16;
|
||||||
|
tracing::debug!(
|
||||||
|
"🔧 Initializing S7 driver for {} at {}:{}",
|
||||||
|
device.name,
|
||||||
|
device.host,
|
||||||
|
device.port
|
||||||
|
);
|
||||||
Box::new(S7CommDriver::new(&device.host, rack, slot))
|
Box::new(S7CommDriver::new(&device.host, rack, slot))
|
||||||
},
|
}
|
||||||
shared_lib::edge_models::devices::DeviceProtocol::OpcUa => {
|
shared_lib::edge_models::devices::DeviceProtocol::OpcUa => {
|
||||||
let endpoint = device.protocol_config
|
let endpoint = device
|
||||||
|
.protocol_config
|
||||||
.get("endpoint")
|
.get("endpoint")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or(&device.host)
|
.unwrap_or(&device.host)
|
||||||
.to_string();
|
.to_string();
|
||||||
tracing::debug!("🔧 Initializing OPC-UA driver for {} at {}", device.name, endpoint);
|
tracing::debug!(
|
||||||
|
"🔧 Initializing OPC-UA driver for {} at {}",
|
||||||
|
device.name,
|
||||||
|
endpoint
|
||||||
|
);
|
||||||
Box::new(OpcUaDriver::new(&endpoint))
|
Box::new(OpcUaDriver::new(&endpoint))
|
||||||
},
|
}
|
||||||
|
shared_lib::edge_models::devices::DeviceProtocol::MqttSparkplugB => {
|
||||||
|
let group_id = device
|
||||||
|
.protocol_config
|
||||||
|
.get("group_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let edge_node_id = device
|
||||||
|
.protocol_config
|
||||||
|
.get("edge_node_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
tracing::debug!(
|
||||||
|
"🔧 Initializing MQTT Sparkplug B driver for {} at {}:{} (Group: {}, Node: {})",
|
||||||
|
device.name,
|
||||||
|
device.host,
|
||||||
|
device.port,
|
||||||
|
group_id,
|
||||||
|
edge_node_id
|
||||||
|
);
|
||||||
|
Box::new(MqttSparkplugBDriver::new(
|
||||||
|
&device.host,
|
||||||
|
device.port as u16,
|
||||||
|
group_id,
|
||||||
|
edge_node_id,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
shared_lib::edge_models::devices::DeviceProtocol::SqlDb => {
|
||||||
|
let conn_str = device
|
||||||
|
.protocol_config
|
||||||
|
.get("connection_string")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
tracing::debug!(
|
||||||
|
"🔧 Initializing SQL Database driver for {} with connection string: {}",
|
||||||
|
device.name,
|
||||||
|
conn_str
|
||||||
|
);
|
||||||
|
Box::new(SqlDbDriver::new(conn_str))
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let msg = format!("Unsupported protocol for device {}: {:?}", device.name, device.protocol);
|
let msg = format!(
|
||||||
|
"Unsupported protocol for device {}: {:?}",
|
||||||
|
device.name, device.protocol
|
||||||
|
);
|
||||||
tracing::error!("{}", msg);
|
tracing::error!("{}", msg);
|
||||||
let _ = reporter.error("COLLECTOR", &msg).await;
|
let _ = reporter.error("COLLECTOR", &msg).await;
|
||||||
return;
|
return;
|
||||||
@@ -105,11 +194,15 @@ impl DataCollectorManager {
|
|||||||
|
|
||||||
if !driver.is_alive() {
|
if !driver.is_alive() {
|
||||||
connected_notified = false;
|
connected_notified = false;
|
||||||
let msg = format!("🔌 Attempting to connect to {} at {}:{}...", device.name, device.host, device.port);
|
let msg = format!(
|
||||||
|
"🔌 Attempting to connect to {} at {}:{}...",
|
||||||
|
device.name, device.host, device.port
|
||||||
|
);
|
||||||
let _ = reporter.info("COLLECTOR", &msg).await;
|
let _ = reporter.info("COLLECTOR", &msg).await;
|
||||||
|
|
||||||
if let Err(e) = driver.connect().await {
|
if let Err(e) = driver.connect().await {
|
||||||
let msg = format!("❌ Connection failed for {}: {}", device.name, e);
|
let msg =
|
||||||
|
format!("❌ Connection failed for {}: {}", device.name, e);
|
||||||
tracing::warn!("{}", msg);
|
tracing::warn!("{}", msg);
|
||||||
let _ = reporter.error("COLLECTOR", &msg).await;
|
let _ = reporter.error("COLLECTOR", &msg).await;
|
||||||
continue;
|
continue;
|
||||||
@@ -117,7 +210,10 @@ impl DataCollectorManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !connected_notified {
|
if !connected_notified {
|
||||||
let msg = format!("✅ Successfully connected to device '{}' at {}:{}", device.name, device.host, device.port);
|
let msg = format!(
|
||||||
|
"✅ Successfully connected to device '{}' at {}:{}",
|
||||||
|
device.name, device.host, device.port
|
||||||
|
);
|
||||||
tracing::info!("{}", msg);
|
tracing::info!("{}", msg);
|
||||||
let _ = reporter.info("COLLECTOR", &msg).await;
|
let _ = reporter.info("COLLECTOR", &msg).await;
|
||||||
connected_notified = true;
|
connected_notified = true;
|
||||||
@@ -129,12 +225,17 @@ impl DataCollectorManager {
|
|||||||
match driver.read_variables(&variables).await {
|
match driver.read_variables(&variables).await {
|
||||||
Ok(values) => {
|
Ok(values) => {
|
||||||
if !values.is_empty() {
|
if !values.is_empty() {
|
||||||
let msg = format!("📥 Read successful for {}: {} values obtained", device.name, values.len());
|
let msg = format!(
|
||||||
|
"📥 Read successful for {}: {} values obtained",
|
||||||
|
device.name,
|
||||||
|
values.len()
|
||||||
|
);
|
||||||
let _ = reporter.info("COLLECTOR", &msg).await;
|
let _ = reporter.info("COLLECTOR", &msg).await;
|
||||||
|
|
||||||
// Apply deadband filter: suppress numeric values that haven't
|
// Apply deadband filter: suppress numeric values that haven't
|
||||||
// changed beyond the configured threshold percentage.
|
// changed beyond the configured threshold percentage.
|
||||||
let filtered: HashMap<String, Value> = values.into_iter()
|
let filtered: HashMap<String, Value> = values
|
||||||
|
.into_iter()
|
||||||
.filter(|(key, val)| {
|
.filter(|(key, val)| {
|
||||||
match val.as_f64() {
|
match val.as_f64() {
|
||||||
Some(f) => deadband.should_publish(key, f),
|
Some(f) => deadband.should_publish(key, f),
|
||||||
@@ -144,7 +245,10 @@ impl DataCollectorManager {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if filtered.is_empty() {
|
if filtered.is_empty() {
|
||||||
tracing::debug!("🔇 All values suppressed by deadband filter for {}", device.name);
|
tracing::debug!(
|
||||||
|
"🔇 All values suppressed by deadband filter for {}",
|
||||||
|
device.name
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
let payload = json!({
|
let payload = json!({
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
@@ -156,23 +260,46 @@ impl DataCollectorManager {
|
|||||||
let payload_str = payload.to_string();
|
let payload_str = payload.to_string();
|
||||||
let payload_bytes = payload_str.as_bytes().to_vec();
|
let payload_bytes = payload_str.as_bytes().to_vec();
|
||||||
|
|
||||||
let msg = format!("📤 Publishing telemetry for {} to MQTT topic: {}", device.name, topic);
|
let msg = format!(
|
||||||
|
"📤 Publishing telemetry for {} to MQTT topic: {}",
|
||||||
|
device.name, topic
|
||||||
|
);
|
||||||
let _ = reporter.info("MQTT", &msg).await;
|
let _ = reporter.info("MQTT", &msg).await;
|
||||||
|
|
||||||
if let Err(e) = mqtt.publish(&topic, QoS::AtMostOnce, false, payload_bytes).await {
|
if let Err(e) = mqtt
|
||||||
let msg = format!("⚠️ MQTT publish failed for {}: {}. Buffering locally.", device.name, e);
|
.publish(&topic, QoS::AtMostOnce, false, payload_bytes)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let msg = format!(
|
||||||
|
"⚠️ MQTT publish failed for {}: {}. Buffering locally.",
|
||||||
|
device.name, e
|
||||||
|
);
|
||||||
let _ = reporter.error("MQTT", &msg).await;
|
let _ = reporter.error("MQTT", &msg).await;
|
||||||
let _ = storage_clone.save(&topic, &payload_str);
|
let _ = storage_clone.save(&topic, &payload_str);
|
||||||
} else {
|
} else {
|
||||||
let _ = reporter.info("MQTT", &format!("✅ Data sent for {}", device.name)).await;
|
let _ = reporter
|
||||||
|
.info(
|
||||||
|
"MQTT",
|
||||||
|
&format!("✅ Data sent for {}", device.name),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let _ = reporter.warn("COLLECTOR", &format!("⚠️ No values returned for device {}", device.name)).await;
|
let _ = reporter
|
||||||
|
.warn(
|
||||||
|
"COLLECTOR",
|
||||||
|
&format!(
|
||||||
|
"⚠️ No values returned for device {}",
|
||||||
|
device.name
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = format!("❌ Error reading from device {}: {}", device.name, e);
|
let msg =
|
||||||
|
format!("❌ Error reading from device {}: {}", device.name, e);
|
||||||
tracing::error!("{}", msg);
|
tracing::error!("{}", msg);
|
||||||
let _ = reporter.error("COLLECTOR", &msg).await;
|
let _ = reporter.error("COLLECTOR", &msg).await;
|
||||||
let _ = driver.disconnect().await;
|
let _ = driver.disconnect().await;
|
||||||
@@ -185,7 +312,10 @@ impl DataCollectorManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let msg = format!("Data collector operational with {} active drivers", handles.len());
|
let msg = format!(
|
||||||
|
"Data collector operational with {} active drivers",
|
||||||
|
handles.len()
|
||||||
|
);
|
||||||
tracing::info!("✅ {}", msg);
|
tracing::info!("✅ {}", msg);
|
||||||
let _ = log_reporter.info("DEPLOY", &msg).await;
|
let _ = log_reporter.info("DEPLOY", &msg).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
use crate::persistence_manager::StoreAndForward;
|
use crate::persistence_manager::StoreAndForward;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Router,
|
||||||
extract::{Json, State},
|
extract::{Json, State},
|
||||||
routing::post,
|
routing::post,
|
||||||
Router,
|
|
||||||
};
|
};
|
||||||
use rumqttc::{AsyncClient, QoS};
|
use rumqttc::{AsyncClient, QoS};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::atomic::{AtomicI64, Ordering};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::time::{interval, Duration};
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
use tokio::time::{Duration, interval};
|
||||||
|
|
||||||
/// Estado compartido para el gateway de datos.
|
/// Estado compartido para el gateway de datos.
|
||||||
struct GatewayState {
|
struct GatewayState {
|
||||||
@@ -93,9 +93,13 @@ async fn ingest_data(
|
|||||||
let final_payload = if data_bytes.len() > 512 {
|
let final_payload = if data_bytes.len() > 512 {
|
||||||
match shared_lib::compression::compress(data_bytes) {
|
match shared_lib::compression::compress(data_bytes) {
|
||||||
Ok(compressed) => {
|
Ok(compressed) => {
|
||||||
tracing::debug!("Compression: {} -> {} bytes", data_bytes.len(), compressed.len());
|
tracing::debug!(
|
||||||
|
"Compression: {} -> {} bytes",
|
||||||
|
data_bytes.len(),
|
||||||
|
compressed.len()
|
||||||
|
);
|
||||||
compressed
|
compressed
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Compression error: {}", e);
|
tracing::error!("Compression error: {}", e);
|
||||||
data_bytes.to_vec()
|
data_bytes.to_vec()
|
||||||
@@ -106,7 +110,9 @@ async fn ingest_data(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Intentar publicar a MQTT con un timeout corto para no colgar a Node-RED
|
// Intentar publicar a MQTT con un timeout corto para no colgar a Node-RED
|
||||||
let publish_future = state.mqtt_client.publish(topic, QoS::AtLeastOnce, false, final_payload);
|
let publish_future = state
|
||||||
|
.mqtt_client
|
||||||
|
.publish(topic, QoS::AtLeastOnce, false, final_payload);
|
||||||
|
|
||||||
match tokio::time::timeout(Duration::from_secs(2), publish_future).await {
|
match tokio::time::timeout(Duration::from_secs(2), publish_future).await {
|
||||||
Ok(Ok(_)) => {
|
Ok(Ok(_)) => {
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ use async_trait::async_trait;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct EtherNetIpDriver {
|
pub struct EtherNetIpDriver {
|
||||||
_host: String,
|
_host: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl EtherNetIpDriver {
|
impl EtherNetIpDriver {
|
||||||
pub fn new(host: &str) -> Self {
|
pub fn new(host: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -25,7 +27,10 @@ impl ProtocolDriver for EtherNetIpDriver {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_variables(&mut self, _variables: &HashMap<String, String>) -> anyhow::Result<HashMap<String, Value>> {
|
async fn read_variables(
|
||||||
|
&mut self,
|
||||||
|
_variables: &HashMap<String, String>,
|
||||||
|
) -> anyhow::Result<HashMap<String, Value>> {
|
||||||
Ok(HashMap::new())
|
Ok(HashMap::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,18 @@ pub trait ProtocolDriver: Send + Sync {
|
|||||||
|
|
||||||
/// Lee un conjunto de variables.
|
/// Lee un conjunto de variables.
|
||||||
/// El map de entrada contiene "alias" -> "dirección/parámetros".
|
/// El map de entrada contiene "alias" -> "dirección/parámetros".
|
||||||
async fn read_variables(&mut self, variables: &HashMap<String, String>) -> anyhow::Result<HashMap<String, Value>>;
|
async fn read_variables(
|
||||||
|
&mut self,
|
||||||
|
variables: &HashMap<String, String>,
|
||||||
|
) -> anyhow::Result<HashMap<String, Value>>;
|
||||||
|
|
||||||
/// Verifica si el dispositivo está en línea.
|
/// Verifica si el dispositivo está en línea.
|
||||||
fn is_alive(&self) -> bool;
|
fn is_alive(&self) -> bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod modbus_tcp;
|
|
||||||
pub mod s7_comm;
|
|
||||||
pub mod ethernet_ip;
|
pub mod ethernet_ip;
|
||||||
|
pub mod modbus_tcp;
|
||||||
|
pub mod mqtt_sparkplug_b;
|
||||||
pub mod opc_ua;
|
pub mod opc_ua;
|
||||||
|
pub mod s7_comm;
|
||||||
|
pub mod sql_db;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
use super::ProtocolDriver;
|
use super::ProtocolDriver;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
use tokio_modbus::client::Context;
|
use tokio_modbus::client::Context;
|
||||||
use tokio_modbus::prelude::*;
|
use tokio_modbus::prelude::*;
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
pub struct ModbusTcpDriver {
|
pub struct ModbusTcpDriver {
|
||||||
host: String,
|
host: String,
|
||||||
@@ -32,7 +32,11 @@ impl ProtocolDriver for ModbusTcpDriver {
|
|||||||
let socket_addr: SocketAddr = format!("{}:{}", self.host, self.port).parse()?;
|
let socket_addr: SocketAddr = format!("{}:{}", self.host, self.port).parse()?;
|
||||||
let ctx = tcp::connect_slave(socket_addr, Slave(self.unit_id)).await?;
|
let ctx = tcp::connect_slave(socket_addr, Slave(self.unit_id)).await?;
|
||||||
self.ctx = Some(Arc::new(Mutex::new(ctx)));
|
self.ctx = Some(Arc::new(Mutex::new(ctx)));
|
||||||
tracing::info!("✅ Connected to Modbus TCP device at {}:{}", self.host, self.port);
|
tracing::info!(
|
||||||
|
"✅ Connected to Modbus TCP device at {}:{}",
|
||||||
|
self.host,
|
||||||
|
self.port
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,8 +45,14 @@ impl ProtocolDriver for ModbusTcpDriver {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_variables(&mut self, variables: &HashMap<String, String>) -> anyhow::Result<HashMap<String, Value>> {
|
async fn read_variables(
|
||||||
let ctx_mutex = self.ctx.as_ref().ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
&mut self,
|
||||||
|
variables: &HashMap<String, String>,
|
||||||
|
) -> anyhow::Result<HashMap<String, Value>> {
|
||||||
|
let ctx_mutex = self
|
||||||
|
.ctx
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
||||||
let mut ctx = ctx_mutex.lock().await;
|
let mut ctx = ctx_mutex.lock().await;
|
||||||
let mut results = HashMap::new();
|
let mut results = HashMap::new();
|
||||||
|
|
||||||
@@ -56,8 +66,7 @@ impl ProtocolDriver for ModbusTcpDriver {
|
|||||||
|
|
||||||
let addr: u16 = addr_str.parse().unwrap_or(0);
|
let addr: u16 = addr_str.parse().unwrap_or(0);
|
||||||
match area {
|
match area {
|
||||||
"HR" => {
|
"HR" => match ctx.read_holding_registers(addr, 1).await {
|
||||||
match ctx.read_holding_registers(addr, 1).await {
|
|
||||||
Ok(Ok(registers)) => {
|
Ok(Ok(registers)) => {
|
||||||
if let Some(v) = registers.first() {
|
if let Some(v) = registers.first() {
|
||||||
results.insert(alias.clone(), json!(v));
|
results.insert(alias.clone(), json!(v));
|
||||||
@@ -65,10 +74,8 @@ impl ProtocolDriver for ModbusTcpDriver {
|
|||||||
}
|
}
|
||||||
Ok(Err(e)) => tracing::warn!("Modbus HR Exception: {} for addr {}", e, addr),
|
Ok(Err(e)) => tracing::warn!("Modbus HR Exception: {} for addr {}", e, addr),
|
||||||
Err(e) => tracing::warn!("Modbus HR Transport error: {} for addr {}", e, addr),
|
Err(e) => tracing::warn!("Modbus HR Transport error: {} for addr {}", e, addr),
|
||||||
}
|
},
|
||||||
}
|
"IR" => match ctx.read_input_registers(addr, 1).await {
|
||||||
"IR" => {
|
|
||||||
match ctx.read_input_registers(addr, 1).await {
|
|
||||||
Ok(Ok(registers)) => {
|
Ok(Ok(registers)) => {
|
||||||
if let Some(v) = registers.first() {
|
if let Some(v) = registers.first() {
|
||||||
results.insert(alias.clone(), json!(v));
|
results.insert(alias.clone(), json!(v));
|
||||||
@@ -76,8 +83,7 @@ impl ProtocolDriver for ModbusTcpDriver {
|
|||||||
}
|
}
|
||||||
Ok(Err(e)) => tracing::warn!("Modbus IR Exception: {} for addr {}", e, addr),
|
Ok(Err(e)) => tracing::warn!("Modbus IR Exception: {} for addr {}", e, addr),
|
||||||
Err(e) => tracing::warn!("Modbus IR Transport error: {} for addr {}", e, addr),
|
Err(e) => tracing::warn!("Modbus IR Transport error: {} for addr {}", e, addr),
|
||||||
}
|
},
|
||||||
}
|
|
||||||
_ => tracing::warn!("Unsupported Modbus area: {}", area),
|
_ => tracing::warn!("Unsupported Modbus area: {}", area),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
services/edge-agent/src/drivers/mqtt_sparkplug_b.rs
Normal file
54
services/edge-agent/src/drivers/mqtt_sparkplug_b.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use super::ProtocolDriver;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct MqttSparkplugBDriver {
|
||||||
|
_host: String,
|
||||||
|
_port: u16,
|
||||||
|
_group_id: String,
|
||||||
|
_edge_node_id: String,
|
||||||
|
connected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MqttSparkplugBDriver {
|
||||||
|
pub fn new(host: &str, port: u16, group_id: &str, edge_node_id: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
_host: host.to_string(),
|
||||||
|
_port: port,
|
||||||
|
_group_id: group_id.to_string(),
|
||||||
|
_edge_node_id: edge_node_id.to_string(),
|
||||||
|
connected: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProtocolDriver for MqttSparkplugBDriver {
|
||||||
|
async fn connect(&mut self) -> anyhow::Result<()> {
|
||||||
|
tracing::info!(
|
||||||
|
"🔌 Connecting MQTT Sparkplug B driver to {}:{}",
|
||||||
|
self._host,
|
||||||
|
self._port
|
||||||
|
);
|
||||||
|
self.connected = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn disconnect(&mut self) -> anyhow::Result<()> {
|
||||||
|
self.connected = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_variables(
|
||||||
|
&mut self,
|
||||||
|
_variables: &HashMap<String, String>,
|
||||||
|
) -> anyhow::Result<HashMap<String, Value>> {
|
||||||
|
// Return dummy data or read local buffer in a full Sparkplug B client implementation.
|
||||||
|
Ok(HashMap::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_alive(&self) -> bool {
|
||||||
|
self.connected
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
use super::ProtocolDriver;
|
use super::ProtocolDriver;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::{json, Value};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use s7::client::Client;
|
use s7::client::Client;
|
||||||
use s7::tcp;
|
use s7::tcp;
|
||||||
use s7::transport::Connection;
|
use s7::transport::Connection;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
|
||||||
@@ -43,8 +43,14 @@ impl ProtocolDriver for S7CommDriver {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_variables(&mut self, variables: &HashMap<String, String>) -> anyhow::Result<HashMap<String, Value>> {
|
async fn read_variables(
|
||||||
let client = self.client.as_mut().ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
&mut self,
|
||||||
|
variables: &HashMap<String, String>,
|
||||||
|
) -> anyhow::Result<HashMap<String, Value>> {
|
||||||
|
let client = self
|
||||||
|
.client
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
||||||
let mut results = HashMap::new();
|
let mut results = HashMap::new();
|
||||||
|
|
||||||
for (alias, address) in variables {
|
for (alias, address) in variables {
|
||||||
|
|||||||
48
services/edge-agent/src/drivers/sql_db.rs
Normal file
48
services/edge-agent/src/drivers/sql_db.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use super::ProtocolDriver;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct SqlDbDriver {
|
||||||
|
connection_string: String,
|
||||||
|
connected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqlDbDriver {
|
||||||
|
pub fn new(connection_string: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
connection_string: connection_string.to_string(),
|
||||||
|
connected: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProtocolDriver for SqlDbDriver {
|
||||||
|
async fn connect(&mut self) -> anyhow::Result<()> {
|
||||||
|
tracing::info!(
|
||||||
|
"🔌 Connecting SQL Database driver with connection string: {}",
|
||||||
|
self.connection_string
|
||||||
|
);
|
||||||
|
self.connected = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn disconnect(&mut self) -> anyhow::Result<()> {
|
||||||
|
self.connected = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_variables(
|
||||||
|
&mut self,
|
||||||
|
_variables: &HashMap<String, String>,
|
||||||
|
) -> anyhow::Result<HashMap<String, Value>> {
|
||||||
|
// SQL Database driver polling logic.
|
||||||
|
// Returns empty HashMap for stub implementation.
|
||||||
|
Ok(HashMap::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_alive(&self) -> bool {
|
||||||
|
self.connected
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
//! Health Checker — Monitoreo periódico del Agente Nativo (sin Docker).
|
//! Health Checker — Monitoreo periódico del Agente Nativo (sin Docker).
|
||||||
|
|
||||||
use shared_lib::mqtt_messages::{HealthReport, AgentStatus};
|
use chrono::Utc;
|
||||||
use rumqttc::{AsyncClient, QoS};
|
use rumqttc::{AsyncClient, QoS};
|
||||||
|
use shared_lib::mqtt_messages::{AgentStatus, HealthReport};
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::time::interval;
|
use tokio::time::interval;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono::Utc;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
use super::config::AgentConfig;
|
use super::config::AgentConfig;
|
||||||
|
|
||||||
@@ -30,7 +29,10 @@ pub async fn start_health_checker(
|
|||||||
let mut timer = interval(check_interval);
|
let mut timer = interval(check_interval);
|
||||||
let mut json_buf = Vec::with_capacity(512);
|
let mut json_buf = Vec::with_capacity(512);
|
||||||
|
|
||||||
tracing::info!("🏥 Native Health checker started (interval: {:?})", check_interval);
|
tracing::info!(
|
||||||
|
"🏥 Native Health checker started (interval: {:?})",
|
||||||
|
check_interval
|
||||||
|
);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
timer.tick().await;
|
timer.tick().await;
|
||||||
@@ -61,11 +63,12 @@ pub async fn start_health_checker(
|
|||||||
let topic = health_topic.clone();
|
let topic = health_topic.clone();
|
||||||
let payload = json_buf.clone();
|
let payload = json_buf.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = mqtt
|
let _ = mqtt.publish(&topic, QoS::AtMostOnce, false, payload).await;
|
||||||
.publish(&topic, QoS::AtMostOnce, false, payload)
|
|
||||||
.await;
|
|
||||||
});
|
});
|
||||||
tracing::debug!("📡 Native health report sent via MQTT for project: {}", project_id);
|
tracing::debug!(
|
||||||
|
"📡 Native health report sent via MQTT for project: {}",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user