diff --git a/.gitignore b/.gitignore index b267f5f..afae21a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ __pycache__/ # venv venv +*.venv # .env variables file .env @@ -35,3 +36,5 @@ apps/static/assets/.temp *.pem kube-config.yaml + +*.sh text eol=lf \ No newline at end of file diff --git a/apps/__init__.py b/apps/__init__.py index be7ee67..01cb25b 100644 --- a/apps/__init__.py +++ b/apps/__init__.py @@ -39,12 +39,26 @@ def register_extensions(app): def register_blueprints(app): + # Core blueprints (always loaded) for module_name in ('authentication', 'home', 'api', 'k8s', 'cli'): - module = import_module('apps.{}.routes'.format(module_name)) + module = import_module(f'apps.{module_name}.routes') app.register_blueprint(module.blueprint) - # Load socketio endpoints - import_module("apps.events") + # Optional blueprints (controlled by config.OPTIONAL_MODULES) + optional_modules = app.config.get("OPTIONAL_MODULES", []) or [] + for opt in optional_modules: + try: + mod = import_module(f"apps.{opt}.routes") + app.register_blueprint(mod.blueprint) + app.logger.info(f"Optional module loaded: {opt}") + except Exception as e: + app.logger.error(f"Failed to load optional module '{opt}': {e}") + + # Socket.IO endpoints (xterm) - always load; Windows must run via Docker (see docs) + try: + import_module("apps.events") + except Exception as e: + app.logger.error(f"Failed to load apps.events: {e}") def configure_database(app): with app.app_context(): @@ -79,9 +93,10 @@ def configure_log(app): def create_app(config): app = Flask(__name__) app.config.from_object(config) + register_extensions(app) register_blueprints(app) - #configure_database(app) + # configure_database(app) configure_oauth(app) configure_log(app) diff --git a/apps/apps/data/clabs_state.json b/apps/apps/data/clabs_state.json new file mode 100644 index 0000000..0a885db --- /dev/null +++ b/apps/apps/data/clabs_state.json @@ -0,0 +1,73 @@ +{ + "clabs": [ + { + "id": "clab-sample-1", + "name": "Sample Topology", + "owner": "demo@example.com", + "status": "Running", + "created_at": "2025-09-20 12:00:00", + "nodes": 2 + }, + { + "lab_instance_id": "clab-20250921023818", + "user": "admin", + "title": "mano pivas", + "name": "mano pivas", + "created": "2025-09-21 02:38:18", + "status": "Running", + "nodes": 0 + } + ], + "details": { + "clab-sample-1": { + "lab_instance_id": "clab-sample-1", + "title": "Sample Topology", + "user": "demo@example.com", + "created": "2025-09-20 12:00:00", + "expires_at": null, + "resources": [ + { + "kind": "node", + "name": "r1", + "ready": "Running", + "node_name": "linux", + "pod_ip": "172.20.20.2", + "age": "--", + "services": [], + "links": [] + }, + { + "kind": "node", + "name": "r2", + "ready": "Running", + "node_name": "linux", + "pod_ip": "172.20.20.3", + "age": "--", + "services": [], + "links": [] + } + ], + "files": [] + }, + "clab-20250921023818": { + "lab_instance_id": "clab-20250921023818", + "title": "mano pivas", + "user": "admin", + "created": "2025-09-21 02:38:18", + "expires_at": null, + "resources": [], + "files": [ + { + "name": "teste/Novo(a) Documento de Texto.txt", + "mimetype": "text/plain", + "size": 0 + }, + { + "name": "teste/teste 1/Novo(a) Documento de Texto2.txt", + "mimetype": "text/plain", + "size": 0 + } + ] + } + } +} \ No newline at end of file diff --git a/apps/clabs/__init__.py b/apps/clabs/__init__.py new file mode 100644 index 0000000..4e8c306 --- /dev/null +++ b/apps/clabs/__init__.py @@ -0,0 +1,11 @@ +# -*- encoding: utf-8 -*- +from flask import Blueprint +from .models import Clab, ClabInstance + +blueprint = Blueprint( + "clabs_blueprint", + __name__, + url_prefix="/containerlabs", + template_folder="templates", + static_folder="static", +) diff --git a/apps/clabs/models.py b/apps/clabs/models.py new file mode 100644 index 0000000..0a57657 --- /dev/null +++ b/apps/clabs/models.py @@ -0,0 +1,97 @@ +from datetime import datetime +from apps import db + +class Clab(db.Model): + __tablename__ = "clabs" + __table_args__ = ( + db.Index( + "ix_clabs_namespace_title_unique", + "namespace_default", + "title", + unique=True, + ), + ) + + id = db.Column(db.String(36), primary_key=True) + title = db.Column(db.String(255), nullable=False) + + description = db.Column(db.String(255)) + extended_desc = db.Column(db.LargeBinary) + clab_guide_md = db.Column(db.Text) + clab_guide_html = db.Column(db.Text) + yaml_manifest = db.Column(db.Text) + namespace_default = db.Column(db.String(128)) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column( + db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) + + groups = db.relationship( + "Groups", + secondary="clab_groups", + lazy="selectin", + backref=db.backref("clabs", lazy="selectin"), + ) + + categories = db.relationship( + "LabCategories", + secondary="clab_categories_association", + lazy="selectin", + backref=db.backref("clabs", lazy="selectin"), + ) + + instances = db.relationship( + "ClabInstance", + back_populates="clab", + cascade="all, delete-orphan", + lazy="selectin", + ) + + def __repr__(self): + return f"" + +class ClabInstance(db.Model): + __tablename__ = "clab_instances" + + id = db.Column(db.String(24), primary_key=True) + token = db.Column(db.String(64), unique=True, index=True, nullable=False) + + owner_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), index=True, nullable=False) + clab_id = db.Column(db.String(36), db.ForeignKey("clabs.id"), index=True, nullable=False) + + is_deleted = db.Column(db.Boolean, default=False, nullable=False) + expiration_ts = db.Column(db.DateTime, index=True) + finish_reason = db.Column(db.String(64)) + + _clab_resources = db.Column(db.Text) + namespace_effective = db.Column(db.String(128)) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + clab = db.relationship( + "Clab", + back_populates="instances", + lazy="selectin", + ) + + owner = db.relationship( + "Users", + lazy="selectin", + ) + + def __repr__(self): + return f"" + +clab_groups = db.Table( + "clab_groups", + db.Column("clab_id", db.String(36), db.ForeignKey("clabs.id"), primary_key=True), + db.Column("group_id", db.Integer, db.ForeignKey("groups.id"), primary_key=True), +) + +clab_categories_association = db.Table( + "clab_categories_association", + db.Column("clab_id", db.String(36), db.ForeignKey("clabs.id"), primary_key=True), + db.Column("category_id", db.Integer, db.ForeignKey("lab_categories.id"), primary_key=True), +) diff --git a/apps/clabs/routes.py b/apps/clabs/routes.py new file mode 100644 index 0000000..f11838c --- /dev/null +++ b/apps/clabs/routes.py @@ -0,0 +1,371 @@ +# -*- encoding: utf-8 -*- +from flask import render_template, request, jsonify, current_app +from flask_login import current_user, login_required +from apps.controllers.clabernetes import C9sController +import yaml +from apps import db +from apps.clabs.models import Clab, ClabInstance +from apps.authentication.models import Users +import json +import datetime as dt + + +from . import blueprint + +# -------- Controller singleton (lazy) -------- +_CLABS_CTRL = None +def ctrl() -> C9sController: + """Return a lazily-initialized controller instance.""" + global _CLABS_CTRL + if _CLABS_CTRL is None: + cfg = current_app.config + _CLABS_CTRL = C9sController( + expire_days=cfg.get("CLABS_EXPIRE_DAYS", 0), + upload_max_files=cfg.get("CLABS_UPLOAD_MAX_FILES", 200), + ) + return _CLABS_CTRL + +# -------- Access helpers -------- +def _is_admin_or_teacher() -> bool: + cat = getattr(current_user, "category", "") or "" + return cat in ("admin", "teacher") + +def _owner_identity() -> str: + return getattr(current_user, "username", None) or getattr(current_user, "email", None) or "anonymous" + +# -------- Serialization helpers (DB → UI/API) -------- +def _row_from_db(ci: ClabInstance) -> dict: + """Linha para /running e /api/list.""" + owner = getattr(ci, "owner", None) + clab = getattr(ci, "clab", None) + created = (ci.created_at or dt.datetime.utcnow()).strftime("%Y-%m-%d %H:%M:%S") + return { + "lab_instance_id": ci.id, + "user": getattr(owner, "username", None) or getattr(owner, "email", None) or "anonymous", + "title": getattr(clab, "title", None) or "Unnamed", + "created": created, + } + +def _detail_from_db(ci: ClabInstance) -> dict: + """Detalhe para /open/.""" + owner = getattr(ci, "owner", None) + clab = getattr(ci, "clab", None) + + created = (ci.created_at or dt.datetime.utcnow()).strftime("%Y-%m-%d %H:%M:%S") + try: + resources = json.loads(ci._clab_resources or "[]") + if not isinstance(resources, list): + resources = [] + except Exception: + resources = [] + + st = _status_from_ci(ci) + return { + "lab_instance_id": ci.id, + "user": getattr(owner, "username", None) or getattr(owner, "email", None) or "anonymous", + "title": getattr(clab, "title", None) or "Unnamed", + "created": created, + "namespace": ci.namespace_effective or getattr(clab, "namespace_default", None) or "--", + "resources": resources, + "name": getattr(clab, "title", None) or "Unnamed", + "status": st["status"], + "expires_at": st["expires_at"], + "remaining_seconds": st["remaining_seconds"] + } + +# -------- Time & Status helpers -------- +def _now_utc(): + return dt.datetime.utcnow() + +def _status_from_ci(ci: ClabInstance) -> dict: + """ + Calcula status atual da instância com base em is_deleted e expiration_ts. + Retorna dict com: status, expires_at (str ISO), remaining_seconds (int|None). + """ + if ci.is_deleted: + return {"status": "Deleted", "expires_at": None, "remaining_seconds": None} + + now = _now_utc() + exp = ci.expiration_ts + if exp is None: + # Sem expiração definida - considerar "Running" sem deadline + return {"status": "Running", "expires_at": None, "remaining_seconds": None} + + # Com data de expiração definida + if now >= exp: + return { + "status": "Expired", + "expires_at": exp.strftime("%Y-%m-%dT%H:%M:%SZ"), + "remaining_seconds": 0, + } + + remaining = int((exp - now).total_seconds()) + return { + "status": "Running", + "expires_at": exp.strftime("%Y-%m-%dT%H:%M:%SZ"), + "remaining_seconds": remaining, + } + +# ----------------- Views ----------------- +@blueprint.route("/running") +@login_required +def running(): + """List running labs. Admin/teacher sees all; users see only their labs.""" + q = ClabInstance.query.filter_by(is_deleted=False) + if not _is_admin_or_teacher(): + q = q.filter_by(owner_user_id=current_user.id) + cis = q.order_by(ClabInstance.created_at.desc()).all() + items = [_row_from_db(ci) for ci in cis] + return render_template("clabs/running.html", labs=items) + +@blueprint.route("/open/") +@login_required +def open_clab(clab_id): + ci = ClabInstance.query.get(clab_id) + if not ci or ci.is_deleted: + return render_template("pages/error.html", title="Open CLab", msg="CLab not found") + if (not _is_admin_or_teacher()) and (ci.owner_user_id != current_user.id): + return render_template("pages/error.html", title="Open CLab", msg="Forbidden"), 403 + + lab = _detail_from_db(ci) + return render_template("clabs/open.html", lab=lab) + +@blueprint.route("/create", methods=["GET", "POST"]) +@login_required +def create(): + if request.method == "GET": + return render_template("clabs/create.html") + + name = (request.form.get("clab_name") or "").strip() + namespace = (request.form.get("clab_namespace") or "").strip() + yaml_manifest = (request.form.get("clab_yaml") or "").strip() + + files = request.files.getlist("clab_files") or [] + files_meta = [{ + "name": f.filename, + "mimetype": getattr(f, "mimetype", "") or "", + "size": getattr(f, "content_length", None), + } for f in files] + files_count = len(files_meta) + + if not yaml_manifest and files_count == 0: + return jsonify({"ok": False, "result": "Provide YAML and/or files"}), 400 + + max_files = current_app.config.get("CLABS_UPLOAD_MAX_FILES", 200) + if files_count > max_files: + return jsonify({ + "ok": False, + "result": f"Too many files: {files_count} (max {max_files})" + }), 400 + + owner = _owner_identity() + + # 1) usa o controller PoC para validar YAML e montar o "detail" compatível + try: + detail = ctrl().create( + name=name, + namespace=namespace, + yaml_manifest=yaml_manifest, + files_meta=files_meta, + owner=owner, + ) + except yaml.YAMLError as e: + return jsonify({"ok": False, "result": f"Invalid YAML: {e}"}), 400 + except Exception: + current_app.logger.exception("Failed to create CLab") + return jsonify({"ok": False, "result": "Internal error"}), 500 + + # 2) persistência no DB (MVP) + clab_title = (detail.get("title") or name or "Unnamed").strip() or "Unnamed" + clab_ns = namespace or None + + # encontra ou cria o template Clab pelo par (namespace_default, title) + existing = Clab.query.filter_by(namespace_default=clab_ns, title=clab_title).first() + if existing: + clab = existing + else: + import uuid + clab = Clab( + id=str(uuid.uuid4()), + title=clab_title, + description=None, + extended_desc=None, + clab_guide_md=None, + clab_guide_html=None, + yaml_manifest=yaml_manifest or "", + namespace_default=clab_ns, + ) + db.session.add(clab) + db.session.flush() + + # cria a instância com o mesmo ID que o PoC gerou + inst_id = detail.get("lab_instance_id") + if not inst_id: + inst_id = f"clab-{int(dt.datetime.utcnow().timestamp())}" + + token = f"tok_{inst_id}" + resources = json.dumps(detail.get("resources", [])) + + ci = ClabInstance( + id=inst_id, + token=token, + owner_user_id=current_user.id, + clab_id=clab.id, + is_deleted=False, + expiration_ts=None, + finish_reason=None, + _clab_resources=resources, + namespace_effective=detail.get("namespace") or clab_ns, + ) + db.session.add(ci) + db.session.commit() + + # 3) resposta compatível com a UI (mantida) + nodes_count = len(detail.get("resources", [])) + return jsonify({ + "ok": True, + "result": f"CLab '{detail.get('title','Unnamed')}' created.", + "received": { + "id": detail.get("lab_instance_id"), + "namespace": detail.get("namespace") or "--", + "has_yaml": bool(yaml_manifest), + "files": files_count, + "resources": nodes_count, + } + }), 201 + +@blueprint.route("/api/list", methods=["GET"]) +@login_required +def api_list(): + """Return list of labs + number of pruned items (TTL).""" + pruned = 0 + + q = ClabInstance.query.filter_by(is_deleted=False) + if not _is_admin_or_teacher(): + q = q.filter_by(owner_user_id=current_user.id) + cis = q.order_by(ClabInstance.created_at.desc()).all() + items = [_row_from_db(ci) for ci in cis] + return jsonify({"items": items, "pruned": pruned}) + +@blueprint.route("/api/clear", methods=["POST"]) +@login_required +def api_clear_all(): + """Admin/teacher-only: clear all labs from state.""" + if not _is_admin_or_teacher(): + return jsonify({"ok": False, "error": "Forbidden"}), 403 + n = ctrl().clear() + return jsonify({"ok": True, "removed": n}) + +@blueprint.route("/api/delete/", methods=["DELETE", "POST"]) +@login_required +def api_delete(clab_id): + """Delete a single lab. Owners can delete their own; admin/teacher can delete any.""" + ci = ClabInstance.query.get(clab_id) + if not ci or ci.is_deleted: + return jsonify({"ok": False, "error": "CLab not found"}), 404 + + if (not _is_admin_or_teacher()) and (ci.owner_user_id != current_user.id): + return jsonify({"ok": False, "error": "Forbidden"}), 403 + + # tentativa de teardown no PoC (idempotente) + try: + ctrl().delete(clab_id) + except Exception: + current_app.logger.exception("PoC teardown failed (continuing)") + + ci.is_deleted = True + ci.finish_reason = ci.finish_reason or "user_deleted" if not _is_admin_or_teacher() else "admin_cleanup" + db.session.commit() + return jsonify({"ok": True}) + +@blueprint.route("/api/status/", methods=["GET"]) +@login_required +def api_status(clab_id): + """ + Retorna o status atual da instância: + { ok, status, expires_at, remaining_seconds } + Regras de acesso: owner/admin/teacher. + """ + ci = ClabInstance.query.get(clab_id) + if not ci: + return jsonify({"ok": False, "error": "CLab not found"}), 404 + + if (not _is_admin_or_teacher()) and (ci.owner_user_id != current_user.id): + return jsonify({"ok": False, "error": "Forbidden"}), 403 + + st = _status_from_ci(ci) + return jsonify({ + "ok": True, + "status": st["status"], + "expires_at": st["expires_at"], + "remaining_seconds": st["remaining_seconds"], + }) + +@blueprint.route("/api/extend/", methods=["POST"]) +@login_required +def api_extend(clab_id): + """ + Estende a expiração da instância em X horas (limites por configuração). + Body (JSON ou form): {"hours": } + """ + ci = ClabInstance.query.get(clab_id) + if not ci: + return jsonify({"ok": False, "error": "CLab not found"}), 404 + + if (not _is_admin_or_teacher()) and (ci.owner_user_id != current_user.id): + return jsonify({"ok": False, "error": "Forbidden"}), 403 + + if ci.is_deleted: + return jsonify({"ok": False, "error": "Cannot extend a deleted CLab"}), 400 + + # parâmetros e limites + cfg = current_app.config + max_per_req = int(cfg.get("CLABS_EXTEND_MAX_PER_REQUEST_HOURS", 4)) + max_total = int(cfg.get("CLABS_EXTEND_MAX_TOTAL_HOURS", 48)) + + hours_raw = (request.json or {}).get("hours") if request.is_json else request.form.get("hours") + try: + req_hours = int(hours_raw) if hours_raw is not None else max_per_req + except Exception: + return jsonify({"ok": False, "error": "Invalid 'hours'"}), 400 + + if req_hours <= 0: + return jsonify({"ok": False, "error": "Invalid 'hours'"}), 400 + + delta_hours = min(req_hours, max_per_req) + + now = _now_utc() + old_exp = ci.expiration_ts + base = old_exp if old_exp and old_exp > now else now + new_exp = base + dt.timedelta(hours=delta_hours) + + # teto absoluto: created_at + max_total + hard_cap = (ci.created_at or now) + dt.timedelta(hours=max_total) + if new_exp > hard_cap: + new_exp = hard_cap + + # se mesmo após o ajuste não houver ganho, avisa + if old_exp and new_exp <= old_exp: + st = _status_from_ci(ci) + return jsonify({ + "ok": False, + "error": "Max extension reached", + "status": st["status"], + "expires_at": st["expires_at"], + "remaining_seconds": st["remaining_seconds"], + }), 400 + + # aplica + ci.expiration_ts = new_exp + db.session.commit() + + st = _status_from_ci(ci) + return jsonify({ + "ok": True, + "old_expires_at": old_exp.strftime("%Y-%m-%dT%H:%M:%SZ") if old_exp else None, + "new_expires_at": st["expires_at"], + "status": st["status"], + "remaining_seconds": st["remaining_seconds"], + "applied_hours": delta_hours, + "max_total_hours": max_total, + }) diff --git a/apps/clabs/templates/clabs/create.html b/apps/clabs/templates/clabs/create.html new file mode 100644 index 0000000..eb37f67 --- /dev/null +++ b/apps/clabs/templates/clabs/create.html @@ -0,0 +1,499 @@ +{% extends "layouts/base.html" %} + +{% block title %} Create CLabs {% endblock %} + +{% block body_class %} sidebar-mini layout-navbar-fixed {% endblock body_class %} + +{% block stylesheets %} + + + + + + + + + +{% endblock stylesheets %} + +{% block content %} + +
+ +
+
+
+
+

