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
|
hls: no
|
||||||
webrtc: yes
|
webrtc: yes
|
||||||
paths:
|
paths:
|
||||||
all:
|
all: {}
|
||||||
sourceOnDemand: yes
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
|
||||||
"""
|
"""
|
||||||
|
|||||||
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
|
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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1 +0,0 @@
|
|||||||
sdgfsdgsdfg
|
|
||||||
Reference in New Issue
Block a user