# retoor <retoor@molodetz.nl>
from typing import Annotated
from fastapi import APIRouter, Form, Request, WebSocket
from starlette.responses import Response
from devplacepy.content import (
can_manage_workspace,
can_open_workspace,
)
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.models import TunnelForm
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
from devplacepy.services.containers.workspace import provision, quota, tunnels
from devplacepy.services.containers.workspace.provision import WorkspaceError
from devplacepy.utils import not_found, require_user
from ._shared import audit_instance, fail
router = APIRouter()
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"])
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 ""
),
"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)
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")
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")
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")
@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")
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")
if data.container_port <= 0:
return json_error(400, "container_port must be between 1 and 65535")
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})")
row = tunnels.create(instance, data.label, data.container_port, user["uid"])
if not row:
return json_error(400, "could not create tunnel")
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")
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")
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)