Create CLabs

+ Paste your containerlab YAML and/or upload files/folders +
+
+ +
+
+
+
+ +
+ +
+
+
+
+

New ContainerLab

+
+
+
+
+ + +
+
+ + +
+
+ +
+ + + Use the same style of editor we já utilizamos para + Kubernetes Manifest (CodeMirror). +
+ +
+ +
+ +
Drag & drop files here, or click to select
+ Accepted: multiple files +
+ +
+
+ +
+ + + + Folder upload works on Chromium-based browsers (Chrome/Edge). Firefox não suporta; como + alternativa, compacte em ZIP e envie nos arquivos acima. + +
+ +
+ +
+
+
+ +
+ +
+ +{% endblock content %} + +{% block javascripts %} + + + + + + + + + + + + + +{% endblock javascripts %} \ No newline at end of file diff --git a/apps/clabs/templates/clabs/open.html b/apps/clabs/templates/clabs/open.html new file mode 100644 index 0000000..5fc290d --- /dev/null +++ b/apps/clabs/templates/clabs/open.html @@ -0,0 +1,204 @@ +{% extends "layouts/base.html" %} + +{% block title %} Open CLab {% endblock %} + +{% block body_class %} sidebar-mini layout-navbar-fixed {% endblock body_class %} + +{% block stylesheets %} + + + + + + + + + +{% endblock stylesheets %} + +{% block content %} + +
+ +
+
+
+
+

