forked from retoor/devplacepy
418 lines
14 KiB
Python
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,
|
|
)
|