Files
devplacepy/devplacepy/routers/projects/containers/workspace.py
T

355 lines
13 KiB
Python
Raw Normal View History

2026-08-07 10:53:08 +02:00
# retoor <retoor@molodetz.nl>
from typing import Annotated
2026-08-10 00:23:20 +02:00
from fastapi import APIRouter, Depends, Form, Request, WebSocket
2026-08-07 10:53:08 +02:00
from starlette.responses import Response
from devplacepy.content import (
can_manage_workspace,
can_open_workspace,
)
from devplacepy.database import get_table, resolve_by_slug
2026-08-10 00:23:20 +02:00
from devplacepy.dependencies import json_or_form
from devplacepy.models import EditorPrefsForm, TunnelForm
2026-08-07 10:53:08 +02:00
from devplacepy.responses import action_result, json_error, respond
from devplacepy.schemas import WorkspaceOut
from devplacepy.services.audit import record as audit
from devplacepy.services.containers import activity, api, forward, store
from devplacepy.services.containers.api import ContainerError
2026-08-10 00:23:20 +02:00
from devplacepy.services.containers.workspace import editor, provision, quota, tunnels
2026-08-07 10:53:08 +02:00
from devplacepy.services.containers.workspace.provision import WorkspaceError
from devplacepy.utils import not_found, require_user
from ._shared import audit_instance, fail
2026-08-07 10:53:08 +02:00
router = APIRouter()
2026-08-10 00:23:20 +02:00
def _restart_required(instance: dict, profile: editor.EditorProfile) -> bool:
if instance.get("status") != store.ST_RUNNING:
return False
return editor.restart_required(instance, profile)
2026-08-07 10:53:08 +02:00
def _project_or_404(slug: str) -> dict:
project = resolve_by_slug(get_table("projects"), slug)
if not project:
raise not_found("Project not found")
return project
def _workspace_or_404(project: dict, user: dict) -> dict:
instance = provision.find_for_project(project["uid"], user["uid"])
if not instance:
raise not_found("No workspace for this project")
return instance
def _guard(request: Request, project: dict, user: dict, event_key: str) -> None:
if can_open_workspace(project, user):
return
audit.record(
request,
event_key,
user=user,
target_type="project",
target_uid=project["uid"],
target_label=project.get("title"),
summary=f"{user['username']} denied workspace access",
result="denied",
)
raise not_found("Workspaces are not available for this project")
@router.get("/{slug}/workspace")
async def workspace_page(request: Request, slug: str):
user = require_user(request)
if isinstance(user, Response):
return user
project = _project_or_404(slug)
_guard(request, project, user, "container.workspace.open")
instance = provision.find_for_project(project["uid"], user["uid"])
limits = quota.resolve(user["uid"])
2026-08-10 00:23:20 +02:00
profile = editor.resolve(user["uid"], instance)
2026-08-07 10:53:08 +02:00
context = {
"project": project,
"workspace": provision.view(instance) if instance else None,
"has_workspace": bool(instance),
"viewer_can_workspace": True,
"workspace_count": provision.count_for_owner(user["uid"]),
"max_workspaces": limits.max_workspaces,
"editor_url": (
f"/projects/{slug}/containers/instances/{instance['uid']}/code/"
if instance
else ""
),
"editor_password": (
api.ensure_editor_password(instance) if instance else ""
),
2026-08-10 00:23:20 +02:00
"editor": editor.view(user["uid"], instance),
"restart_required": (
_restart_required(instance, profile) if instance else False
),
2026-08-07 10:53:08 +02:00
"user": user,
}
return respond(request, "workspace.html", context, model=WorkspaceOut)
@router.post("/{slug}/workspace")
async def workspace_open(request: Request, slug: str):
user = require_user(request)
if isinstance(user, Response):
return user
project = _project_or_404(slug)
_guard(request, project, user, "container.workspace.quota.block")
try:
instance = await provision.ensure(project, user)
instance = provision.resume(instance)
except WorkspaceError as error:
audit.record(
request,
"container.workspace.quota.block",
user=user,
target_type="project",
target_uid=project["uid"],
summary=str(error),
result="denied",
)
return json_error(400, str(error))
except ContainerError as error:
return fail(error)
2026-08-07 10:53:08 +02:00
audit_instance(
request,
user,
"container.workspace.create",
instance,
project,
summary=f"{user['username']} opened workspace for {project.get('title')}",
)
provision.write_manifest(instance)
return action_result(
request, f"/projects/{slug}/workspace", data=provision.view(instance)
)
@router.post("/{slug}/workspace/stop")
async def workspace_stop(request: Request, slug: str):
user = require_user(request)
if isinstance(user, Response):
return user
project = _project_or_404(slug)
instance = _workspace_or_404(project, user)
if not can_manage_workspace(instance, project, user):
return json_error(403, "Not allowed to manage this workspace")
2026-08-07 10:53:08 +02:00
provision.stop(instance)
audit_instance(request, user, "container.workspace.stop", instance, project)
return action_result(request, f"/projects/{slug}/workspace")
@router.post("/{slug}/workspace/delete")
async def workspace_delete(request: Request, slug: str):
user = require_user(request)
if isinstance(user, Response):
return user
project = _project_or_404(slug)
instance = _workspace_or_404(project, user)
if not can_manage_workspace(instance, project, user):
return json_error(403, "Not allowed to manage this workspace")
2026-08-07 10:53:08 +02:00
for row in tunnels.list_for_instance(instance["uid"]):
tunnels.soft_delete(row["uid"], user["uid"])
store.delete_instance(instance["uid"], user["uid"])
audit_instance(request, user, "container.workspace.delete", instance, project)
return action_result(request, f"/projects/{slug}/workspace")
2026-08-10 00:23:20 +02:00
@router.get("/{slug}/workspace/editor")
async def editor_prefs_read(request: Request, slug: str):
user = require_user(request)
if isinstance(user, Response):
return user
project = _project_or_404(slug)
instance = _workspace_or_404(project, user)
if not can_manage_workspace(instance, project, user):
return json_error(403, "Not allowed to manage this workspace")
profile = editor.resolve(user["uid"], instance)
return {
"editor": editor.view(user["uid"], instance),
"restart_required": _restart_required(instance, profile),
}
@router.post("/{slug}/workspace/editor")
async def editor_prefs_write(
request: Request,
slug: str,
data: Annotated[EditorPrefsForm, Depends(json_or_form(EditorPrefsForm))],
):
user = require_user(request)
if isinstance(user, Response):
return user
project = _project_or_404(slug)
instance = _workspace_or_404(project, user)
if not can_manage_workspace(instance, project, user):
return json_error(403, "Not allowed to manage this workspace")
owner_uid = instance.get("workspace_owner_uid") or user["uid"]
if data.reset:
editor.reset_prefs(owner_uid, user["uid"])
summary = f"{user['username']} reset their workspace editor preferences"
else:
editor.save_prefs(
owner_uid, data.model_dump(exclude={"reset"}, exclude_unset=True)
)
summary = f"{user['username']} updated their workspace editor preferences"
audit_instance(
request,
user,
"container.workspace.editor.update",
instance,
project,
summary=summary,
)
profile = editor.resolve(owner_uid, instance)
return action_result(
request,
f"/projects/{slug}/workspace",
data={
"editor": editor.view(owner_uid, instance),
"restart_required": _restart_required(instance, profile),
},
)
2026-08-07 10:53:08 +02:00
@router.get("/{slug}/workspace/tunnels")
async def tunnel_list(request: Request, slug: str):
user = require_user(request)
if isinstance(user, Response):
return user
project = _project_or_404(slug)
instance = _workspace_or_404(project, user)
if not can_manage_workspace(instance, project, user):
return json_error(403, "Not allowed to manage this workspace")
2026-08-07 10:53:08 +02:00
return {"tunnels": tunnels.list_for_instance(instance["uid"])}
@router.post("/{slug}/workspace/tunnels")
async def tunnel_create(
request: Request, slug: str, data: Annotated[TunnelForm, Form()]
):
user = require_user(request)
if isinstance(user, Response):
return user
project = _project_or_404(slug)
instance = _workspace_or_404(project, user)
if not can_manage_workspace(instance, project, user):
return json_error(403, "Not allowed to manage this workspace")
2026-08-07 10:53:08 +02:00
if data.container_port <= 0:
return json_error(400, "container_port must be between 1 and 65535")
2026-08-07 10:53:08 +02:00
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
if limits.max_tunnels and tunnels.count_for_instance(
instance["uid"]
) >= limits.max_tunnels:
return json_error(400, f"tunnel limit reached ({limits.max_tunnels})")
2026-08-07 10:53:08 +02:00
row = tunnels.create(instance, data.label, data.container_port, user["uid"])
if not row:
return json_error(400, "could not create tunnel")
2026-08-07 10:53:08 +02:00
audit_instance(
request,
user,
"container.tunnel.create",
instance,
project,
metadata={"hostname": row["hostname"], "port": data.container_port},
)
provision.write_manifest(instance)
return action_result(request, f"/projects/{slug}/workspace", data=row)
@router.delete("/{slug}/workspace/tunnels/{uid}")
@router.post("/{slug}/workspace/tunnels/{uid}/delete")
async def tunnel_delete(request: Request, slug: str, uid: str):
user = require_user(request)
if isinstance(user, Response):
return user
project = _project_or_404(slug)
instance = _workspace_or_404(project, user)
if not can_manage_workspace(instance, project, user):
return json_error(403, "Not allowed to manage this workspace")
2026-08-07 10:53:08 +02:00
row = tunnels.get(uid)
if not row or row.get("instance_uid") != instance["uid"]:
raise not_found("Tunnel not found")
tunnels.soft_delete(uid, user["uid"])
audit_instance(
request,
user,
"container.tunnel.delete",
instance,
project,
metadata={"hostname": row.get("hostname", "")},
)
provision.write_manifest(instance)
return action_result(request, f"/projects/{slug}/workspace")
def _editor_guard(request: Request, slug: str, uid: str):
user = require_user(request)
if isinstance(user, Response):
return None, None, user
project = _project_or_404(slug)
instance = store.get_instance(uid)
if not instance or not instance.get("is_workspace"):
raise not_found("Workspace not found")
if not can_manage_workspace(instance, project, user):
return None, None, json_error(403, "Not allowed to open this workspace")
2026-08-07 10:53:08 +02:00
return project, instance, None
@router.api_route(
"/{slug}/containers/instances/{uid}/code", methods=forward.METHODS
)
@router.api_route(
"/{slug}/containers/instances/{uid}/code/{path:path}", methods=forward.METHODS
)
async def editor_proxy(request: Request, slug: str, uid: str, path: str = ""):
project, instance, denial = _editor_guard(request, slug, uid)
if denial is not None:
return denial
if instance.get("suspended_at"):
return Response("this workspace is suspended", status_code=403)
if instance.get("status") != store.ST_RUNNING:
return Response("this workspace is not running", status_code=409)
host, port = provision.editor_target(instance)
if not host or not port:
return Response("the editor has no reachable port", status_code=502)
activity.touch(instance["uid"])
prefix = f"/projects/{slug}/containers/instances/{uid}/code"
return await forward.proxy_http(request, host, port, path, prefix=prefix)
@router.websocket("/{slug}/containers/instances/{uid}/code")
@router.websocket("/{slug}/containers/instances/{uid}/code/{path:path}")
async def editor_proxy_ws(
websocket: WebSocket, slug: str, uid: str, path: str = ""
):
from devplacepy.utils import get_current_user
user = get_current_user(websocket)
project = resolve_by_slug(get_table("projects"), slug)
instance = store.get_instance(uid)
if not user or not project or not instance or not instance.get("is_workspace"):
await websocket.close(code=1008)
return
if not can_manage_workspace(instance, project, user):
await websocket.close(code=1008)
return
if instance.get("suspended_at") or instance.get("status") != store.ST_RUNNING:
await websocket.close(code=1011)
return
host, port = provision.editor_target(instance)
if not host or not port:
await websocket.close(code=1011)
return
activity.touch(instance["uid"])
prefix = f"/projects/{slug}/containers/instances/{uid}/code"
await forward.proxy_ws(websocket, host, port, path, prefix=prefix)