Open CLab

+ View ContainerLab resources +
+
+ +
+
+
+
+ +
+ + +
+
+
+
+

{{ lab.title }}

+
+
+
+
+ Instance ID: +

{{ lab.lab_instance_id }}

+
+
+ User: +

{{ lab.user }}

+
+
+ Created (UTC): +

{{ lab.created or '--' }}

+
+
+ Expires at: +

{{ lab.expires_at or '--' }}

+
+
+
+
+
+
+ + +
+
+
+
+

Uploaded Files

+
+
+ {% if lab.files and lab.files|length > 0 %} +
    + {% for f in lab.files %} +
  • + + {{ f.name or f.filename or f }} + {% if f.size %} ({{ f.size }} bytes){% endif %} + {% if f.mimetype %} — {{ f.mimetype }}{% endif %} +
  • + {% endfor %} +
+ {% else %} +

No files uploaded.

+ {% endif %} +
+
+
+
+ + +
+
+
+
+

Resources

+
+
+ + + + + + + + + + + + + + + {% for r in lab.resources %} + + + + + + + + + + + {% endfor %} + +
KindNameStatusNodePod IPAgeServicesLinks
{{ r.kind }}{{ r.name }} + + {{ r.ready or '--' }} + + {{ r.node_name or '--' }}{{ r.pod_ip or '--' }}{{ r.age or '--' }} + {% if r.services %} + {% for s in r.services %} + {{ + s.name }} + {% endfor %} + {% else %} + -- + {% endif %} + + {% if r.links %} + {% for l in r.links %} + {{ l.label }} + {% endfor %} + {% else %} + -- + {% endif %} +
+
+
+
+
+ +
+ +
+ +{% endblock content %} + +{% block javascripts %} + + + + + + + + + + + + + +{% endblock javascripts %} \ No newline at end of file diff --git a/apps/clabs/templates/clabs/running.html b/apps/clabs/templates/clabs/running.html new file mode 100644 index 0000000..89155f1 --- /dev/null +++ b/apps/clabs/templates/clabs/running.html @@ -0,0 +1,280 @@ +{% extends "layouts/base.html" %} + +{% block title %} Running CLabs {% endblock %} + +{% block body_class %} sidebar-mini layout-navbar-fixed {% endblock body_class %} + +{% block stylesheets %} + + + + + + + + + + + +{% endblock stylesheets %} + +{% block content %} + +
+
+
+
+
+

