añadido Argos Core
This commit is contained in:
@@ -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
|
||||
@@ -11,5 +11,4 @@ data:
|
||||
hls: no
|
||||
webrtc: yes
|
||||
paths:
|
||||
all:
|
||||
sourceOnDemand: yes
|
||||
all: {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
"""
|
||||
|
||||
87
argos/configmaps/configmap-panel.yaml.old
Normal file
87
argos/configmaps/configmap-panel.yaml.old
Normal 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>
|
||||
"""
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
11
argos/policies/allow-same-namespace.yaml
Normal file
11
argos/policies/allow-same-namespace.yaml
Normal 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
199
argos/readme.md
Normal 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 **on‑demand**.
|
||||
|
||||
---
|
||||
|
||||
## 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)** + **cert‑manager**: TLS para el panel; HTTP‑01 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).
|
||||
* **cert‑manager** + `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 HTTP‑01 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.
|
||||
@@ -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
|
||||
|
||||
@@ -7,4 +7,6 @@ spec:
|
||||
type: ClusterIP
|
||||
selector: { app: argos-panel }
|
||||
ports:
|
||||
- { name: http, port: 80, targetPort: 8000 }
|
||||
- name: http
|
||||
port: 8000
|
||||
targetPort: 8000
|
||||
@@ -1 +0,0 @@
|
||||
sdgfsdgsdfg
|
||||
Reference in New Issue
Block a user