# retoor from typing import Annotated from fastapi import Depends, APIRouter, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from devplacepy.database import ( db, get_table, get_users_by_uids, resolve_by_slug, search_users_by_username, ) from devplacepy.content import ( can_manage_instance, can_view_instance, can_view_project_containers, ) from devplacepy.models import ContainerAdminCreateForm, ContainerEditForm from devplacepy.responses import action_result, json_error, respond from devplacepy.schemas import ( AdminContainerEditOut, AdminContainerInstanceOut, AdminContainersOut, ) from devplacepy.seo import base_seo_context, site_url, website_schema from devplacepy.services.containers import api, store from devplacepy.services.containers.api import ContainerError from devplacepy.services.audit import record as audit from devplacepy.utils import not_found, require_admin from devplacepy.dependencies import json_or_form router = APIRouter() BOOT_LANGUAGES = list(api.BOOT_LANGUAGES) RESTART_POLICIES = list(store.RESTART_POLICIES) def _project_index(project_uids: set) -> dict: if not project_uids or "projects" not in db.tables: return {} table = get_table("projects") rows = table.find(table.table.columns.uid.in_(list(project_uids))) return {row["uid"]: row for row in rows} def _decorate(instances: list, viewer: dict | None = None) -> list: index = _project_index({inst["project_uid"] for inst in instances}) decorated = [] for inst in instances: project = index.get(inst["project_uid"], {}) if viewer is None: if not project or project.get("is_private"): continue manageable = False else: if not can_view_instance(inst, project or None, viewer): continue manageable = can_manage_instance(inst, project or None, viewer) row = dict(inst) row["project_title"] = project.get("title", "") row["project_slug"] = project.get("slug") or project.get("uid") or "" row["can_manage"] = manageable decorated.append(row) decorated.sort(key=lambda r: r.get("created_at", ""), reverse=True) return decorated def _instance_or_404(uid: str) -> dict: inst = store.get_instance(uid) if not inst: raise not_found("Instance not found") return inst def _project_of(inst: dict) -> dict: return get_table("projects").find_one(uid=inst["project_uid"]) or {} def _viewable_instance_or_404(uid: str, viewer: dict) -> dict: inst = _instance_or_404(uid) project = _project_of(inst) if not can_view_instance(inst, project or None, viewer): raise not_found("Instance not found") return inst def _manage_denied( request: Request, admin: dict, inst: dict, event_key: str ) -> JSONResponse | None: project = _project_of(inst) if can_manage_instance(inst, project or None, admin): return None _audit_admin( request, admin, event_key, inst, f"admin {admin['username']} denied {event_key} on instance {inst.get('name')}", result="denied", ) return json_error(403, "Only the container owner or the primary administrator can manage this instance") def _audit_admin(request: Request, admin: dict, event_key: str, inst: dict, summary: str, metadata=None, result: str = "success") -> None: audit.record( request, event_key, user=admin, target_type="instance", target_uid=inst["uid"], target_label=inst.get("name"), metadata=metadata, summary=summary, links=[audit.instance(inst["uid"], inst.get("name"))], result=result, ) @router.get("", response_class=HTMLResponse) async def containers_index(request: Request): admin = require_admin(request) instances = _decorate(store.all_instances(), admin) base = site_url(request) seo_ctx = base_seo_context( request, title="Containers - Admin", description="Manage container instances across all projects.", robots="noindex,nofollow", breadcrumbs=[ {"name": "Home", "url": "/feed"}, {"name": "Admin", "url": "/admin"}, {"name": "Containers", "url": "/admin/containers"}, ], schemas=[website_schema(base)], ) return respond( request, "containers_admin.html", { **seo_ctx, "request": request, "user": admin, "instances": instances, "boot_languages": BOOT_LANGUAGES, "restart_policies": RESTART_POLICIES, "admin_section": "containers", }, model=AdminContainersOut, ) @router.get("/data") async def containers_index_json(request: Request): admin = require_admin(request) return JSONResponse({"instances": _decorate(store.all_instances(), admin)}) @router.get("/projects/search") async def project_search(request: Request, q: str = ""): admin = require_admin(request) if not q or "projects" not in db.tables: return JSONResponse({"results": []}) rows = db.query( "SELECT uid, slug, title, user_uid, is_private FROM projects " "WHERE deleted_at IS NULL AND title LIKE :q LIMIT 20", q=f"%{q}%", ) results = [ {"uid": r["uid"], "slug": r["slug"] or r["uid"], "title": r["title"]} for r in rows if can_view_project_containers(r, admin) ][:10] return JSONResponse({"results": results}) @router.get("/users/search") async def user_search(request: Request, q: str = ""): require_admin(request) if not q: return JSONResponse({"results": []}) return JSONResponse({"results": search_users_by_username(q)}) @router.post("/create") async def container_create( request: Request, data: Annotated[ContainerAdminCreateForm, Depends(json_or_form(ContainerAdminCreateForm))] ): admin = require_admin(request) project = resolve_by_slug(get_table("projects"), data.project_slug) if not project or not can_view_project_containers(project, admin): return json_error(404, "project not found") try: inst = await api.create_instance( project, name=data.name, boot_command=data.boot_command, boot_language=data.boot_language, boot_script=data.boot_script, run_as_uid=data.run_as_uid, start_on_boot=data.start_on_boot, env=data.env, cpu_limit=data.cpu_limit, mem_limit=data.mem_limit, ports=data.ports, volumes=data.volumes, restart_policy=data.restart_policy, autostart=data.autostart, ingress_slug=data.ingress_slug, ingress_port=data.ingress_port, actor=("user", admin["uid"]), ) except ContainerError as exc: return json_error(400, str(exc)) _audit_admin( request, admin, "container.instance.create", inst, f"admin {admin['username']} created container instance {inst.get('name')}", metadata={ "project": project.get("title"), "boot_language": data.boot_language, "run_as_uid": data.run_as_uid or None, "start_on_boot": data.start_on_boot, }, ) return action_result(request, "/admin/containers", data={"instance": inst}) @router.get("/{uid}/edit", response_class=HTMLResponse) async def container_edit_page(request: Request, uid: str): admin = require_admin(request) inst = _viewable_instance_or_404(uid, admin) project = _project_of(inst) if not can_manage_instance(inst, project or None, admin): return RedirectResponse(url=f"/admin/containers/{uid}", status_code=302) run_as_user = None if inst.get("run_as_uid"): run_as_user = get_users_by_uids([inst["run_as_uid"]]).get(inst["run_as_uid"]) base = site_url(request) seo_ctx = base_seo_context( request, title=f"Edit {inst['name']} - Containers", description=f"Edit container instance {inst['name']}.", robots="noindex,nofollow", breadcrumbs=[ {"name": "Home", "url": "/feed"}, {"name": "Admin", "url": "/admin"}, {"name": "Containers", "url": "/admin/containers"}, {"name": inst["name"], "url": f"/admin/containers/{inst['uid']}"}, {"name": "Edit", "url": f"/admin/containers/{inst['uid']}/edit"}, ], schemas=[website_schema(base)], ) return respond( request, "containers_edit.html", { **seo_ctx, "request": request, "user": admin, "instance": inst, "project": project, "run_as_user": run_as_user, "boot_languages": BOOT_LANGUAGES, "restart_policies": RESTART_POLICIES, "admin_section": "containers", }, model=AdminContainerEditOut, ) @router.post("/{uid}/edit") async def container_edit( request: Request, uid: str, data: Annotated[ContainerEditForm, Depends(json_or_form(ContainerEditForm))] ): admin = require_admin(request) inst = _viewable_instance_or_404(uid, admin) denied = _manage_denied(request, admin, inst, "container.instance.configure") if denied: return denied try: updated = api.update_instance_config( inst, run_as_uid=data.run_as_uid, boot_language=data.boot_language, boot_script=data.boot_script, boot_command=data.boot_command, restart_policy=data.restart_policy, start_on_boot=data.start_on_boot, cpu_limit=data.cpu_limit, mem_limit=data.mem_limit, actor=("user", admin["uid"]), ) except ContainerError as exc: return json_error(400, str(exc)) _audit_admin( request, admin, "container.instance.configure", updated, f"admin {admin['username']} edited container instance {updated.get('name')}", metadata={ "boot_language": data.boot_language, "run_as_uid": data.run_as_uid or None, "start_on_boot": data.start_on_boot, "restart_policy": data.restart_policy, }, ) return action_result( request, f"/admin/containers/{uid}", data={"instance": updated} ) _ACTIONS = { "start": store.DESIRED_RUNNING, "stop": store.DESIRED_STOPPED, "pause": store.DESIRED_PAUSED, "resume": store.DESIRED_RUNNING, } @router.post("/{uid}/start") @router.post("/{uid}/stop") @router.post("/{uid}/pause") @router.post("/{uid}/resume") @router.post("/{uid}/restart") async def container_action(request: Request, uid: str): admin = require_admin(request) inst = _viewable_instance_or_404(uid, admin) action = request.url.path.rsplit("/", 1)[-1] denied = _manage_denied(request, admin, inst, f"container.instance.{action}") if denied: return denied actor = ("user", admin["uid"]) if action == "restart": api.request_restart(inst, actor=actor) else: api.set_desired_state(inst, _ACTIONS[action], actor=actor) _audit_admin( request, admin, f"container.instance.{action}", inst, f"admin {admin['username']} {action} container instance {inst.get('name')}", ) return action_result( request, "/admin/containers", data={"uid": uid, "action": action} ) @router.post("/{uid}/sync") async def container_sync(request: Request, uid: str): admin = require_admin(request) inst = _viewable_instance_or_404(uid, admin) denied = _manage_denied(request, admin, inst, "container.instance.sync") if denied: return denied try: counts = await api.sync_workspace(inst, admin) except ContainerError as exc: return json_error(400, str(exc)) _audit_admin( request, admin, "container.instance.sync", inst, f"admin {admin['username']} synced the workspace of instance {inst.get('name')}", metadata=counts, ) return action_result(request, f"/admin/containers/{uid}", data=counts) @router.post("/{uid}/delete") async def container_delete(request: Request, uid: str): admin = require_admin(request) inst = _viewable_instance_or_404(uid, admin) denied = _manage_denied(request, admin, inst, "container.instance.delete") if denied: return denied api.mark_for_removal(inst, actor=("user", admin["uid"])) _audit_admin( request, admin, "container.instance.delete", inst, f"admin {admin['username']} removed container instance {inst.get('name')}", ) return action_result(request, "/admin/containers", data={"uid": uid}) @router.get("/{uid}", response_class=HTMLResponse) async def container_instance_page(request: Request, uid: str): admin = require_admin(request) inst = _viewable_instance_or_404(uid, admin) project = _project_of(inst) project_slug = project.get("slug") or project.get("uid") or "" can_manage = can_manage_instance(inst, project or None, admin) base = site_url(request) seo_ctx = base_seo_context( request, title=f"{inst['name']} - Containers", description=f"Container instance {inst['name']}.", robots="noindex,nofollow", breadcrumbs=[ {"name": "Home", "url": "/feed"}, {"name": "Admin", "url": "/admin"}, {"name": "Containers", "url": "/admin/containers"}, {"name": inst["name"], "url": f"/admin/containers/{inst['uid']}"}, ], schemas=[website_schema(base)], ) return respond( request, "containers_instance.html", { **seo_ctx, "request": request, "user": admin, "instance": inst, "project": project, "project_slug": project_slug, "events": store.list_events(inst["uid"]), "schedules": store.list_schedules(inst["uid"]), "stats": api.instance_stats(inst["uid"]), "runtime": api.instance_runtime(inst), "can_manage": can_manage, "admin_section": "containers", }, model=AdminContainerInstanceOut, )