Running CLabs

+
+
+ +
+
+
+
+ +
+
+
+
+
+

Running CLabs

+
+ + +
+
+ +
+ + + + + + + + + + + + {% for lab in labs %} + + + + + + + + {% endfor %} + +
UserLab titleCreated (UTC)Actions
+
+ + +
+
{{ lab['user'] }}{{ lab['title'] }}{{ lab['created'] }} + Open CLab +
+ +
+ + +
+ +
+
+ + + +
+
+ +{% endblock content %} + +{% block javascripts %} + + + + + + + + + + + + + + + + + +{% endblock javascripts %} \ No newline at end of file diff --git a/apps/config.py b/apps/config.py index b39ac91..3ff7322 100644 --- a/apps/config.py +++ b/apps/config.py @@ -11,6 +11,15 @@ class Config(object): basedir = os.path.abspath(os.path.dirname(__file__)) + # Persistência dos CLabs (PoC) + CLABS_EXPIRE_DAYS = int(os.getenv("CLABS_EXPIRE_DAYS", "0")) + + # Limites de upload + MAX_CONTENT_LENGTH = int(os.getenv("MAX_CONTENT_LENGTH", str(50 * 1024 * 1024))) # 50 MB + + # Limite de quantidade de arquivos (PoC) + CLABS_UPLOAD_MAX_FILES = int(os.getenv("CLABS_UPLOAD_MAX_FILES", 200)) + # Directories DATA_DIR = os.getenv('DATA_DIR', os.path.join(basedir, 'data')) UPLOAD_DIR = os.path.join(DATA_DIR, 'uploads') @@ -70,7 +79,7 @@ class Config(object): # Kubernetes K8S_NAMESPACE = os.getenv('K8S_NAMESPACE', "") K8S_CONFIG = os.path.expanduser(os.getenv("KUBECONFIG", "~/.kube/config")) - K8S_AVOID_NODES = os.getenv("K8S_AVOID_NODES", "").split(",") + K8S_AVOID_NODES = [n.strip() for n in os.getenv("K8S_AVOID_NODES", "").split(",") if n.strip()] # Base URL BASE_URL = os.getenv("BASE_URL", 'https://dashboard.hackinsdn.ufba.br') @@ -111,6 +120,11 @@ class Config(object): MAP_ZOOM_LEVEL = int(os.getenv('MAP_ZOOM_LEVEL', "1")) MAP_POINTS = json.loads(os.getenv('MAP_POINTS', '[]')) + # -------- Optional modules & feature flags -------- + # Canonical optional modules control (CSV -> list). Default includes "clabs". + OPTIONAL_MODULES = [m.strip() for m in os.getenv("OPTIONAL_MODULES", "clabs").split(",") if m.strip()] + # Backward-compatible flag for CLabs; prefer OPTIONAL_MODULES going forward. + ENABLE_CLABS = str(os.getenv("ENABLE_CLABS", "false")).lower() in ("1", "true", "yes", "on") class ProductionConfig(Config): DEBUG = False diff --git a/apps/controllers/__init__.py b/apps/controllers/__init__.py index 139db07..a336ca7 100644 --- a/apps/controllers/__init__.py +++ b/apps/controllers/__init__.py @@ -1,5 +1,23 @@ """apps controllers""" -from apps.controllers.kubernetes import K8sController +from .kubernetes import K8sController +from .clabernetes import C9sController -k8s = K8sController() +class _LazyProxy: + def __init__(self, factory): + self._factory = factory + self._inst = None + def _get(self): + if self._inst is None: + self._inst = self._factory() + return self._inst + def __getattr__(self, name): + return getattr(self._get(), name) + def __call__(self, *args, **kwargs): + return self._get() + +# proxies: só instanciam quando alguém usa +k8s = _LazyProxy(lambda: K8sController()) +c9s = _LazyProxy(lambda: C9sController()) + +__all__ = ["K8sController", "C9sController", "k8s", "c9s"] diff --git a/apps/controllers/clabernetes.py b/apps/controllers/clabernetes.py new file mode 100644 index 0000000..e520b90 --- /dev/null +++ b/apps/controllers/clabernetes.py @@ -0,0 +1,285 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import os +import json +import tempfile +import datetime as dt +import threading +from typing import List, Dict, Optional, Any + +import yaml # requires PyYAML in requirements.txt + + +class C9sController: + """ + Controller to manage ContainerLabs (PoC): + - In-memory state with optional JSON file persistence (safe defaults) + - Minimal CRUD ops + TTL prune + - ContainerLab YAML parsing -> resource (node) list + """ + + def __init__( + self, + state_path: Optional[str] = None, + expire_days: int = 0, + upload_max_files: int = 200, + ) -> None: + self._lock = threading.RLock() + + # Persistence default order: + # 1) explicit state_path argument + # 2) environment var CLABS_STATE_PATH (backward compatible) + # 3) safe temp file fallback + default_file = os.path.join(tempfile.gettempdir(), "clabs_state.json") + self.state_path = state_path or os.getenv("CLABS_STATE_PATH") or default_file + + self.expire_days = int(expire_days or 0) + self.upload_max_files = int(upload_max_files or 200) + + self._list: List[Dict[str, Any]] = [] + self._details: Dict[str, Dict[str, Any]] = {} + + self._load_state() + + # -------------------- Persistence (PoC) -------------------- + + def _state_file(self) -> Optional[str]: + """ + Ensure the directory exists and return the file path. + """ + if not self.state_path: + return None + dirname = os.path.dirname(self.state_path) or None + if dirname: + os.makedirs(dirname, exist_ok=True) + return self.state_path + + def _load_state(self) -> None: + path = self._state_file() + if not path or not os.path.exists(path): + self._list = [] + self._details = {} + return + try: + with self._lock, open(path, "r", encoding="utf-8") as f: + payload = json.load(f) + self._list = payload.get("list", []) or [] + self._details = payload.get("details", {}) or {} + except Exception: + # On read error, start empty (do not break the app) + self._list = [] + self._details = {} + + def _save_state(self) -> None: + path = self._state_file() + if not path: + return + payload = {"list": self._list, "details": self._details} + tmp = path + ".tmp" + with self._lock, open(tmp, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + os.replace(tmp, path) + + # -------------------- Helpers -------------------- + + @staticmethod + def _normalize_row(c: dict) -> dict: + lab_id = c.get("id") or c.get("lab_instance_id") or c.get("lab_id") + user = c.get("owner") or c.get("user") or "--" + title = c.get("name") or c.get("title") or "--" + created = c.get("created_at") or c.get("created") or "--" + return { + "lab_instance_id": lab_id, + "user": user, + "title": title, + "created": created, + } + + @staticmethod + def _parse_created(val: Optional[str]) -> Optional[dt.datetime]: + if not val: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f"): + try: + return dt.datetime.strptime(val, fmt) + except Exception: + continue + try: + return dt.datetime.fromisoformat(val.replace("Z", "")) + except Exception: + return None + + # -------------------- Public API -------------------- + + def list(self, user: Optional[str] = None) -> List[dict]: + """ + Return normalized table rows. If `user` is provided, filter by owner. + """ + items: List[dict] = [] + for c in self._list: + if user: + owner = c.get("owner") or c.get("user") + if owner != user: + continue + row = self._normalize_row(c) + if row["lab_instance_id"]: + items.append(row) + return items + + def get(self, lab_id: str) -> Optional[dict]: + return self._details.get(lab_id) + + def delete(self, lab_id: str) -> bool: + with self._lock: + if lab_id not in self._details: + return False + # remove details + self._details.pop(lab_id, None) + # remove from list + self._list = [ + c + for c in self._list + if (c.get("id") or c.get("lab_instance_id") or c.get("lab_id")) != lab_id + ] + self._save_state() + return True + + def clear(self) -> int: + with self._lock: + n = len(self._details) + self._list.clear() + self._details.clear() + self._save_state() + return n + + def prune(self, now: Optional[dt.datetime] = None) -> int: + """Remove labs older than `expire_days` (if > 0).""" + days = self.expire_days + if not days or days <= 0: + return 0 + + cutoff = (now or dt.datetime.utcnow()) - dt.timedelta(days=days) + + ids_to_remove: List[str] = [] + for c in self._list: + created_val = c.get("created_at") or c.get("created") + ts = self._parse_created(created_val) + if ts and ts < cutoff: + _id = c.get("id") or c.get("lab_instance_id") or c.get("lab_id") + if _id: + ids_to_remove.append(_id) + + if not ids_to_remove: + return 0 + + ids = set(ids_to_remove) + with self._lock: + self._list = [ + c + for c in self._list + if (c.get("id") or c.get("lab_instance_id") or c.get("lab_id")) not in ids + ] + for cid in list(self._details.keys()): + if cid in ids: + self._details.pop(cid, None) + self._save_state() + + return len(ids) + + # -------------------- Create & YAML -------------------- + + def _resources_from_yaml(self, yaml_text: str) -> List[dict]: + """ + Build the resource (node) list from a ContainerLab YAML manifest. + """ + if not yaml_text: + return [] + + data = yaml.safe_load(yaml_text) + if not isinstance(data, dict): + return [] + + topo = data.get("topology") or {} + nodes = topo.get("nodes") or {} + if not isinstance(nodes, dict): + return [] + + resources: List[dict] = [] + for name, cfg in nodes.items(): + pod_ip = None + node_name = None + if isinstance(cfg, dict): + pod_ip = cfg.get("mgmt_ipv4") + node_name = cfg.get("kind") or cfg.get("image") or "--" + + resources.append( + { + "kind": "node", + "name": str(name), + "ready": "Running", + "links": [{"label": "Console", "href": "#"}], + "services": [], + "age": "--", + "node_name": node_name or "--", + "pod_ip": pod_ip or "--", + } + ) + + return resources + + def create( + self, + name: str, + namespace: str, + yaml_manifest: str, + files_meta: Optional[List[dict]], + owner: Optional[str], + ) -> dict: + """ + Create a new CLab (PoC), validate YAML and update state. + Return the created detail. + """ + if yaml_manifest: + # validate syntax (raises yaml.YAMLError if invalid) + yaml.safe_load(yaml_manifest) + + resources = self._resources_from_yaml(yaml_manifest) + nodes_count = len(resources) + + now = dt.datetime.utcnow() + new_id = f"clab-{now.strftime('%Y%m%d%H%M%S')}" + created_str = now.strftime("%Y-%m-%d %H:%M:%S") + _owner = owner or "anonymous" + _name = name or "Unnamed" + + header = { + "id": new_id, + "lab_instance_id": new_id, + "owner": _owner, + "user": _owner, + "title": _name, + "name": _name, + "created_at": created_str, + "created": created_str, + "status": "Running", + "nodes": nodes_count, + } + + detail = { + "lab_instance_id": new_id, + "title": _name, + "user": _owner, + "created": created_str, + "expires_at": None, + "resources": resources, + "files": files_meta or [], + "namespace": namespace or "--", + } + + with self._lock: + self._list.append(header) + self._details[new_id] = detail + self._save_state() + + return detail diff --git a/apps/templates/includes/sidebar.html b/apps/templates/includes/sidebar.html index 4b07429..d8ece08 100644 --- a/apps/templates/includes/sidebar.html +++ b/apps/templates/includes/sidebar.html @@ -97,14 +97,41 @@ + + {% if config.get('ENABLE_CLABS') %} + + {% endif %} + {% if current_user.category == "admin" or current_user.category == "teacher" %}