Files
devplacepy/devplacepy/routers/admin/containers.py
T
2026-07-07 15:28:28 +02:00

418 lines
14 KiB
Python

# retoor <retoor@molodetz.nl>
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,
)