añadido Argos Core

This commit is contained in:
2025-08-20 01:16:53 +02:00
parent 550d5fbe52
commit d973225012
17 changed files with 550 additions and 151 deletions

View File

@@ -1,12 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: panel-argos-cert
namespace: argos-core
spec:
secretName: panel-argos-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- panel.argos.c2et.net

View File

@@ -11,5 +11,4 @@ data:
hls: no
webrtc: yes
paths:
all:
sourceOnDemand: yes
all: {}

View File

@@ -1,9 +1,11 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: mosquitto-conf
name: mosquitto-config
namespace: argos-core
data:
mosquitto.conf: |
persistence true
persistence_location /mosquitto/data/
listener 1883 0.0.0.0
allow_anonymous true

View File

@@ -6,11 +6,14 @@ metadata:
data:
settings.yaml: |
mqtt:
host: mqtt.argos.interna
port: 1883
host: mosquitto.argos-core.svc.cluster.local
topic: frigate/events
topic_base: argos/alerts
port: 1883
client_id: argos-core
minio:
endpoint: minio.argos-core.svc.cluster.local:9000
secure: false
bucket: argos
region: us-east-1
edges:

View File

@@ -5,83 +5,136 @@ metadata:
namespace: argos-core
data:
app.py: |
import os, sqlite3, time
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
import os
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
from minio import Minio
from urllib.parse import urlparse
from urllib.parse import quote
from datetime import timezone
import io
DB="/data/argos.db"
mc=Minio(os.getenv("MINIO_ENDPOINT","s3.argos.interna"),
access_key=os.getenv("MINIO_ACCESS_KEY"),
secret_key=os.getenv("MINIO_SECRET_KEY"),
secure=os.getenv("MINIO_SECURE","false").lower()=="true")
app=FastAPI()
BUCKET = os.getenv("MINIO_BUCKET", "argos")
endpoint = os.getenv("MINIO_ENDPOINT", "minio.argos-core.svc.cluster.local:9000")
access_key = os.getenv("MINIO_ACCESS_KEY")
secret_key = os.getenv("MINIO_SECRET_KEY")
secure = os.getenv("MINIO_SECURE", "false").lower() == "true"
def rows(limit=100, camera=None, since=None):
q="SELECT id, ts, edge, camera, label, s3url, thumb_s3 FROM events"
cond=[]; args=[]
if camera: cond.append("camera=?"); args.append(camera)
if since: cond.append("ts>=?"); args.append(int(since))
if cond: q+=" WHERE "+ " AND ".join(cond)
q+=" ORDER BY ts DESC LIMIT ?"; args.append(limit)
con=sqlite3.connect(DB); cur=con.cursor()
cur.execute(q, tuple(args)); r=cur.fetchall(); con.close()
return r
mc = Minio(endpoint, access_key=access_key, secret_key=secret_key, secure=secure)
app = FastAPI()
@app.get("/health")
def health():
# simple check contra MinIO
try:
mc.list_buckets()
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
def _iter_events(prefix: str | None, limit: int):
# list_objects devuelve en orden “no garantizado”; recolectamos y ordenamos por last_modified desc
objs = []
for obj in mc.list_objects(BUCKET, prefix=prefix, recursive=True):
# omitimos directorios “lógicos” y thumbs por defecto en el listado principal
if obj.is_dir:
continue
if "/thumbs/" in obj.object_name:
continue
objs.append(obj)
objs.sort(key=lambda o: o.last_modified or 0, reverse=True)
return objs[:limit]
@app.get("/api/events")
def api_events(limit:int=100, camera:str=None, since:int=None):
return [dict(id=i, ts=t, edge=e, camera=c, label=l or "", s3url=s, thumb=th or "")
for (i,t,e,c,l,s,th) in rows(limit,camera,since)]
def api_events(prefix: str | None = Query(default=None), limit: int = 50):
data = []
for o in _iter_events(prefix, limit):
ts = None
if o.last_modified:
ts = o.last_modified.astimezone(timezone.utc).isoformat()
# si existe thumb, la inferimos (cam/... → cam/thumbs/...)
thumb = None
parts = o.object_name.split("/")
if len(parts) >= 2:
maybe = f"{parts[0]}/thumbs/" + parts[-1].rsplit(".",1)[0] + ".jpg"
try:
mc.stat_object(BUCKET, maybe)
thumb = maybe
except:
pass
data.append({
"key": o.object_name,
"size": o.size,
"last_modified": ts,
"thumb": thumb
})
return JSONResponse(data)
@app.get("/api/url/{event_id}")
def presign(event_id: str, expires: int = 600):
con=sqlite3.connect(DB); cur=con.cursor()
cur.execute("SELECT s3url FROM events WHERE id=?", (event_id,))
row=cur.fetchone(); con.close()
if not row: raise HTTPException(404, "Not found")
s3url=row[0]; p=urlparse(s3url); b=p.netloc; k=p.path.lstrip("/")
return {"url": mc.presigned_get_object(b, k, expires=expires)}
@app.get("/file")
def file(key: str):
try:
resp = mc.get_object(BUCKET, key)
except Exception as e:
raise HTTPException(status_code=404, detail=f"not found: {e}")
# stream al cliente sin exponer MinIO
def gen():
for d in resp.stream(32*1024):
yield d
# tipo de contenido básico por extensión
ctype = "application/octet-stream"
k = key.lower()
if k.endswith(".mp4"): ctype = "video/mp4"
elif k.endswith(".jpg") or k.endswith(".jpeg"): ctype = "image/jpeg"
return StreamingResponse(gen(), media_type=ctype)
@app.get("/view", response_class=HTMLResponse)
def view(key: str):
keyq = quote(key)
return f"""
<!doctype html>
<html><head><meta charset="utf-8"><title>Ver {key}</title>
<style>body{{font-family:sans-serif;padding:16px}} video{{max-width:100%;height:auto}}</style>
</head><body>
<h2>{key}</h2>
<video controls src="/file?key={keyq}"></video>
<p><a href="/file?key={keyq}" download>Descargar</a></p>
<p><a href="/">Volver</a></p>
</body></html>
"""
@app.get("/", response_class=HTMLResponse)
def index():
return """
<!doctype html><meta charset="utf-8"><title>ARGOS Panel</title>
<style>body{font-family:system-ui;margin:1.5rem} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
.card{border:1px solid #ddd;border-radius:10px;padding:10px} img{width:100%;height:160px;object-fit:cover;border-radius:8px}
button{padding:.4rem .6rem;margin-right:.3rem}</style>
<h1>ARGOS Alarmas</h1>
<div class="grid" id="grid"></div>
<div style="margin-top:1rem"><video id="player" width="960" controls></video></div>
<script>
const fmt=t=>new Date(t*1000).toLocaleString();
async function load(){
const r=await fetch('/api/events?limit=100'); const data=await r.json();
const g=document.getElementById('grid'); g.innerHTML='';
for(const ev of data){
const d=document.createElement('div'); d.className='card';
const img = ev.thumb ? `<img src="${ev.thumb.replace('s3://','/api/url/THUMB?key=')}" alt="thumb">` : '';
d.innerHTML = `${img}<div><b>${ev.camera}</b> — ${fmt(ev.ts)}<br>${ev.label||''}</div>
<div style="margin-top:.4rem">
<button data-id="${ev.id}" data-action="clip">Ver clip</button>
<button data-path="${ev.camera}" data-action="live">En directo</button>
</div>`;
g.appendChild(d);
}
g.onclick=async (e)=>{
if(e.target.tagName!=='BUTTON') return;
const v=document.getElementById('player');
if(e.target.dataset.action==='clip'){
const id=e.target.dataset.id;
const j=await (await fetch('/api/url/'+id)).json();
v.src=j.url; v.play();
}else if(e.target.dataset.action==='live'){
const path=e.target.dataset.path;
// usa MediaMTX web player
window.open('http://mediamtx.argos.interna/?path='+encodeURIComponent(path),'_blank');
}
}
}
load(); setInterval(load,10000);
</script>
def home(prefix: str | None = None, limit: int = 20):
# HTML mínimo que consume /api/events y pinta thumbs/links
p = f"&prefix={quote(prefix)}" if prefix else ""
items = []
for e in _iter_events(prefix, limit):
thumb_html = ""
# intentar thumb
parts = e.object_name.split("/")
if len(parts) >= 2:
maybe = f"{parts[0]}/thumbs/" + parts[-1].rsplit(".",1)[0] + ".jpg"
try:
mc.stat_object(BUCKET, maybe)
thumb_html = f'<img src="/file?key={quote(maybe)}" style="height:80px;border-radius:6px;margin-right:8px" />'
except:
pass
items.append(f"""
<li style="margin:8px 0;list-style:none;display:flex;align-items:center">
{thumb_html}
<a href="/view?key={quote(e.object_name)}">{e.object_name}</a>
</li>
""")
items_html = "\n".join(items) or "<p><i>Sin eventos aún.</i></p>"
return f"""
<!doctype html><html><head><meta charset="utf-8"><title>Argos Panel</title>
<style>body{{font-family:sans-serif;max-width:1000px;margin:24px auto;padding:0 16px}}</style>
</head><body>
<h1>Argos Panel</h1>
<form>
<label>Prefijo (cámara): <input type="text" name="prefix" value="{prefix or ""}"></label>
<label style="margin-left:12px">Límite: <input type="number" name="limit" value="{limit}" min="1" max="200"></label>
<button type="submit">Filtrar</button>
</form>
<ul style="padding:0">{items_html}</ul>
</body></html>
"""

View File

@@ -0,0 +1,87 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: argos-panel-config
namespace: argos-core
data:
app.py: |
import os, sqlite3, time
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
from minio import Minio
from urllib.parse import urlparse
DB="/data/argos.db"
mc=Minio(os.getenv("MINIO_ENDPOINT","s3.argos.interna"),
access_key=os.getenv("MINIO_ACCESS_KEY"),
secret_key=os.getenv("MINIO_SECRET_KEY"),
secure=os.getenv("MINIO_SECURE","false").lower()=="true")
app=FastAPI()
def rows(limit=100, camera=None, since=None):
q="SELECT id, ts, edge, camera, label, s3url, thumb_s3 FROM events"
cond=[]; args=[]
if camera: cond.append("camera=?"); args.append(camera)
if since: cond.append("ts>=?"); args.append(int(since))
if cond: q+=" WHERE "+ " AND ".join(cond)
q+=" ORDER BY ts DESC LIMIT ?"; args.append(limit)
con=sqlite3.connect(DB); cur=con.cursor()
cur.execute(q, tuple(args)); r=cur.fetchall(); con.close()
return r
@app.get("/api/events")
def api_events(limit:int=100, camera:str=None, since:int=None):
return [dict(id=i, ts=t, edge=e, camera=c, label=l or "", s3url=s, thumb=th or "")
for (i,t,e,c,l,s,th) in rows(limit,camera,since)]
@app.get("/api/url/{event_id}")
def presign(event_id: str, expires: int = 600):
con=sqlite3.connect(DB); cur=con.cursor()
cur.execute("SELECT s3url FROM events WHERE id=?", (event_id,))
row=cur.fetchone(); con.close()
if not row: raise HTTPException(404, "Not found")
s3url=row[0]; p=urlparse(s3url); b=p.netloc; k=p.path.lstrip("/")
return {"url": mc.presigned_get_object(b, k, expires=expires)}
@app.get("/", response_class=HTMLResponse)
def index():
return """
<!doctype html><meta charset="utf-8"><title>ARGOS Panel</title>
<style>body{font-family:system-ui;margin:1.5rem} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
.card{border:1px solid #ddd;border-radius:10px;padding:10px} img{width:100%;height:160px;object-fit:cover;border-radius:8px}
button{padding:.4rem .6rem;margin-right:.3rem}</style>
<h1>ARGOS Alarmas</h1>
<div class="grid" id="grid"></div>
<div style="margin-top:1rem"><video id="player" width="960" controls></video></div>
<script>
const fmt=t=>new Date(t*1000).toLocaleString();
async function load(){
const r=await fetch('/api/events?limit=100'); const data=await r.json();
const g=document.getElementById('grid'); g.innerHTML='';
for(const ev of data){
const d=document.createElement('div'); d.className='card';
const img = ev.thumb ? `<img src="${ev.thumb.replace('s3://','/api/url/THUMB?key=')}" alt="thumb">` : '';
d.innerHTML = `${img}<div><b>${ev.camera}</b> — ${fmt(ev.ts)}<br>${ev.label||''}</div>
<div style="margin-top:.4rem">
<button data-id="${ev.id}" data-action="clip">Ver clip</button>
<button data-path="${ev.camera}" data-action="live">En directo</button>
</div>`;
g.appendChild(d);
}
g.onclick=async (e)=>{
if(e.target.tagName!=='BUTTON') return;
const v=document.getElementById('player');
if(e.target.dataset.action==='clip'){
const id=e.target.dataset.id;
const j=await (await fetch('/api/url/'+id)).json();
v.src=j.url; v.play();
}else if(e.target.dataset.action==='live'){
const path=e.target.dataset.path;
// usa MediaMTX web player
window.open('http://mediamtx.argos.interna/?path='+encodeURIComponent(path),'_blank');
}
}
}
load(); setInterval(load,10000);
</script>
"""

View File

@@ -5,29 +5,33 @@ metadata:
namespace: argos-core
spec:
replicas: 1
selector: { matchLabels: { app: mediamtx } }
selector:
matchLabels: { app: mediamtx }
template:
metadata: { labels: { app: mediamtx } }
metadata:
labels: { app: mediamtx }
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
containers:
- name: mediamtx
image: bluenviron/mediamtx:1.14.0
command: ["/bin/sh","-c"]
args:
- |
set -e
ulimit -n 1048576
exec mediamtx /config/mediamtx.yml
# Nada de /bin/sh: solo pasa la ruta del YAML como argumento posicional
args: ["/config/mediamtx.yml"]
volumeMounts:
- name: cfg
mountPath: /config
ports:
- containerPort: 8554 # RTSP
- containerPort: 8189 # SRT
- containerPort: 8889 # WHIP
- containerPort: 8880 # HTTP/API
- name: rtsp
containerPort: 8554
protocol: TCP
- name: http
containerPort: 8880
protocol: TCP
- name: whip
containerPort: 8889
protocol: TCP
- name: srt
containerPort: 8189
protocol: UDP
volumes:
- name: cfg
configMap:

View File

@@ -4,23 +4,69 @@ metadata:
name: minio
namespace: argos-core
spec:
strategy:
type: Recreate
replicas: 1
selector: { matchLabels: { app: minio } }
selector:
matchLabels:
app: minio
template:
metadata: { labels: { app: minio } }
metadata:
labels:
app: minio
app.kubernetes.io/part-of: argos
app.kubernetes.io/managed-by: kustomize
spec:
# ayuda a que el FS sea accesible por el grupo
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
# arregla permisos heredados de root en el PVC
initContainers:
- name: fix-perms
image: alpine:3.20
command: ["/bin/sh","-c"]
args:
- |
set -ex
apk add --no-cache acl
chown -R 1000:1000 /data || true
chmod -R u+rwX,g+rwX /data || true
find /data -type d -exec chmod g+s {} \; || true
setfacl -R -m g:1000:rwx /data || true
setfacl -R -d -m g:1000:rwx /data || true
securityContext:
runAsUser: 0
volumeMounts:
- name: data
mountPath: /data
containers:
- name: minio
image: quay.io/minio/minio:latest
securityContext:
runAsUser: 1000
runAsGroup: 1000
args: ["server", "/data", "--console-address", ":9001"]
envFrom:
- secretRef: { name: minio-creds }
ports:
- containerPort: 9000
- containerPort: 9001
volumeMounts:
- name: data
mountPath: /data
- { containerPort: 9000, name: api }
- { containerPort: 9001, name: console }
readinessProbe:
httpGet: { path: /minio/health/ready, port: 9000 }
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: { path: /minio/health/live, port: 9000 }
initialDelaySeconds: 10
periodSeconds: 20
resources:
requests:
cpu: 50m
memory: 256Mi
limits:
cpu: "1"
memory: 2Gi
volumes:
- name: data
persistentVolumeClaim:

View File

@@ -5,22 +5,27 @@ metadata:
namespace: argos-core
spec:
replicas: 1
selector: { matchLabels: { app: mosquitto } }
selector:
matchLabels: { app: mosquitto }
template:
metadata: { labels: { app: mosquitto } }
metadata:
labels: { app: mosquitto }
spec:
containers:
- name: mosquitto
image: harbor.c2et.net/library/eclipse-mosquitto:latest
image: eclipse-mosquitto:2
ports:
- containerPort: 1883
volumeMounts:
- name: cfg
mountPath: /mosquitto/config
- name: data
mountPath: /mosquitto/data
volumes:
- name: cfg
configMap:
name: mosquitto-conf
name: mosquitto-config
items:
- key: mosquitto.conf
path: mosquitto.conf
- { key: mosquitto.conf, path: mosquitto.conf }
- name: data
emptyDir: {} # o tu PVC si lo tienes

View File

@@ -5,30 +5,30 @@ metadata:
namespace: argos-core
spec:
replicas: 1
selector: { matchLabels: { app: argos-panel } }
selector:
matchLabels: { app: argos-panel }
template:
metadata: { labels: { app: argos-panel } }
metadata:
labels: { app: argos-panel }
spec:
containers:
- name: panel
image: harbor.c2et.net/library/python:3.13.7-slim-bookworm
image: python:3.13.7-slim-bookworm
command: ["/bin/sh","-c"]
args:
- |
set -e
pip install fastapi uvicorn minio
uvicorn app:app --host 0.0.0.0 --port 8000
exec uvicorn app:app --host 0.0.0.0 --port 8000 --app-dir /app
envFrom:
- secretRef: { name: argos-panel-secret }
volumeMounts:
- { name: app, mountPath: /app }
- { name: data, mountPath: /data }
- { name: app, mountPath: /app }
ports:
- containerPort: 8000
- { containerPort: 8000, name: http }
volumes:
- name: app
configMap:
name: argos-panel-config
items: [ { key: app.py, path: app.py } ]
- name: data
emptyDir: {}
items:
- { key: app.py, path: app.py }

View File

@@ -3,16 +3,21 @@ kind: Ingress
metadata:
name: argos-panel-internal
namespace: argos-core
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx-internal
ingressClassName: nginx
rules:
- host: panel.argos.c2et.net # mismo FQDN
- host: argos.panel.c2et.net
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: argos-panel, port: { number: 80 } }
service:
name: argos-panel
port:
name: http # <-- o usa "number: 8000"
tls:
- hosts: ["panel.argos.c2et.net"]
- hosts: ["argos.panel.c2et.net"]
secretName: panel-argos-tls

View File

@@ -3,14 +3,9 @@ kind: Kustomization
namespace: argos-core
commonLabels:
app.kubernetes.io/part-of: argos
app.kubernetes.io/managed-by: kustomize
resources:
# Namespace y políticas
# Namespace
- namespace.yaml
- policies/network-policy.yaml
# ConfigMaps
- configmaps/configmap-mediamtx.yaml
@@ -18,31 +13,33 @@ resources:
- configmaps/configmap-orchestrator.yaml
- configmaps/configmap-panel.yaml
# Secrets
- secrets/secret-minio.yaml
- secrets/secret-orchestrator.yaml
- secrets/secret-panel.yaml
# Storage
- pvc/pvc-minio.yaml
# Deployments
- deployments/deploy-mediamtx.yaml
- deployments/deploy-minio.yaml
- deployments/deploy-mosquitto.yaml
- deployments/deploy-orchestrator.yaml
- deployments/deploy-panel.yaml
- deployments/deploy-minio.yaml
# Services
- services/argos-panel.yaml
- services/svc-mediamtx-tcp.yaml
- services/svc-mediamtx-udp.yaml
- services/svc-minio.yaml
- services/svc-mosquitto.yaml
- services/svc-panel.yaml
# Ingress
- ingress/ingress-minio.yaml
- ingress/ingress-panel.yaml
# Certificados (si usas ACME en el externo y compartes el secret)
- certs/certificate-argos-panel.yaml
# Almacenamiento
- pvc/pvc-minio.yaml
# Políticas de red
- policies/network-policy.yaml
- policies/allow-same-namespace.yaml
# Secrets (si ya existen en el cluster, puedes omitirlos aquí)
- secrets/secret-minio.yaml
- secrets/secret-orchestrator.yaml
- secrets/secret-panel.yaml
# - secrets/secret-harbor-cred.yaml

View File

@@ -0,0 +1,11 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-same-namespace
namespace: argos-core
spec:
podSelector: {} # aplica a todos los pods de argos-core (destino)
policyTypes: [Ingress]
ingress:
- from:
- podSelector: {} # origen: cualquier pod del mismo namespace

199
argos/readme.md Normal file
View File

@@ -0,0 +1,199 @@
# Argos Core
**Argos Core** es el backend central de una plataforma de videovigilancia distribuida. Está diseñado para recibir eventos desde **edges** (sitios remotos con Frigate), capturar/ingestar vídeo bajo demanda, almacenarlo de forma fiable y ofrecer un **panel web** seguro para consulta y reproducción, todo **encapsulado en Kubernetes** y oculto tras una **VPN WireGuard**.
---
## Visión general
```text
[Edge / Frigate] --(VPN/WG + MQTT eventos)--> [Argos Core]
├─ Mosquitto (MQTT)
├─ Orchestrator (ingesta clips)
│ ├─ Pull clip nativo de Frigate
│ └─ Fallback: captura RTSP temporal
├─ MediaMTX (live/retransmisión)
├─ MinIO (S3, almacenamiento)
└─ Panel (FastAPI + Uvicorn)
```
**Objetivo clave:** los edges **no** envían flujo constante; sólo cuando hay evento. El core ingesta y guarda el clip, y ofrece visualización **ondemand**.
---
## Componentes
* **WireGuard (wg-easy)**: servidor VPN del clúster. Todo el tráfico de edges → core circula por WG.
* **Mosquitto** (`eclipse-mosquitto:2`): broker MQTT interno (ClusterIP). Topic de eventos (p. ej. `frigate/events`).
* **Orchestrator** (Python):
* Se suscribe a MQTT.
* Tras un evento, intenta **descargar el clip nativo** desde la API del Frigate del edge.
* Si no existe, hace **captura RTSP** temporal con `ffmpeg` (duración configurada) contra la cámara/edge.
* Sube el resultado a **MinIO** (`s3://argos/...`) y registra metadatos en SQLite embebido.
* **MinIO** (S3 compatible): almacenamiento de clips y miniaturas.
* **MediaMTX**: redistribución de flujos (RTSP/WHIP/SRT) y posible live view on-demand.
* **Panel** (FastAPI/Uvicorn):
* Lista eventos desde MinIO (sin exponer MinIO: **proxy** de objetos).
* Página de reproducción (`/view?key=…`).
* Endpoints simples (`/file`, `/api/events`, `/health`).
* **CoreDNS**: DNS interno con split-horizon (resolución diferente desde LAN/VPN/cluster cuando aplica).
* **Ingress NGINX (interno/externo)** + **certmanager**: TLS para el panel; HTTP01 servido por el controlador externo.
* **MetalLB**: IPs para servicios tipo LoadBalancer en la red `192.168.200.0/24` (p. ej. MediaMTX).
---
## Flujo de datos (evento → clip)
1. **Edge** detecta (Frigate) y publica en **MQTT** `frigate/events`.
2. **Orchestrator** recibe el evento, localiza la cámara/edge en su `settings.yaml`.
3. Intenta **pull** del **clip nativo** vía API Frigate del edge (VPN).
4. Si no hay clip, ejecuta **`ffmpeg`** para capturar **RTSP** temporal (fallback).
5. Sube el fichero a **MinIO** con una clave tipo: `camX/YYYY/MM/DD/TS_eventId.mp4` y optional thumb.
6. **Panel** lo muestra en la lista y permite reproducir vía `/file?key=…` (stream proxy desde MinIO).
> Para **live**, MediaMTX publica `/` con `path=<cam>`; puedes incrustar un reproductor WebRTC/RTSP en el panel.
---
## Despliegue
### Requisitos previos
* Kubernetes operativo con:
* **MetalLB** (rango `192.168.200.0/24`).
* **NGINX Ingress** *interno* y *externo* (split-horizon DNS).
* **certmanager** + `ClusterIssuer` (p. ej. `letsencrypt-prod`).
* **wg-easy** funcionando (con NAT/iptables para alcanzar `200.x`, `0.x`, etc.).
### Estructura
```
argos/
├─ configmaps/ # mediamtx, mosquitto, orchestrator, panel
├─ deployments/ # mediamtx, minio, mosquitto, orchestrator, panel
├─ ingress/ # minio (opcional), panel (TLS)
├─ policies/ # network-policy, allow-same-namespace
├─ pvc/ # pvc-minio
├─ secrets/ # minio, orchestrator, panel
└─ services/ # mediamtx tcp/udp, minio, mosquitto, panel
```
### Variables/Secrets esperados
* **MinIO** (`secrets/secret-minio.yaml`): `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`.
* **Orchestrator** (`secrets/secret-orchestrator.yaml`): `MINIO_ENDPOINT`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`, `MINIO_SECURE`.
* **Panel** (`secrets/secret-panel.yaml`): `MINIO_*` y `MINIO_BUCKET`.
### Servicios y puertos
* **MinIO**: `ClusterIP` :9000 (API), :9001 (console, opcional). No expuesto fuera.
* **Mosquitto**: `ClusterIP` :1883.
* **MediaMTX**: `LoadBalancer` (MetalLB) en `192.168.200.16` (RTSP 8554 TCP, WHIP 8889 TCP/HTTP 8880, SRT 8189 UDP).
* **Panel**: `ClusterIP` :8000 → Ingress (TLS) `https://argos.panel.c2et.net/` (ajusta FQDN).
### Kustomize
```bash
kubectl apply -k .
```
> Nota: evitamos `commonLabels` globales para no romper los **selectors**.
---
## Configuración relevante
### Orchestrator (`configmaps/configmap-orchestrator.yaml`)
* `mqtt.host`: `mosquitto.argos-core.svc.cluster.local`
* `mqtt.port`: `1883`
* `mqtt.topic`: `frigate/events`
* `minio.endpoint`: `minio.argos-core.svc.cluster.local:9000`
* `edges`: lista de edges y cámaras (URLs de Frigate, RTSP principal, etc.).
### Mosquitto (`configmaps/configmap-mosquitto.yaml`)
* Config mínimo:
```
persistence true
persistence_location /mosquitto/data/
listener 1883 0.0.0.0
allow_anonymous true # RECOMENDACIÓN: pasar a autenticación/TLS más adelante
```
### MediaMTX
* Ejecutar **sin** `/bin/sh -c …` y pasando **solo** la ruta del YAML como `args`.
* Corrige warnings como `sourceOnDemand` según el modo (`publisher` o `record`).
### MinIO (seguridad/permisos)
* Ejecuta como **no-root** con `runAsUser: 1000` y un **initContainer** que hace `chown/chmod/ACL` del PVC.
* Estrategia de despliegue **Recreate** para evitar doble pod en updates cuando la `readinessProbe` tarda.
### Ingress + cert-manager
* El **Certificate** del panel debe forzar el solver HTTP01 por el **ingress externo**:
* Anotación: `acme.cert-manager.io/http01-ingress-class: nginx-external` (o ajusta en `ClusterIssuer`).
---
## Operación
### Comandos útiles
```bash
# Estado general
kubectl -n argos-core get pods,svc,ingress,endpoints
# Endpoints vacíos (<none>)
kubectl -n argos-core get endpoints <svc> -o wide
# Revisa selector del Service y labels del Pod
# Logs
kubectl -n argos-core logs -f deploy/<nombre>
# Reinicios controlados
kubectl -n argos-core rollout restart deploy/<nombre>
# Diff del Service en vivo vs manifiesto
kubectl -n argos-core get svc argos-panel -o yaml --show-managed-fields=false
```
### Problemas típicos y fixes
* **Service con `ENDPOINTS <none>`**: selector ≠ labels, o pod **not Ready** (probe fallando). Ajusta selector a `{app: …}` y corrige readiness.
* **Dos pods tras update**: rolling update atascado. Usa `strategy: Recreate` mientras estabilizas.
* **MinIO `file access denied`**: permisos del PVC. Añade `initContainer` que hace `chown -R 1000:1000 /data` + ACL.
* **Mosquitto `Connection refused`** desde Orchestrator: Service sin endpoints o broker no escucha en 1883. Ver logs `mosquitto` y `ss -lnt`.
* **Panel `ASGI app not found`**: `uvicorn app:app --app-dir /app` y asegúrate de montar `app.py` con `app = FastAPI()`.
* **MediaMTX `unknown flag -c`**: no usar `/bin/sh -c …`; pasar `args: ["/config/mediamtx.yml"]`.
---
## Edge (resumen de mañana)
* **WireGuard** cliente (IP del edge en la VPN; DNS apuntando al core).
* **Frigate** en Docker + `mqtt.host=mosquitto.argos-core.svc.cluster.local`.
* API de Frigate accesible desde el core por la VPN (p. ej. `http://10.20.0.10:5000`).
* Opcional: MediaMTX local si se quiere “empujar” live al core durante la alarma.
---
## Seguridad y siguientes pasos
* Endurecer Mosquitto (usuarios/TLS), MinIO (políticas/buckets versionados), y el Panel (auth detrás de Ingress o login propio).
* Métricas y dashboards (Prometheus/Grafana), y mover metadatos a **PostgreSQL** para búsquedas ricas.
* Live WebRTC integrado en el panel (MediaMTX WHIP/WHEP).
---
## Licencia / Créditos
* **MediaMTX** (Bluenviron), **MinIO**, **Eclipse Mosquitto**, **Frigate** (en los edges).
* Argos Core: composición y orquestación Kubernetes + servicios auxiliares.

View File

@@ -4,8 +4,7 @@ metadata:
name: mosquitto
namespace: argos-core
spec:
type: LoadBalancer
loadBalancerIP: 192.168.200.15
type: ClusterIP
selector: { app: mosquitto }
ports:
- name: mqtt

View File

@@ -7,4 +7,6 @@ spec:
type: ClusterIP
selector: { app: argos-panel }
ports:
- { name: http, port: 80, targetPort: 8000 }
- name: http
port: 8000
targetPort: 8000

View File

@@ -1 +0,0 @@
sdgfsdgsdfg