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 hls: no
webrtc: yes webrtc: yes
paths: paths:
all: all: {}
sourceOnDemand: yes

View File

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

View File

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

View File

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

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

View File

@@ -4,23 +4,69 @@ metadata:
name: minio name: minio
namespace: argos-core namespace: argos-core
spec: spec:
strategy:
type: Recreate
replicas: 1 replicas: 1
selector: { matchLabels: { app: minio } } selector:
matchLabels:
app: minio
template: template:
metadata: { labels: { app: minio } } metadata:
labels:
app: minio
app.kubernetes.io/part-of: argos
app.kubernetes.io/managed-by: kustomize
spec: 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: containers:
- name: minio - name: minio
image: quay.io/minio/minio:latest image: quay.io/minio/minio:latest
securityContext:
runAsUser: 1000
runAsGroup: 1000
args: ["server", "/data", "--console-address", ":9001"] args: ["server", "/data", "--console-address", ":9001"]
envFrom: envFrom:
- secretRef: { name: minio-creds } - secretRef: { name: minio-creds }
ports: ports:
- containerPort: 9000 - { containerPort: 9000, name: api }
- containerPort: 9001 - { containerPort: 9001, name: console }
volumeMounts: readinessProbe:
- name: data httpGet: { path: /minio/health/ready, port: 9000 }
mountPath: /data 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: volumes:
- name: data - name: data
persistentVolumeClaim: persistentVolumeClaim:

View File

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

View File

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

View File

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

View File

@@ -3,14 +3,9 @@ kind: Kustomization
namespace: argos-core namespace: argos-core
commonLabels:
app.kubernetes.io/part-of: argos
app.kubernetes.io/managed-by: kustomize
resources: resources:
# Namespace y políticas # Namespace
- namespace.yaml - namespace.yaml
- policies/network-policy.yaml
# ConfigMaps # ConfigMaps
- configmaps/configmap-mediamtx.yaml - configmaps/configmap-mediamtx.yaml
@@ -18,31 +13,33 @@ resources:
- configmaps/configmap-orchestrator.yaml - configmaps/configmap-orchestrator.yaml
- configmaps/configmap-panel.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
- deployments/deploy-mediamtx.yaml - deployments/deploy-mediamtx.yaml
- deployments/deploy-minio.yaml
- deployments/deploy-mosquitto.yaml - deployments/deploy-mosquitto.yaml
- deployments/deploy-orchestrator.yaml - deployments/deploy-orchestrator.yaml
- deployments/deploy-panel.yaml - deployments/deploy-panel.yaml
- deployments/deploy-minio.yaml
# Services # Services
- services/argos-panel.yaml
- services/svc-mediamtx-tcp.yaml - services/svc-mediamtx-tcp.yaml
- services/svc-mediamtx-udp.yaml - services/svc-mediamtx-udp.yaml
- services/svc-minio.yaml - services/svc-minio.yaml
- services/svc-mosquitto.yaml - services/svc-mosquitto.yaml
- services/svc-panel.yaml
# Ingress # Ingress
- ingress/ingress-minio.yaml - ingress/ingress-minio.yaml
- ingress/ingress-panel.yaml - ingress/ingress-panel.yaml
# Certificados (si usas ACME en el externo y compartes el secret) # Almacenamiento
- certs/certificate-argos-panel.yaml - 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 name: mosquitto
namespace: argos-core namespace: argos-core
spec: spec:
type: LoadBalancer type: ClusterIP
loadBalancerIP: 192.168.200.15
selector: { app: mosquitto } selector: { app: mosquitto }
ports: ports:
- name: mqtt - name: mqtt

View File

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

View File

@@ -1 +0,0 @@
sdgfsdgsdfg