|
# 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)
|