iUUUUpdatexz
Some checks failed
DevPlace CI / test (push) Failing after 1h2m3s

This commit is contained in:
retoor 2026-08-07 10:53:08 +02:00
parent b777a5b9d0
commit 21f6ae0615
57 changed files with 4050 additions and 165 deletions

View File

@ -16,6 +16,7 @@ UPLOADS_DIR = DATA_DIR / "uploads"
ATTACHMENTS_DIR = UPLOADS_DIR / "attachments"
PROJECT_FILES_DIR = UPLOADS_DIR / "project_files"
CONTAINER_WORKSPACES_DIR = DATA_DIR / "container_workspaces"
WORKSPACE_STATE_DIR = DATA_DIR / "workspace_state"
ZIPS_DIR = DATA_DIR / "zips"
ZIP_STAGING_DIR = DATA_DIR / "zip_staging"
FORK_STAGING_DIR = DATA_DIR / "fork_staging"
@ -107,6 +108,12 @@ INIT_LOCK_FILE = LOCKS_DIR / "devplace-init.lock"
CONTAINER_IMAGE = environ.get("DEVPLACE_CONTAINER_IMAGE", "ppy:latest")
CONTAINER_PROXY_HOST = environ.get("DEVPLACE_CONTAINER_PROXY_HOST", "").strip()
WORKSPACE_TUNNEL_DOMAIN = environ.get(
"DEVPLACE_WORKSPACE_TUNNEL_DOMAIN", "tunnel.pravda.education"
).strip()
WORKSPACE_ACTIVITY_WRITE_SECONDS = 30
WORKSPACE_METRICS_RING = 720
VAPID_PRIVATE_KEY_FILE = KEYS_DIR / "notification-private.pem"
VAPID_PRIVATE_KEY_PKCS8_FILE = KEYS_DIR / "notification-private.pkcs8.pem"
VAPID_PUBLIC_KEY_FILE = KEYS_DIR / "notification-public.pem"
@ -118,6 +125,7 @@ DATA_PATHS: dict[str, Path] = {
"attachments": ATTACHMENTS_DIR,
"project_files": PROJECT_FILES_DIR,
"container_workspaces": CONTAINER_WORKSPACES_DIR,
"workspace_state": WORKSPACE_STATE_DIR,
"zips": ZIPS_DIR,
"zip_staging": ZIP_STAGING_DIR,
"fork_staging": FORK_STAGING_DIR,

View File

@ -136,6 +136,45 @@ def can_manage_instance(
return is_primary_admin(user) or owns_instance(instance, project, user)
def workspaces_enabled() -> bool:
from devplacepy.database import get_setting
return get_setting("workspace_enabled", "0") == "1"
def can_open_workspace(project: dict | None, user: dict | None) -> bool:
if not project or not user or not user.get("uid"):
return False
if not workspaces_enabled():
return False
return is_owner(project, user) or is_admin(user)
def owns_workspace(instance: dict | None, user: dict | None) -> bool:
if not instance or not user:
return False
uid = user.get("uid")
if not uid:
return False
return instance.get("workspace_owner_uid") == uid
def can_manage_workspace(
instance: dict | None, project: dict | None, user: dict | None
) -> bool:
if not instance or not user:
return False
if owns_workspace(instance, user):
return True
return can_manage_instance(instance, project, user)
def can_manage_tunnel(
instance: dict | None, project: dict | None, user: dict | None
) -> bool:
return can_manage_workspace(instance, project, user)
def canonical_redirect(
area: str, item: dict, requested: str
) -> RedirectResponse | None:

View File

@ -153,6 +153,7 @@ The profile **Media** tab (`/profile/{username}?tab=media`, public) is a paginat
- `owns(item, user)` (= `content.is_owner`) - per-item ownership (e.g. each comment). Page-level detail templates keep using the `is_owner` **bool** passed in their context (post/gist/project/profile); do not call `is_owner(...)` as a function - that name is a context bool and shadows globals.
- `is_self(user, uid)` - "is this me" (profile follow vs edit, leaderboard highlight).
- `guest_disabled(user)` -> emits ` disabled aria-disabled="true" title="Log in to participate"` for guests (empty for members); `login_hint(user)` -> a small login link. Both return `Markup`.
- **Account enabled/disabled is `database.is_account_active(row)` - never read `is_active` inline.** `is_active` is a nullable column, so a row written before it existed (or by any insert that omits it) holds SQL `NULL`, and the obvious `bool(row.get("is_active"))` reads that as *disabled*. `.get("is_active", True)` is no better: the default only applies when the key is **absent**, and a `SELECT *` row always has the key with value `None`. The predicate is `is_active is None or bool(is_active)` - unknown means active, only an explicit `0`/`False` disables. This is the same NULL-vs-0 trap as the `COALESCE` rule for atomic updates. It gates session auth, API-key auth, Basic auth, the login and access-token routes, the devRant token/auth paths, the admin enable/disable toggle, and `_can_hold_primary_admin`; every one of them goes through this single function. A NULL row previously could not log in at all, and the admin toggle could not disable it. (The `is_active` on a `gateway_models` row is a different table with its own semantics and is deliberately not routed through this.)
- **Role values are stored capitalized:** `users.role` is exactly `"Admin"` or `"Member"` (first registered user is `"Admin"`, `auth.py`); `is_admin` compares `== "Admin"` case-sensitively. The CLI is the only lowercase surface (`devplace role set ... <member|admin>` writes `role.capitalize()`; `role get` prints `.lower()`). A lowercase role in the DB silently defeats every admin check - never write a raw lowercase role.
- **The shadow rule generalizes beyond `is_owner` to ANY Jinja global** (`is_admin`, `avatar_url`, `format_date`, `is_self`, `owns`, `guest_disabled`): `respond(req, tmpl, ctx, model=XOut)` hands the **same** `ctx` to the Pydantic model and the template, and a context key shadows the same-named global across the whole `base.html` chain. A bool named `is_admin` in the context makes `base.html`'s `{% if is_admin(user) %}` raise `TypeError: 'bool' object is not callable` - a 500 that only fires for the branch invoking the global (logged-in users, not guests, which is why guest-only smoke tests miss it). Name viewer/permission flags distinctly (`viewer_is_admin`) in both schema and context. Real issue fixed on `/issues/{number}`; regression-guarded by `tests/api/issues/create.py::test_issue_detail_renders_for_{member,admin}` (they render the page as an authenticated Member/Admin and assert 200 + the admin-only control).
- **Policy enforced everywhere:** guests see all non-admin content read-only with action controls **shown but disabled** (`guest_disabled` on vote/star/react/poll/bookmark/follow/comment submit; create FABs become `/auth/login` links via `.feed-fab.login-required`); members get full member actions; **role badges render only to admin viewers** (`{% if is_admin(user) %}` around every `*.role` label). Backend stays the real gate (`require_user`/`require_admin`).

View File

@ -4,7 +4,7 @@ from .core import dataset, logging, Path, or_, defaultdict, datetime, timedelta,
from .core import refresh_snapshot, _local_cache_versions, _cache_version_cache, _cache_state_ready, _ensure_cache_state, get_cache_version, bump_cache_version, sync_local_cache, _index, _drop_index, _uid_index, get_table, _in_clause, _now_iso
from .atomic import conditional_update_row
from .settings import _settings_cache, get_setting, get_int_setting, set_setting, clear_settings_cache, internal_gateway_key
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, set_last_seen, get_online_users, get_primary_admin_uid, search_users_by_username
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, set_last_seen, get_online_users, get_primary_admin_uid, is_account_active, search_users_by_username
from .relations import _relations_cache, get_user_relations, get_blocked_uids, get_muted_uids, get_silenced_uids, invalidate_user_relations
from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, clear_user_post_count, build_pagination
from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event
@ -88,6 +88,7 @@ __all__ = [
"set_last_seen",
"get_online_users",
"get_primary_admin_uid",
"is_account_active",
"search_users_by_username",
"_relations_cache",
"get_user_relations",

View File

@ -19,6 +19,7 @@ NOTIFICATION_TYPES = [
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
{"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"},
{"key": "quiz_attempt", "label": "Quiz attempts", "description": "Someone completes one of your quizzes"},
{"key": "workspace", "label": "Dev workspaces", "description": "Idle, quota, retention and moderation notices for your dev workspaces"},
{"key": "system", "label": "System alerts", "description": "Platform infrastructure alerts (e.g. the AI gateway going down)"},
]

View File

@ -568,10 +568,109 @@ def init_db():
("boot_language", "none"),
("boot_script", ""),
("start_on_boot", 0),
("is_workspace", 0),
("workspace_owner_uid", ""),
("editor_port", 0),
("editor_host_port", 0),
("tunnel_name", ""),
("last_active_at", ""),
("idle_warned_at", ""),
("delete_warned_at", ""),
("disk_bytes", 0),
("disk_sampled_at", ""),
("egress_bytes", 0),
("request_count", 0),
("flagged_at", ""),
("flag_reason", ""),
("suspended_at", ""),
("suspended_by", ""),
):
if not instances.has_column(column):
instances.create_column_by_example(column, example)
tunnels = get_table("tunnels")
for column, example in (
("uid", ""),
("instance_uid", ""),
("project_uid", ""),
("user_uid", ""),
("hostname", ""),
("label", ""),
("container_port", 0),
("desired_state", "present"),
("status", "pending"),
("cert_status", ""),
("cert_checked_at", ""),
("request_count", 0),
("bytes_out", 0),
("last_request_at", ""),
("last_error", ""),
("last_synced_at", ""),
("created_at", ""),
("updated_at", ""),
):
if not tunnels.has_column(column):
tunnels.create_column_by_example(column, example)
quota_rules = get_table("workspace_quota_rules")
for column, example in (
("uid", ""),
("owner_kind", ""),
("owner_id", ""),
("label", ""),
("max_workspaces", 0),
("max_tunnels", 0),
("disk_quota_mb", 0),
("egress_quota_mb", 0),
("idle_stop_minutes", 0),
("retention_days", 0),
("created_at", ""),
("updated_at", ""),
):
if not quota_rules.has_column(column):
quota_rules.create_column_by_example(column, example)
flags = get_table("workspace_flags")
for column, example in (
("uid", ""),
("instance_uid", ""),
("user_uid", ""),
("kind", ""),
("severity", "warn"),
("detail", ""),
("metric_value", 0.0),
("threshold", 0.0),
("status", "open"),
("resolved_by", ""),
("resolved_at", ""),
("created_at", ""),
("updated_at", ""),
):
if not flags.has_column(column):
flags.create_column_by_example(column, example)
_index(db, "instances", "idx_instances_workspace", ["is_workspace", "status"])
_index(db, "instances", "idx_instances_workspace_owner", ["workspace_owner_uid"])
_index(db, "instances", "idx_instances_tunnel_name", ["tunnel_name"])
_index(db, "tunnels", "idx_tunnels_hostname", ["hostname"])
_index(db, "tunnels", "idx_tunnels_instance", ["instance_uid"])
_index(db, "tunnels", "idx_tunnels_user", ["user_uid"])
_index(db, "tunnels", "idx_tunnels_state", ["desired_state", "status"])
_index(
db,
"workspace_quota_rules",
"idx_workspace_quota_owner",
["owner_kind", "owner_id"],
)
_index(db, "workspace_flags", "idx_workspace_flags_open", ["status", "created_at"])
_index(
db,
"workspace_flags",
"idx_workspace_flags_instance",
["instance_uid", "kind"],
)
_index(db, "workspace_flags", "idx_workspace_flags_user", ["user_uid"])
_index(db, "instances", "idx_instances_project", ["project_uid"])
_index(db, "instances", "idx_instances_slug", ["slug"])
_index(db, "instances", "idx_instances_name", ["name"])

View File

@ -23,6 +23,8 @@ SOFT_DELETE_TABLES = [
"sessions",
"instances",
"instance_schedules",
"tunnels",
"workspace_flags",
"backup_schedules",
"devii_conversations",
"devii_tasks",

View File

@ -74,13 +74,15 @@ def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
)
def is_account_active(row) -> bool:
is_active = (row or {}).get("is_active")
return is_active is None or bool(is_active)
def _can_hold_primary_admin(row, tracks_active):
if row.get("deleted_at"):
return False
if not tracks_active:
return True
is_active = row.get("is_active")
return is_active is None or bool(is_active)
return not tracks_active or is_account_active(row)
def get_primary_admin_uid():

View File

@ -12,6 +12,7 @@ from . import (
uploads,
project_files,
containers,
workspaces,
tools,
push,
issues,
@ -34,6 +35,7 @@ ORDERED_GROUPS = [
uploads.GROUP,
project_files.GROUP,
containers.GROUP,
workspaces.GROUP,
tools.GROUP,
push.GROUP,
issues.GROUP,

View File

@ -0,0 +1,167 @@
# retoor <retoor@molodetz.nl>
from .._shared import endpoint, field
GROUP = {
"slug": "workspaces",
"title": "Dev Workspaces",
"intro": """
# Dev Workspaces
A workspace is a browser VS Code environment attached to one of your projects. It runs your project
files, a terminal, and preinstalled Python, Rust, Nim and Swift toolchains. `sudo` and
`apt install` work with no extra setup; ports below 1024 cannot bind, so use a high port and publish
it through a tunnel.
A **tunnel** publishes one port from inside your container on a public HTTPS hostname of the form
`<port>-<name>.tunnel.pravda.education`. **Tunnel URLs are public and unauthenticated** - anyone with
the link can reach whatever you are serving.
Workspaces are bounded: a count limit per user, a disk quota, an egress quota, and a tunnel limit.
An idle workspace is warned about, then stopped, then warned again, then removed. Every warning
arrives as a `workspace` notification and states exactly what happens next and when.
""",
"endpoints": [
endpoint(
id="workspace-get",
method="GET",
path="/projects/{slug}/workspace",
title="Read workspace",
summary=(
"State, quota usage, idle countdown, tunnels and open moderation flags "
"for your workspace on this project."
),
auth="user",
params=[
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
],
sample_response={
"has_workspace": True,
"viewer_can_workspace": True,
"workspace_count": 1,
"max_workspaces": 2,
"editor_url": "/projects/my-project/containers/instances/INSTANCE_UID/code/",
"workspace": {
"uid": "INSTANCE_UID",
"status": "running",
"suspended": False,
"tunnel_name": "brave-otter",
"primary_url": "https://brave-otter.tunnel.pravda.education",
"disk_bytes": 5242880,
"disk_quota_mb": 2048,
"disk_percent": 1,
"egress_bytes": 10240,
"egress_quota_mb": 10240,
"egress_percent": 0,
"idle_stop_minutes": 60,
"retention_days": 14,
"max_tunnels": 5,
"tunnels": [],
"flags": [],
},
},
),
endpoint(
id="workspace-open",
method="POST",
path="/projects/{slug}/workspace",
title="Open or resume workspace",
summary=(
"Create the workspace if you have none for this project, otherwise resume "
"it. Idempotent. Refused when you are at your workspace limit, over disk "
"quota, or suspended."
),
auth="user",
params=[
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
],
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
),
endpoint(
id="workspace-stop",
method="POST",
path="/projects/{slug}/workspace/stop",
title="Stop workspace",
summary="Stop the container. Files and tunnels are kept.",
auth="user",
params=[
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
],
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
),
endpoint(
id="workspace-delete",
method="POST",
path="/projects/{slug}/workspace/delete",
title="Delete workspace",
summary="Remove the workspace and its tunnels. An administrator can restore it.",
auth="user",
params=[
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
],
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
),
endpoint(
id="workspace-tunnels-list",
method="GET",
path="/projects/{slug}/workspace/tunnels",
title="List tunnels",
summary="Every public tunnel published by this workspace.",
auth="user",
params=[
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
],
sample_response={
"tunnels": [
{
"uid": "TUNNEL_UID",
"hostname": "8080-brave-otter.tunnel.pravda.education",
"label": "web",
"container_port": 8080,
"status": "active",
"cert_status": "valid",
"request_count": 12,
"bytes_out": 40960,
}
]
},
),
endpoint(
id="workspace-tunnel-create",
method="POST",
path="/projects/{slug}/workspace/tunnels",
title="Create tunnel",
summary=(
"Publish a container port on a public HTTPS hostname. The URL is public "
"and unauthenticated. Refused past the tunnel limit."
),
auth="user",
params=[
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
field("container_port", "body", "integer", True, 8080, "Port inside the container."),
field("label", "body", "string", False, "web", "Human label."),
],
sample_response={
"ok": True,
"data": {
"uid": "TUNNEL_UID",
"hostname": "8080-brave-otter.tunnel.pravda.education",
"status": "pending",
},
},
),
endpoint(
id="workspace-tunnel-delete",
method="POST",
path="/projects/{slug}/workspace/tunnels/{uid}/delete",
title="Delete tunnel",
summary="Remove a tunnel. The public URL stops serving immediately.",
auth="user",
params=[
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
field("uid", "path", "string", True, "TUNNEL_UID", "Tunnel uid."),
],
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
),
],
}

View File

@ -8,7 +8,7 @@ import time
from collections import defaultdict
from contextlib import asynccontextmanager, contextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, WebSocket
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError
@ -112,6 +112,7 @@ from devplacepy.services.jobs.deepsearch.service import DeepsearchService
from devplacepy.services.jobs.isslop.service import IsslopService
from devplacepy.services.gitea.service import IssueTrackerService
from devplacepy.services.containers.service import ContainerService
from devplacepy.services.containers.workspace_service import WorkspaceService
from devplacepy.services.xmlrpc import XmlrpcService
from devplacepy.services.audit import AuditService
from devplacepy.services.audit import record as audit
@ -274,6 +275,7 @@ async def lifespan(app: FastAPI):
service_manager.register(PlanningReportService())
service_manager.register(IssueTrackerService())
service_manager.register(ContainerService())
service_manager.register(WorkspaceService())
service_manager.register(XmlrpcService())
service_manager.register(AuditService())
service_manager.register(PushService())
@ -615,6 +617,42 @@ async def response_timing(request: Request, call_next):
return response
class TunnelDispatchMiddleware:
def __init__(self, app):
self.app = app
@staticmethod
def _host(scope) -> str:
for key, value in scope.get("headers") or []:
if key == b"host":
return value.decode("latin-1")
return ""
async def __call__(self, scope, receive, send):
if scope["type"] not in ("http", "websocket"):
await self.app(scope, receive, send)
return
from devplacepy.routers import tunnel as tunnel_router
from devplacepy.services.containers.workspace import naming
try:
if not naming.is_tunnel_host(self._host(scope)):
await self.app(scope, receive, send)
return
except Exception:
await self.app(scope, receive, send)
return
path = (scope.get("path") or "/").lstrip("/")
if scope["type"] == "websocket":
websocket = WebSocket(scope, receive, send)
await tunnel_router.handle_ws(websocket, path)
return
request = Request(scope, receive)
response = await tunnel_router.handle_http(request, path)
await response(scope, receive, send)
app.add_middleware(TunnelDispatchMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=5)

View File

@ -885,3 +885,29 @@ class QuizImportForm(BaseModel):
except ValueError as exc:
raise ValueError("document must be valid JSON") from exc
return value
class TunnelForm(BaseModel):
label: str = Field(default="", max_length=64)
container_port: int = Field(default=0, ge=0, le=65535)
class WorkspaceQuotaForm(BaseModel):
owner_id: str = Field(default="", max_length=36)
label: str = Field(default="", max_length=64)
max_workspaces: int = Field(default=0, ge=0, le=100)
max_tunnels: int = Field(default=0, ge=0, le=100)
disk_quota_mb: int = Field(default=0, ge=0)
egress_quota_mb: int = Field(default=0, ge=0)
idle_stop_minutes: int = Field(default=0, ge=0)
retention_days: int = Field(default=0, ge=0)
class WorkspaceFlagForm(BaseModel):
kind: str = Field(default="manual", max_length=40)
severity: str = Field(default="warn", max_length=16)
detail: str = Field(default="", max_length=500)
class WorkspaceSuspendForm(BaseModel):
reason: str = Field(default="", max_length=500)

View File

@ -8,6 +8,7 @@ from devplacepy.routers.admin import (
backups,
bots,
containers,
workspaces,
devii_tasks,
game,
gateway_configs,
@ -42,3 +43,4 @@ router.include_router(devii_tasks.router)
router.include_router(game.router)
router.include_router(services.router, prefix="/services")
router.include_router(containers.router, prefix="/containers")
router.include_router(workspaces.router)

View File

@ -10,6 +10,7 @@ from devplacepy.database import (
build_pagination,
get_post_counts_by_user_uids,
invalidate_admins_cache,
is_account_active,
)
from devplacepy.utils import (
require_admin,
@ -196,7 +197,7 @@ async def admin_user_toggle(request: Request, uid: str):
if _is_senior_admin(admin, user):
return _deny_senior(request, admin, uid, user, "admin.user.active.disable")
if user:
new_state = not user.get("is_active", True)
new_state = not is_account_active(user)
users.update({"uid": uid, "is_active": new_state}, ["uid"])
clear_user_cache(uid)
logger.info(

View File

@ -0,0 +1,282 @@
# retoor <retoor@molodetz.nl>
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from devplacepy.database import db, get_table, get_users_by_uids
from devplacepy.dependencies import json_or_form
from devplacepy.models import (
WorkspaceFlagForm,
WorkspaceQuotaForm,
WorkspaceSuspendForm,
)
from devplacepy.responses import action_result, json_error, respond
from devplacepy.schemas import AdminWorkspacesOut
from devplacepy.seo import base_seo_context
from devplacepy.services.audit import record as audit
from devplacepy.services.containers import store
from devplacepy.services.containers.workspace import flags, provision, quota, tunnels
from devplacepy.utils import create_notification, generate_uid, not_found, require_admin
router = APIRouter()
def _decorate(rows: list[dict]) -> list[dict]:
owner_uids = {row.get("workspace_owner_uid") for row in rows if row.get("workspace_owner_uid")}
owners = get_users_by_uids(list(owner_uids)) if owner_uids else {}
projects = {}
if "projects" in db.tables:
project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")}
for uid in project_uids:
found = get_table("projects").find_one(uid=uid)
if found:
projects[uid] = found
decorated = []
for row in rows:
view = provision.view(row)
owner = owners.get(row.get("workspace_owner_uid", "")) or {}
project = projects.get(row.get("project_uid", "")) or {}
view["owner_username"] = owner.get("username", "")
view["project_title"] = project.get("title", "")
view["project_slug"] = project.get("slug", "") or project.get("uid", "")
decorated.append(view)
return decorated
def _all_workspaces() -> list[dict]:
return list(get_table("instances").find(is_workspace=1, deleted_at=None))
def _instance_or_404(uid: str) -> dict:
instance = store.get_instance(uid)
if not instance or not instance.get("is_workspace"):
raise not_found("Workspace not found")
return instance
def _audit(request: Request, admin: dict, event_key: str, instance: dict, **extra):
audit.record(
request,
event_key,
user=admin,
target_type="instance",
target_uid=instance["uid"],
target_label=instance.get("name"),
summary=f"{admin['username']} {event_key} workspace {instance.get('name')}",
links=[audit.instance(instance["uid"], instance.get("name"))],
**extra,
)
@router.get("/workspaces")
async def admin_workspaces(request: Request):
admin = require_admin(request)
if not isinstance(admin, dict):
return admin
context = {
"workspaces": _decorate(_all_workspaces()),
"flags": flags.list_flags(),
"admin_section": "workspaces",
"user": admin,
**base_seo_context(
request,
title="Workspaces - Admin",
description="Administer dev workspaces.",
robots="noindex,nofollow",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Workspaces", "url": "/admin/workspaces"},
],
),
}
return respond(request, "admin_workspaces.html", context, model=AdminWorkspacesOut)
@router.get("/workspaces/data")
async def admin_workspaces_data(request: Request):
admin = require_admin(request)
if not isinstance(admin, dict):
return admin
return JSONResponse(
{"workspaces": _decorate(_all_workspaces()), "flags": flags.list_flags()}
)
@router.post("/workspaces/{uid}/suspend")
async def admin_workspace_suspend(
request: Request,
uid: str,
data: Annotated[WorkspaceSuspendForm, Depends(json_or_form(WorkspaceSuspendForm))],
):
admin = require_admin(request)
if not isinstance(admin, dict):
return admin
instance = _instance_or_404(uid)
reason = (data.reason or "").strip()
if not reason:
return json_error("a reason is required and is shown to the owner", 400)
provision.suspend(instance, admin["uid"], reason)
_audit(request, admin, "container.workspace.suspend", instance, metadata={"reason": reason})
owner = instance.get("workspace_owner_uid", "")
if owner:
create_notification(
owner,
"workspace",
f"Workspace {instance.get('name', '')} was suspended: {reason}",
instance["uid"],
"/projects",
)
return action_result(request, "/admin/workspaces")
@router.post("/workspaces/{uid}/unsuspend")
async def admin_workspace_unsuspend(request: Request, uid: str):
admin = require_admin(request)
if not isinstance(admin, dict):
return admin
instance = _instance_or_404(uid)
provision.unsuspend(instance)
_audit(request, admin, "container.workspace.unsuspend", instance)
owner = instance.get("workspace_owner_uid", "")
if owner:
create_notification(
owner,
"workspace",
f"Workspace {instance.get('name', '')} is available again.",
instance["uid"],
"/projects",
)
return action_result(request, "/admin/workspaces")
@router.post("/workspaces/{uid}/stop")
async def admin_workspace_stop(request: Request, uid: str):
admin = require_admin(request)
if not isinstance(admin, dict):
return admin
instance = _instance_or_404(uid)
provision.stop(instance)
_audit(request, admin, "container.workspace.stop", instance)
return action_result(request, "/admin/workspaces")
@router.post("/workspaces/{uid}/start")
async def admin_workspace_start(request: Request, uid: str):
admin = require_admin(request)
if not isinstance(admin, dict):
return admin
instance = _instance_or_404(uid)
store.update_instance(instance["uid"], {"desired_state": "running"})
_audit(request, admin, "container.workspace.resume", instance)
return action_result(request, "/admin/workspaces")
@router.post("/workspaces/{uid}/delete")
async def admin_workspace_delete(request: Request, uid: str):
admin = require_admin(request)
if not isinstance(admin, dict):
return admin
instance = _instance_or_404(uid)
for row in tunnels.list_for_instance(instance["uid"]):
tunnels.soft_delete(row["uid"], admin["uid"])
store.delete_instance(instance["uid"], admin["uid"])
_audit(request, admin, "container.workspace.delete", instance)
return action_result(request, "/admin/workspaces")
@router.post("/workspaces/{uid}/flag")
async def admin_workspace_flag(
request: Request,
uid: str,
data: Annotated[WorkspaceFlagForm, Depends(json_or_form(WorkspaceFlagForm))],
):
admin = require_admin(request)
if not isinstance(admin, dict):
return admin
instance = _instance_or_404(uid)
row = flags.raise_flag(
instance, data.kind or flags.KIND_MANUAL, data.severity, data.detail
)
_audit(request, admin, "container.workspace.flag.raise", instance,
metadata={"kind": data.kind, "severity": data.severity})
owner = instance.get("workspace_owner_uid", "")
if owner:
create_notification(
owner,
"workspace",
f"Workspace {instance.get('name', '')} was flagged: {data.detail or data.kind}",
instance["uid"],
"/projects",
)
return action_result(request, "/admin/workspaces", data=row)
@router.post("/workspaces/flags/{flag_uid}/resolve")
async def admin_flag_resolve(request: Request, flag_uid: str, status: str = "resolved"):
admin = require_admin(request)
if not isinstance(admin, dict):
return admin
if not flags.set_status(flag_uid, status, admin["uid"]):
raise not_found("Flag not found")
event = (
"container.workspace.flag.dismiss"
if status == "dismissed"
else "container.workspace.flag.resolve"
)
audit.record(
request,
event,
user=admin,
target_type="workspace_flag",
target_uid=flag_uid,
summary=f"{admin['username']} set flag {flag_uid} to {status}",
)
return action_result(request, "/admin/workspaces")
@router.post("/workspaces/quota")
async def admin_workspace_quota(
request: Request,
data: Annotated[WorkspaceQuotaForm, Depends(json_or_form(WorkspaceQuotaForm))],
):
admin = require_admin(request)
if not isinstance(admin, dict):
return admin
if not data.owner_id:
return json_error("owner_id is required", 400)
table = get_table(quota.RULES_TABLE)
existing = table.find_one(
owner_kind="user", owner_id=data.owner_id, deleted_at=None
)
payload = {key: getattr(data, key) for key in quota.RULE_COLUMNS}
if existing:
table.update({"uid": existing["uid"], "label": data.label, **payload}, ["uid"])
uid = existing["uid"]
else:
uid = generate_uid()
table.insert(
{
"uid": uid,
"owner_kind": "user",
"owner_id": data.owner_id,
"label": data.label,
"created_at": "",
"updated_at": "",
"deleted_at": None,
"deleted_by": None,
**payload,
}
)
audit.record(
request,
"container.workspace.settings.update",
user=admin,
target_type="user",
target_uid=data.owner_id,
summary=f"{admin['username']} updated workspace quota",
metadata=payload,
)
return action_result(request, "/admin/workspaces", data={"uid": uid, **payload})

View File

@ -4,7 +4,7 @@ import logging
from typing import Annotated
from fastapi import Depends, APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, get_int_setting
from devplacepy.database import get_table, get_int_setting, is_account_active
from devplacepy.templating import templates
from devplacepy.utils import (
verify_password_async,
@ -59,7 +59,7 @@ async def login(request: Request, data: Annotated[LoginForm, Depends(json_or_for
if not user or not await verify_password_async(password, user["password_hash"]):
errors.append("Invalid email or password")
elif not user.get("is_active", True):
elif not is_account_active(user):
errors.append("Account is deactivated")
if errors:

View File

@ -6,7 +6,7 @@ from typing import Annotated
from fastapi import Depends, APIRouter, Request
from fastapi.responses import JSONResponse
from devplacepy.database import get_table
from devplacepy.database import get_table, is_account_active
from devplacepy.utils import verify_password_async, get_current_user
from devplacepy.models import LoginForm
from devplacepy.dependencies import json_or_form
@ -59,7 +59,7 @@ async def token(
status_code=401,
)
if not user.get("is_active", True):
if not is_account_active(user):
audit.record(
request,
"auth.token.failure",

View File

@ -9,7 +9,7 @@ from fastapi.responses import Response
from devplacepy.cache import TTLCache
from devplacepy.config import SECONDS_PER_DAY
from devplacepy.database import get_table, get_setting
from devplacepy.database import get_table, get_setting, is_account_active
from devplacepy.utils import verify_password_async, register_account_async
from devplacepy.services.audit import record as audit
from devplacepy.services.devrant.params import merge_params
@ -42,7 +42,7 @@ async def auth_token(request: Request):
)
if (
not user
or not user.get("is_active", True)
or not is_account_active(user)
or not await verify_password_async(password, user["password_hash"])
):
audit.record(

View File

@ -2,8 +2,9 @@
from fastapi import APIRouter
from devplacepy.routers.projects.containers import instances, schedules
from devplacepy.routers.projects.containers import instances, schedules, workspace
router = APIRouter()
router.include_router(instances.router)
router.include_router(schedules.router)
router.include_router(workspace.router)

View File

@ -0,0 +1,278 @@
# 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, forward, store
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
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 ""
),
"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(str(error), 400)
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("Not allowed to manage this workspace", 403)
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("Not allowed to manage this workspace", 403)
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("Not allowed to manage this workspace", 403)
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("Not allowed to manage this workspace", 403)
if data.container_port <= 0:
return json_error("container_port must be between 1 and 65535", 400)
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(f"tunnel limit reached ({limits.max_tunnels})", 400)
row = tunnels.create(instance, data.label, data.container_port, user["uid"])
if not row:
return json_error("could not create tunnel", 400)
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("Not allowed to manage this workspace", 403)
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("Not allowed to open this workspace", 403)
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"])
await forward.proxy_ws(websocket, host, port, path)

View File

@ -1,70 +1,18 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import httpx
import websockets
from fastapi import APIRouter, Request, WebSocket
from starlette.responses import Response
from devplacepy.services.containers import api, store
from devplacepy.utils import not_found
from devplacepy.services.audit import record as audit
from devplacepy.services.containers import api, forward, store
from devplacepy.utils import not_found
logger = logging.getLogger(__name__)
router = APIRouter()
HOP_HEADERS = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"host",
"content-length",
"content-encoding",
}
METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
def _forward_headers(request: Request, prefix: str) -> dict:
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_HEADERS}
headers["X-Forwarded-Prefix"] = prefix
headers["X-Script-Name"] = prefix
headers["X-Forwarded-Host"] = request.headers.get(
"host", request.url.hostname or ""
)
headers["X-Forwarded-Proto"] = request.headers.get(
"x-forwarded-proto", request.url.scheme
)
headers["Accept-Encoding"] = "identity"
return headers
def _inject_base(body: bytes, prefix: str) -> bytes:
lowered = body.lower()
if b"<base" in lowered:
return body
tag = f'<base href="{prefix}/">'.encode()
head = lowered.find(b"<head")
anchor = (
lowered.find(b">", head)
if head != -1
else lowered.find(b">", lowered.find(b"<html"))
)
if anchor == -1:
return tag + body
return body[: anchor + 1] + tag + body[anchor + 1 :]
def _rewrite_location(value: str, prefix: str) -> str:
if value.startswith("/") and not value.startswith("//"):
return prefix + value
return value
METHODS = forward.METHODS
def _resolve(slug: str):
@ -95,41 +43,9 @@ async def proxy_http(request: Request, slug: str, path: str = ""):
summary=f"request proxied to instance {instance.get('name')} via ingress {slug}",
links=[audit.instance(instance["uid"], instance.get("name"))],
)
prefix = f"/p/{slug}"
url = f"http://{host}:{port}/{path}"
headers = _forward_headers(request, prefix)
body = await request.body()
try:
async with httpx.AsyncClient(timeout=60.0, follow_redirects=False) as client:
upstream = await client.request(
request.method,
url,
params=request.query_params,
headers=headers,
content=body,
)
except httpx.HTTPError as exc:
return Response(f"upstream error: {exc}", status_code=502)
out_headers = {
k: v
for k, v in upstream.headers.items()
if k.lower() not in HOP_HEADERS and k.lower() != "set-cookie"
}
if "location" in out_headers:
out_headers["location"] = _rewrite_location(out_headers["location"], prefix)
content_type = upstream.headers.get("content-type", "")
content = upstream.content
if "text/html" in content_type.lower():
content = _inject_base(content, prefix)
response = Response(
content=content,
status_code=upstream.status_code,
headers=out_headers,
media_type=content_type or None,
return await forward.proxy_http(
request, host, port, path, prefix=f"/p/{slug}", timeout=60.0
)
for cookie in upstream.headers.get_list("set-cookie"):
response.headers.append("set-cookie", cookie)
return response
@router.websocket("/{slug}")
@ -139,9 +55,6 @@ async def proxy_ws(websocket: WebSocket, slug: str, path: str = ""):
if instance is None or not host or not port:
await websocket.close(code=1011)
return
upstream_url = f"ws://{host}:{port}/{path}"
if websocket.url.query:
upstream_url += f"?{websocket.url.query}"
await websocket.accept()
audit.record(
websocket,
@ -154,48 +67,4 @@ async def proxy_ws(websocket: WebSocket, slug: str, path: str = ""):
summary=f"websocket proxied to instance {instance.get('name')} via ingress {slug}",
links=[audit.instance(instance["uid"], instance.get("name"))],
)
try:
async with websockets.connect(
upstream_url, open_timeout=10, max_size=None
) as upstream:
await _pump(websocket, upstream)
except Exception as exc:
logger.debug("ws proxy %s failed: %s", slug, exc)
try:
await websocket.close(code=1011)
except Exception:
pass
async def _pump(client_ws: WebSocket, upstream) -> None:
async def client_to_upstream():
try:
while True:
message = await client_ws.receive()
if message["type"] == "websocket.disconnect":
break
if message.get("text") is not None:
await upstream.send(message["text"])
elif message.get("bytes") is not None:
await upstream.send(message["bytes"])
except Exception:
pass
finally:
await upstream.close()
async def upstream_to_client():
try:
async for message in upstream:
if isinstance(message, (bytes, bytearray)):
await client_ws.send_bytes(bytes(message))
else:
await client_ws.send_text(message)
except Exception:
pass
finally:
try:
await client_ws.close()
except Exception:
pass
await asyncio.gather(client_to_upstream(), upstream_to_client())
await forward.proxy_ws(websocket, host, port, path, accepted=True)

View File

@ -0,0 +1,67 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, Request, WebSocket
from starlette.responses import Response
from devplacepy.services.containers import activity, api, forward, store
from devplacepy.services.containers.workspace import naming, tunnels
logger = logging.getLogger(__name__)
router = APIRouter()
METHODS = forward.METHODS
def resolve(host: str):
if not naming.is_tunnel_host(host):
return None, None, None, None
row = tunnels.by_hostname(host)
if not row or row.get("status") not in tunnels.SERVING_STATUSES:
return None, None, None, None
instance = store.get_instance(row.get("instance_uid", ""))
if not instance or instance.get("deleted_at"):
return None, None, None, None
if instance.get("suspended_at"):
return row, instance, None, None
if instance.get("status") != store.ST_RUNNING:
return row, instance, None, None
gateway, _ = api.proxy_target(instance)
host_port = _published_host_port(instance, int(row.get("container_port") or 0))
return row, instance, gateway, host_port
def _published_host_port(instance: dict, container_port: int) -> int:
import json
for mapping in json.loads(instance.get("ports_json") or "[]"):
if int(mapping.get("container") or 0) == container_port:
return int(mapping.get("host") or 0)
return 0
async def handle_http(request: Request, path: str) -> Response:
host = request.headers.get("host", "")
row, instance, gateway, port = resolve(host)
if row is None:
return Response("no tunnel is published at this address", status_code=404)
if instance is not None and instance.get("suspended_at"):
return Response("this workspace is suspended", status_code=403)
if not gateway or not port:
return Response("the tunnel has no reachable port", status_code=502)
response = await forward.proxy_http(request, gateway, port, path)
size = len(response.body) if hasattr(response, "body") and response.body else 0
activity.touch(instance["uid"], egress_bytes=size)
tunnels.record_hit(row["uid"], size)
return response
async def handle_ws(websocket: WebSocket, path: str) -> None:
host = websocket.headers.get("host", "")
row, instance, gateway, port = resolve(host)
if row is None or instance is None or not gateway or not port:
await websocket.close(code=1011)
return
activity.touch(instance["uid"])
await forward.proxy_ws(websocket, gateway, port, path)

View File

@ -71,10 +71,15 @@ from devplacepy.schemas.containers import (
AdminContainerEditOut,
AdminContainerInstanceOut,
AdminContainersOut,
AdminWorkspacesOut,
BotFrameOut,
ContainersOut,
InstanceOut,
ScheduleOut,
TunnelOut,
WorkspaceFlagOut,
WorkspaceOut,
WorkspaceViewOut,
)
from devplacepy.schemas.jobs import (
DbQueryJobOut,

View File

@ -99,3 +99,75 @@ class AdminBotsOut(_Out):
service_status: str = ""
admin_section: Optional[str] = None
user: Optional[Any] = None
class TunnelOut(_Out):
uid: str = ""
instance_uid: str = ""
project_uid: str = ""
user_uid: str = ""
hostname: str = ""
label: str = ""
container_port: int = 0
desired_state: str = ""
status: str = ""
cert_status: str = ""
request_count: int = 0
bytes_out: int = 0
last_request_at: str = ""
last_error: str = ""
created_at: str = ""
class WorkspaceFlagOut(_Out):
uid: str = ""
instance_uid: str = ""
user_uid: str = ""
kind: str = ""
severity: str = ""
detail: str = ""
metric_value: float = 0.0
threshold: float = 0.0
status: str = ""
created_at: str = ""
class WorkspaceViewOut(_Out):
uid: str = ""
name: str = ""
status: str = ""
desired_state: str = ""
suspended: bool = False
flag_reason: str = ""
tunnel_name: str = ""
primary_url: str = ""
last_active_at: str = ""
disk_bytes: int = 0
disk_quota_mb: int = 0
disk_percent: int = 0
egress_bytes: int = 0
egress_quota_mb: int = 0
egress_percent: int = 0
idle_stop_minutes: int = 0
retention_days: int = 0
max_tunnels: int = 0
tunnels: list[TunnelOut] = []
flags: list[WorkspaceFlagOut] = []
class WorkspaceOut(_Out):
project: Optional[Any] = None
workspace: Optional[WorkspaceViewOut] = None
has_workspace: bool = False
viewer_can_workspace: bool = False
workspace_count: int = 0
max_workspaces: int = 0
editor_url: str = ""
user: Optional[Any] = None
class AdminWorkspacesOut(_Out):
workspaces: list[WorkspaceViewOut] = []
flags: list[WorkspaceFlagOut] = []
admin_section: Optional[str] = None
user: Optional[Any] = None

View File

@ -4,7 +4,7 @@ import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from devplacepy.database import get_table, get_int_setting
from devplacepy.database import get_table, get_int_setting, is_account_active
from devplacepy.config import SECONDS_PER_DAY
from devplacepy.utils import generate_uid
@ -77,7 +77,7 @@ def resolve_token(token: str) -> Optional[dict]:
return None
user = get_table("users").find_one(uid=row.get("user_uid"))
if not user or not user.get("is_active", True):
if not user or not is_account_active(user):
return None
return user

View File

@ -120,7 +120,7 @@ The security hotpatch that used to run per build is now baked into `ppy.Dockerfi
- `COPY`s the **sudo superclone** (`files/sudo`) over `/usr/local/bin/sudo` (+ symlink `/usr/bin/sudo`; the real `sudo` package is not installed).
- `COPY`s the **`aptroot` fakeroot wrapper** (`files/aptroot`, symlinked over `apt`/`apt-get`/`dpkg` in `/usr/local/bin` so pravda installs system packages without root).
- `COPY`s **`pagent`** (`files/pagent`, the stdlib AI agent; reads `DEVPLACE_OPENAI_URL`+`DEVPLACE_API_KEY`, falling back to its public endpoint + `DEEPSEEK_API_KEY`) to `/usr/bin/pagent.py`, plus `files/.vimrc` to `/home/pravda/.vimrc` (whose AI helper - `AiEditSelection` - targets the same gateway as pagent via `DEVPLACE_OPENAI_URL`/`DEVPLACE_API_KEY`, with a public fallback, never `api.openai.com`).
- `COPY`s **`dpc`** (`files/dpc`, DevPlace Code, the Claude-Code-class coding agent) to `/usr/bin/dpc`, and **`bot.py`** to `/usr/bin/botje.py`. **`dpc` is the one prebuilt binary in this repository**: a stripped ELF 64-bit x86-64 executable, 3578664 bytes, sha256 `24f7fbb068e8461e085c5d49ea92556afc10452e4610de7784b87650f96377dd`, linked against GCC (Ubuntu 15.2.0-4ubuntu4) 15.2. Its source is NOT in this repository and there is no build recipe here, so unlike every other file in `files/` it cannot be reviewed before it is installed root-owned onto `PATH` in every user container. Record a new checksum here whenever it is replaced.
- `COPY`s **`dpc`** (`files/dpc`, DevPlace Code, the Claude-Code-class coding agent) to `/usr/bin/dpc`, and **`bot.py`** to `/usr/bin/botje.py`. **`dpc` is the one prebuilt binary in this repository**: an ELF 64-bit x86-64 position-independent executable, 10850272 bytes, sha256 `0c0f980717deebed285dcde7968c249f77638a65d76af8ae5deeda3ac2b0f042`. Its source is NOT in this repository and there is no build recipe here, so unlike every other file in `files/` it cannot be reviewed before it is installed root-owned onto `PATH` in every user container. Record a new size and checksum here whenever it is replaced - this record is the only integrity check that exists on it, so a stale entry is worse than none. (The previous entry, 3578664 bytes / sha256 `24f7fbb0...`, described a build that is no longer the file on disk.)
- Evicts any pre-existing uid-1000 user, creates user **`pravda` at `1000:1000`**.
- Hands pravda ownership of the toolchain AND the OS package trees (`chown -R pravda` over `/usr/local/lib`, `/usr/local/bin`, `/usr/lib/python3`, `/opt`, `/app`, `/home/pravda`, plus `/usr/lib`, `/usr/bin`, `/usr/sbin`, `/usr/share`, `/usr/include`, `/etc`, `/var/lib`, `/var/cache`, `/var/log`, `/srv` so `apt`/`dpkg` can write; `~/.local/bin` on `PATH`).
- Ends on `USER pravda`.
@ -199,3 +199,71 @@ The container runtime is also the basis of "vibe coding": the public prose page
When the runtime, the agent binaries, or the `PRAVDA_*`/ingress contract change, update this page alongside the source.
**The agents are gateway-only:** `dpc`/`d.py` and `botje.py`/`bot.py` use a single `molodetz` backend pointed at `DEVPLACE_OPENAI_URL` (the gateway); the former direct `api.deepseek.com` fallback backend was removed so every in-container AI call is ledgered under the run-as user and nothing bypasses `gateway_usage_ledger`. `pagent`/`.vimrc` already posted to the gateway URL (using `DEEPSEEK_API_KEY` only as a key fallback, never the DeepSeek endpoint). Rebuild the image (`make ppy`) for the change to reach running containers.
## Dev Workspaces (`workspace/`, `workspace_service.py`, `activity.py`, `forward.py`)
A **workspace** is a member-facing container running `code-server`, layered on this same runtime. The
admin container manager's authorization is unchanged; workspaces add their own narrower predicates.
**Two planes, deliberately distinct.**
| | Plane A: editor | Plane B: user's servers |
|---|---|---|
| Entry | `/projects/{slug}/containers/instances/{uid}/code/...` | `{port}-{name}.tunnel.pravda.education` |
| Auth | session + `can_manage_workspace` | none, public by design |
| Backend | code-server, `--auth none`, bound in-container | whatever the user runs |
Plane B routing is **one static molohttp site** `*.tunnel.pravda.education -> 127.0.0.1:10500`;
DevPlace resolves the instance from the `Host` header. There is no molohttp object per tunnel.
`TunnelDispatchMiddleware` (`main.py`) is ASGI-level and pre-empts the router for both HTTP and
WebSocket, so a tunnel host can never render the application. molohttp's `HostIndex` glob matches
**exactly one label**, which is why the pattern is `{port}-{name}`, never `{port}.{name}`.
**One forwarding core.** `forward.py` owns header filtering, prefix stripping, `Location` rewriting,
`<base>` injection and the bidirectional WS pump. `/p/{slug}`, the editor route and the tunnel route
all call it. Never write a second proxy.
**Editor persistence.** code-server's user-data and extensions live in
`config.WORKSPACE_STATE_DIR/<instance uid>`, bind-mounted at `WORKSPACE_STATE_MOUNT`, so extensions
survive container recreation. `api.editor_command` builds the argv; `run_spec_for` prefers it over
the boot-script/boot-command chain when `is_workspace` and `editor_port` are set.
**Activity and egress are the presence pattern.** `activity.py` keeps a per-worker monotonic dict and
writes at most once per `WORKSPACE_ACTIVITY_WRITE_SECONDS`, accumulating egress and request counts
into one atomic `COALESCE` UPDATE (`store.record_activity`). Both proxy planes call `touch`; because
plane B traverses DevPlace, public traffic is observed directly rather than inferred.
**`workspace/` package.** `quota.py` resolves limits instance -> user rule -> setting -> default
through ONE resolver (never read a workspace setting at a call site). `flags.py` is the abuse ledger
and is **idempotent per `(instance_uid, kind)` while a flag is open**, so a sustained condition is one
row, not one per tick. `naming.py` generates faker labels with collision retry and owns the hostname
patterns plus `is_tunnel_host`. `tunnels.py` is CRUD with revive-not-duplicate. `provision.py` is the
create/resume/stop/suspend/view surface and writes `/app/.devplace/tunnels.json`.
**`WorkspaceService`** is the only new service: lock-owner, `default_enabled=False`, four wrapped
phases (disk sample on its own slow cadence, flag evaluation, lifecycle, purge sweep). It is a
reconciler, not a `JobService`.
**Two contracts that bite:**
- A `ConfigField` with `type="select"` needs `options=[{"value": ..., "label": ...}]`. Plain strings
crash `docs_api.build_services_group`, which `docs_search` indexes, so the whole docs search page
500s. No other service used `select` before this one.
- A route taking `Annotated[Form, Form()]` validates **before** the handler's auth guard, so a
required field makes an anonymous request 422 instead of 401 and `tests/api/auth/matrix.py` fails.
Give the field a default and validate it inside the handler after `require_user`.
**Admin console** is `/admin/workspaces` (`routers/admin/workspaces.py`, `admin_workspaces.html`):
list, start/stop, suspend/unsuspend with a required reason, raise/resolve/dismiss flags, per-user
quota rules. `base_seo_context` takes `breadcrumbs`/`schemas`, not `canonical`/`schema`.
**Devii** has 15 tools under `handler="workspace"`; five are in `CONFIRM_REQUIRED` and every one of
them declares a `confirm` param (schemas are `additionalProperties: false`, so a gated tool without it
loops forever).
**Toolchains in `ppy`.** Rust (rustup), Nim (choosenim), Swift (swiftly, then the toolchain is moved
to a fixed `/opt/swift/toolchain` because swiftly's proxy resolves against `$HOME` and breaks for
`pravda` at runtime). The choosenim installer **exits 1 even on success**, so its `RUN` ends with
`|| true` plus a real version check. The build smoke test must not pipe (`cmd | head -1` returns
`head`'s status and masks a broken toolchain - this hid a non-working Swift through a full build).
`/etc/profile.d/devplace-toolchains.sh` re-exports the PATH because a login shell resets it, which is
what the container terminal uses.

View File

@ -0,0 +1,56 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import time
from datetime import datetime, timezone
from devplacepy.config import WORKSPACE_ACTIVITY_WRITE_SECONDS
from devplacepy.services.containers import store
_last_write: dict[str, float] = {}
_pending_egress: dict[str, int] = {}
_pending_requests: dict[str, int] = {}
def touch(instance_uid: str, egress_bytes: int = 0) -> None:
if not instance_uid:
return
if egress_bytes > 0:
_pending_egress[instance_uid] = _pending_egress.get(instance_uid, 0) + egress_bytes
_pending_requests[instance_uid] = _pending_requests.get(instance_uid, 0) + 1
now = time.monotonic()
if now - _last_write.get(instance_uid, 0.0) < WORKSPACE_ACTIVITY_WRITE_SECONDS:
return
_last_write[instance_uid] = now
flush(instance_uid)
def flush(instance_uid: str) -> None:
egress = _pending_egress.pop(instance_uid, 0)
requests = _pending_requests.pop(instance_uid, 0)
store.record_activity(
instance_uid,
datetime.now(timezone.utc).isoformat(),
egress,
requests,
)
def flush_all() -> None:
for instance_uid in list(_pending_requests) + list(_pending_egress):
if instance_uid in _pending_requests or instance_uid in _pending_egress:
flush(instance_uid)
def pending(instance_uid: str) -> tuple[int, int]:
return (
_pending_egress.get(instance_uid, 0),
_pending_requests.get(instance_uid, 0),
)
def forget(instance_uid: str) -> None:
_last_write.pop(instance_uid, None)
_pending_egress.pop(instance_uid, None)
_pending_requests.pop(instance_uid, None)

View File

@ -10,6 +10,7 @@ from devplacepy import config, project_files, stealth
from devplacepy.services.containers import store
from devplacepy.services.containers.backend.base import (
WORKSPACE_MOUNT,
WORKSPACE_STATE_MOUNT,
Mount,
PortMapping,
RunSpec,
@ -414,7 +415,7 @@ def pravda_env(instance: dict) -> dict:
break
slug = instance.get("ingress_slug") or ""
ingress_url = (f"{base_url}/p/{slug}" if base_url else f"/p/{slug}") if slug else ""
return {
env = {
"DEVPLACE_BASE_URL": base_url,
"DEVPLACE_OPENAI_URL": f"{base_url}/openai/v1" if base_url else "",
"DEVPLACE_API_KEY": api_key,
@ -423,6 +424,109 @@ def pravda_env(instance: dict) -> dict:
"DEVPLACE_CONTAINER_UID": instance.get("uid") or "",
"DEVPLACE_INGRESS_URL": ingress_url,
}
env.update(workspace_env(instance, base_url))
return env
def workspace_env(instance: dict, base_url: str) -> dict:
from devplacepy import database
from devplacepy.database import get_setting
from devplacepy.services.containers.workspace import naming, quota
if not instance.get("is_workspace"):
return {"DEVPLACE_WORKSPACE": ""}
project_slug = ""
project_title = ""
project_uid = instance.get("project_uid") or ""
if project_uid:
project = database.get_table("projects").find_one(uid=project_uid)
if project:
project_slug = project.get("slug") or project_uid
project_title = project.get("title") or ""
owner_uid = instance.get("workspace_owner_uid") or ""
owner_name = ""
if owner_uid:
owner = database.get_users_by_uids([owner_uid]).get(owner_uid)
if owner:
owner_name = owner.get("username") or ""
name = instance.get("tunnel_name") or ""
domain = naming.domain()
primary = naming.hostname_for(name) if name else ""
workspace_url = (
f"{base_url}/projects/{project_slug}/workspace"
if base_url and project_slug
else (f"/projects/{project_slug}/workspace" if project_slug else "")
)
limits = quota.resolve(owner_uid, instance)
gallery = get_setting("workspace_extensions_gallery", "").strip()
editor_port = int(instance.get("editor_port") or 0)
env = {
"DEVPLACE_WORKSPACE": "1",
"DEVPLACE_WORKSPACE_UID": instance.get("uid") or "",
"DEVPLACE_WORKSPACE_URL": workspace_url,
"DEVPLACE_WORKSPACE_OWNER": owner_name,
"DEVPLACE_WORKSPACE_OWNER_UID": owner_uid,
"DEVPLACE_PROJECT_SLUG": project_slug,
"DEVPLACE_PROJECT_TITLE": project_title,
"DEVPLACE_PROJECT_URL": (
f"{base_url}/projects/{project_slug}"
if base_url and project_slug
else (f"/projects/{project_slug}" if project_slug else "")
),
"DEVPLACE_WORKSPACE_DIR": WORKSPACE_MOUNT,
"DEVPLACE_WORKSPACE_STATE_DIR": WORKSPACE_STATE_MOUNT,
"DEVPLACE_TUNNEL_NAME": name,
"DEVPLACE_TUNNEL_DOMAIN": domain,
"DEVPLACE_TUNNEL_URL": f"https://{primary}" if primary else "",
"DEVPLACE_TUNNEL_PATTERN": naming.host_pattern(),
"DEVPLACE_TUNNEL_PORT_PATTERN": naming.port_pattern(),
"DEVPLACE_TUNNEL_MANIFEST": f"{WORKSPACE_MOUNT}/.devplace/tunnels.json",
"DEVPLACE_TUNNEL_MAX": str(limits.max_tunnels),
"VSCODE_PROXY_URI": naming.proxy_uri_template(name),
"DEVPLACE_EDITOR": "code-server",
"DEVPLACE_EDITOR_PORT": str(editor_port),
"DEVPLACE_EDITOR_URL": (
f"{workspace_url}" if workspace_url else ""
),
"VSCODE_CLI_DATA_DIR": f"{WORKSPACE_STATE_MOUNT}/cli",
"DEVPLACE_QUOTA_DISK_MB": str(limits.disk_quota_mb),
"DEVPLACE_QUOTA_DISK_USED_MB": str(
int(instance.get("disk_bytes") or 0) // (1024 * 1024)
),
"DEVPLACE_QUOTA_EGRESS_MB": str(limits.egress_quota_mb),
"DEVPLACE_IDLE_STOP_MINUTES": str(limits.idle_stop_minutes),
"DEVPLACE_RETENTION_DAYS": str(limits.retention_days),
"DEVPLACE_CPU_LIMIT": str(instance.get("cpu_limit") or ""),
"DEVPLACE_MEM_LIMIT": str(instance.get("mem_limit") or ""),
}
if gallery:
env["EXTENSIONS_GALLERY"] = gallery
return {key: ("" if value is None else str(value)) for key, value in env.items()}
EDITOR_DEFAULT_PORT = 8443
def editor_command(instance: dict) -> list[str]:
port = int(instance.get("editor_port") or EDITOR_DEFAULT_PORT)
return [
"code-server",
"--bind-addr",
f"0.0.0.0:{port}",
"--auth",
"none",
"--disable-telemetry",
"--disable-update-check",
"--user-data-dir",
f"{WORKSPACE_STATE_MOUNT}/data",
"--extensions-dir",
f"{WORKSPACE_STATE_MOUNT}/extensions",
WORKSPACE_MOUNT,
]
def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
@ -432,6 +536,10 @@ def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
for p in json.loads(instance.get("ports_json") or "[]")
]
mounts = [Mount(instance["workspace_dir"], WORKSPACE_MOUNT, "rw")]
if instance.get("is_workspace"):
state_dir = config.WORKSPACE_STATE_DIR / instance["uid"]
state_dir.mkdir(parents=True, exist_ok=True)
mounts.append(Mount(str(state_dir), WORKSPACE_STATE_MOUNT, "rw"))
for extra in json.loads(instance.get("volumes_json") or "[]"):
if isinstance(extra, dict) and extra.get("host") and extra.get("container"):
mounts.append(
@ -439,7 +547,9 @@ def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
)
language = (instance.get("boot_language") or "none").strip().lower()
boot = (instance.get("boot_command") or "").strip()
if language in BOOT_SCRIPT_FILES and (instance.get("boot_script") or "").strip():
if instance.get("is_workspace") and int(instance.get("editor_port") or 0):
command = editor_command(instance)
elif language in BOOT_SCRIPT_FILES and (instance.get("boot_script") or "").strip():
script_path = f"{WORKSPACE_MOUNT}/{BOOT_SCRIPT_FILES[language]}"
command = [BOOT_SCRIPT_RUNNERS[language], script_path]
elif boot:

View File

@ -9,6 +9,7 @@ from typing import Awaitable, Callable, Optional
LogCallback = Callable[[str], Awaitable[None]]
WORKSPACE_MOUNT = "/app"
WORKSPACE_STATE_MOUNT = "/home/pravda/.workspace-state"
@dataclass

View File

@ -0,0 +1,173 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import asyncio
import logging
import httpx
import websockets
from fastapi import Request, WebSocket
from starlette.responses import Response
logger = logging.getLogger(__name__)
HOP_HEADERS = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"host",
"content-length",
"content-encoding",
}
METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
DEFAULT_TIMEOUT = 300.0
def forward_headers(request: Request, prefix: str = "") -> dict:
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_HEADERS}
if prefix:
headers["X-Forwarded-Prefix"] = prefix
headers["X-Script-Name"] = prefix
headers["X-Forwarded-Host"] = request.headers.get(
"host", request.url.hostname or ""
)
headers["X-Forwarded-Proto"] = request.headers.get(
"x-forwarded-proto", request.url.scheme
)
headers["Accept-Encoding"] = "identity"
return headers
def inject_base(body: bytes, prefix: str) -> bytes:
lowered = body.lower()
if b"<base" in lowered:
return body
tag = f'<base href="{prefix}/">'.encode()
head = lowered.find(b"<head")
anchor = (
lowered.find(b">", head)
if head != -1
else lowered.find(b">", lowered.find(b"<html"))
)
if anchor == -1:
return tag + body
return body[: anchor + 1] + tag + body[anchor + 1 :]
def rewrite_location(value: str, prefix: str) -> str:
if not prefix:
return value
if value.startswith("/") and not value.startswith("//"):
return prefix + value
return value
async def proxy_http(
request: Request,
host: str,
port: int,
path: str,
*,
prefix: str = "",
timeout: float = DEFAULT_TIMEOUT,
rewrite_html: bool = True,
) -> Response:
url = f"http://{host}:{port}/{path}"
headers = forward_headers(request, prefix)
body = await request.body()
try:
async with httpx.AsyncClient(
timeout=timeout, follow_redirects=False
) as client:
upstream = await client.request(
request.method,
url,
params=request.query_params,
headers=headers,
content=body,
)
except httpx.HTTPError as error:
return Response(f"upstream error: {error}", status_code=502)
out_headers = {
k: v
for k, v in upstream.headers.items()
if k.lower() not in HOP_HEADERS and k.lower() != "set-cookie"
}
if "location" in out_headers:
out_headers["location"] = rewrite_location(out_headers["location"], prefix)
content_type = upstream.headers.get("content-type", "")
content = upstream.content
if prefix and rewrite_html and "text/html" in content_type.lower():
content = inject_base(content, prefix)
response = Response(
content=content,
status_code=upstream.status_code,
headers=out_headers,
media_type=content_type or None,
)
for cookie in upstream.headers.get_list("set-cookie"):
response.headers.append("set-cookie", cookie)
return response
async def proxy_ws(
websocket: WebSocket, host: str, port: int, path: str, *, accepted: bool = False
) -> None:
upstream_url = f"ws://{host}:{port}/{path}"
if websocket.url.query:
upstream_url += f"?{websocket.url.query}"
if not accepted:
await websocket.accept()
try:
async with websockets.connect(
upstream_url, open_timeout=10, max_size=None
) as upstream:
await pump(websocket, upstream)
except Exception as error:
logger.debug("ws proxy to %s:%s failed: %s", host, port, error)
try:
await websocket.close(code=1011)
except Exception:
pass
async def pump(client_ws: WebSocket, upstream) -> None:
async def client_to_upstream():
try:
while True:
message = await client_ws.receive()
if message["type"] == "websocket.disconnect":
break
if message.get("text") is not None:
await upstream.send(message["text"])
elif message.get("bytes") is not None:
await upstream.send(message["bytes"])
except Exception:
pass
finally:
await upstream.close()
async def upstream_to_client():
try:
async for message in upstream:
if isinstance(message, (bytes, bytearray)):
await client_ws.send_bytes(bytes(message))
else:
await client_ws.send_text(message)
except Exception:
pass
finally:
try:
await client_ws.close()
except Exception:
pass
await asyncio.gather(client_to_upstream(), upstream_to_client())

View File

@ -109,6 +109,29 @@ def update_instance(uid: str, changes: dict) -> None:
get_table("instances").update({"uid": uid, "updated_at": now(), **changes}, ["uid"])
def record_activity(
uid: str, seen_at: str, egress_bytes: int = 0, requests: int = 0
) -> None:
from sqlalchemy import text
if not _exists("instances"):
return
sql = (
"UPDATE instances SET last_active_at = :seen_at, "
"egress_bytes = COALESCE(egress_bytes, 0) + :egress, "
"request_count = COALESCE(request_count, 0) + :requests "
"WHERE uid = :uid AND deleted_at IS NULL"
)
params = {
"seen_at": seen_at,
"egress": max(0, egress_bytes),
"requests": max(0, requests),
"uid": uid,
}
with db:
db.executable.execute(text(sql), params)
def delete_instance(uid: str, deleted_by: str = "system") -> None:
get_table("instances").update(
{"uid": uid, "deleted_at": now(), "deleted_by": deleted_by}, ["uid"]

View File

@ -0,0 +1,5 @@
# retoor <retoor@molodetz.nl>
from . import flags, naming, provision, quota, tunnels
__all__ = ["flags", "naming", "provision", "quota", "tunnels"]

View File

@ -0,0 +1,134 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
FLAGS_TABLE = "workspace_flags"
SEVERITIES = ("info", "warn", "critical")
STATUSES = ("open", "acknowledged", "resolved", "dismissed")
KIND_CPU = "cpu_sustained"
KIND_EGRESS = "egress_spike"
KIND_REQUESTS = "request_rate"
KIND_ERRORS = "error_ratio"
KIND_DISK = "disk_growth"
KIND_TUNNEL_CHURN = "tunnel_churn"
KIND_MANUAL = "manual"
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def open_flag(instance_uid: str, kind: str) -> dict | None:
return get_table(FLAGS_TABLE).find_one(
instance_uid=instance_uid, kind=kind, status="open", deleted_at=None
)
def raise_flag(
instance: dict,
kind: str,
severity: str = "warn",
detail: str = "",
metric_value: float = 0.0,
threshold: float = 0.0,
) -> dict | None:
if severity not in SEVERITIES:
severity = "warn"
instance_uid = instance.get("uid", "")
if not instance_uid or not kind:
return None
table = get_table(FLAGS_TABLE)
existing = open_flag(instance_uid, kind)
if existing:
table.update(
{
"uid": existing["uid"],
"severity": severity,
"detail": detail,
"metric_value": float(metric_value),
"threshold": float(threshold),
"updated_at": _now(),
},
["uid"],
)
return table.find_one(uid=existing["uid"])
uid = generate_uid()
table.insert(
{
"uid": uid,
"instance_uid": instance_uid,
"user_uid": instance.get("workspace_owner_uid", ""),
"kind": kind,
"severity": severity,
"detail": detail,
"metric_value": float(metric_value),
"threshold": float(threshold),
"status": "open",
"resolved_by": "",
"resolved_at": "",
"created_at": _now(),
"updated_at": _now(),
"deleted_at": None,
"deleted_by": None,
}
)
return table.find_one(uid=uid)
def clear_flag(instance_uid: str, kind: str, resolved_by: str = "system") -> bool:
existing = open_flag(instance_uid, kind)
if not existing:
return False
get_table(FLAGS_TABLE).update(
{
"uid": existing["uid"],
"status": "resolved",
"resolved_by": resolved_by,
"resolved_at": _now(),
"updated_at": _now(),
},
["uid"],
)
return True
def set_status(uid: str, status: str, actor_uid: str) -> bool:
if status not in STATUSES:
return False
table = get_table(FLAGS_TABLE)
row = table.find_one(uid=uid, deleted_at=None)
if not row:
return False
changes = {"uid": uid, "status": status, "updated_at": _now()}
if status in ("resolved", "dismissed"):
changes["resolved_by"] = actor_uid
changes["resolved_at"] = _now()
table.update(changes, ["uid"])
return True
def list_flags(
instance_uid: str = "", user_uid: str = "", status: str = "open"
) -> list[dict]:
filters: dict[str, object] = {"deleted_at": None}
if instance_uid:
filters["instance_uid"] = instance_uid
if user_uid:
filters["user_uid"] = user_uid
if status:
filters["status"] = status
return list(get_table(FLAGS_TABLE).find(order_by=["-created_at"], **filters))
def has_critical(instance_uid: str) -> bool:
for row in list_flags(instance_uid=instance_uid):
if row.get("severity") == "critical":
return True
return False

View File

@ -0,0 +1,83 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import re
from faker import Faker
from devplacepy.config import WORKSPACE_TUNNEL_DOMAIN
from devplacepy.database import get_setting, get_table
LABEL = re.compile(r"^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$")
MAX_ATTEMPTS = 12
_faker = Faker()
def domain() -> str:
return get_setting("workspace_tunnel_domain", WORKSPACE_TUNNEL_DOMAIN).strip(".")
def host_pattern() -> str:
return get_setting("workspace_hostname_pattern", "{name}.{domain}")
def port_pattern() -> str:
return get_setting("workspace_port_hostname_pattern", "{port}-{name}.{domain}")
def is_valid_label(value: str) -> bool:
return bool(value) and bool(LABEL.match(value))
def _slugify(value: str) -> str:
cleaned = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return cleaned[:49].strip("-")
def candidate(index: int) -> str:
words = [_slugify(_faker.word()) for _ in range(2)]
words = [word for word in words if word]
if not words:
words = ["workspace"]
name = "-".join(words)
if index:
name = f"{name}-{index}"
return name if is_valid_label(name) else "workspace"
def taken(name: str) -> bool:
return bool(get_table("instances").find_one(tunnel_name=name, deleted_at=None))
def generate(fallback_uid: str = "") -> str:
for attempt in range(MAX_ATTEMPTS):
name = candidate(attempt)
if not taken(name):
return name
tail = (fallback_uid or "").replace("-", "")[-8:]
return f"workspace-{tail}" if tail else "workspace"
def hostname_for(name: str, port: int = 0) -> str:
if not name:
return ""
pattern = port_pattern() if port else host_pattern()
return pattern.format(name=name, domain=domain(), port=port)
def proxy_uri_template(name: str) -> str:
if not name:
return ""
return "https://" + port_pattern().format(
name=name, domain=domain(), port="{{port}}"
)
def is_tunnel_host(host: str) -> bool:
if not host:
return False
bare = host.split(":", 1)[0].lower().rstrip(".")
suffix = "." + domain().lower()
return bare.endswith(suffix)

View File

@ -0,0 +1,188 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
from pathlib import Path
from devplacepy import config
from devplacepy.database import get_table
from devplacepy.services.containers import api, store
from . import flags, naming, quota, tunnels
MANIFEST_DIRECTORY = ".devplace"
MANIFEST_NAME = "tunnels.json"
class WorkspaceError(Exception):
pass
def find_for_project(project_uid: str, owner_uid: str) -> dict | None:
return get_table("instances").find_one(
project_uid=project_uid,
workspace_owner_uid=owner_uid,
is_workspace=1,
deleted_at=None,
)
def count_for_owner(owner_uid: str) -> int:
return get_table("instances").count(
workspace_owner_uid=owner_uid, is_workspace=1, deleted_at=None
)
async def ensure(project: dict, user: dict) -> dict:
owner_uid = user["uid"]
existing = find_for_project(project["uid"], owner_uid)
if existing:
return existing
limits = quota.resolve(owner_uid)
if limits.max_workspaces and count_for_owner(owner_uid) >= limits.max_workspaces:
raise WorkspaceError(
f"workspace limit reached ({limits.max_workspaces}); "
"delete one before creating another"
)
instance = await api.create_instance(
project,
name=f"ws-{project.get('slug') or project['uid']}"[:64],
actor=("user", owner_uid),
ports=[f"{api.EDITOR_DEFAULT_PORT}"],
)
store.update_instance(
instance["uid"],
{
"is_workspace": 1,
"workspace_owner_uid": owner_uid,
"editor_port": api.EDITOR_DEFAULT_PORT,
"tunnel_name": naming.generate(instance["uid"]),
"desired_state": "running",
},
)
return store.get_instance(instance["uid"])
def resume(instance: dict) -> dict:
if instance.get("suspended_at"):
raise WorkspaceError("this workspace is suspended; contact an administrator")
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
disk_quota = limits.disk_quota_bytes()
if disk_quota and int(instance.get("disk_bytes") or 0) >= disk_quota:
raise WorkspaceError(
"disk quota reached; free space before starting this workspace again"
)
store.update_instance(
instance["uid"],
{"desired_state": "running", "idle_warned_at": "", "delete_warned_at": ""},
)
return store.get_instance(instance["uid"])
def stop(instance: dict) -> dict:
store.update_instance(instance["uid"], {"desired_state": "stopped"})
return store.get_instance(instance["uid"])
def suspend(instance: dict, actor_uid: str, reason: str) -> dict:
from datetime import datetime, timezone
store.update_instance(
instance["uid"],
{
"desired_state": "stopped",
"suspended_at": datetime.now(timezone.utc).isoformat(),
"suspended_by": actor_uid,
"flag_reason": reason,
},
)
tunnels.suspend_for_instance(instance["uid"])
return store.get_instance(instance["uid"])
def unsuspend(instance: dict) -> dict:
store.update_instance(
instance["uid"], {"suspended_at": "", "suspended_by": "", "flag_reason": ""}
)
tunnels.resume_for_instance(instance["uid"])
return store.get_instance(instance["uid"])
def editor_target(instance: dict) -> tuple[str, int]:
host, port = api.proxy_target(instance)
return host, port
def manifest_payload(instance: dict) -> dict:
rows = tunnels.list_for_instance(instance["uid"])
return {
"workspace": {
"uid": instance.get("uid", ""),
"name": instance.get("name", ""),
"tunnel_name": instance.get("tunnel_name", ""),
"domain": naming.domain(),
"project_uid": instance.get("project_uid", ""),
},
"tunnels": [
{
"label": row.get("label", ""),
"hostname": row.get("hostname", ""),
"url": f"https://{row.get('hostname', '')}",
"container_port": int(row.get("container_port") or 0),
"status": row.get("status", ""),
"cert_status": row.get("cert_status", ""),
}
for row in rows
],
}
def write_manifest(instance: dict) -> None:
workspace_dir = instance.get("workspace_dir")
if not workspace_dir:
return
directory = Path(workspace_dir) / MANIFEST_DIRECTORY
try:
directory.mkdir(parents=True, exist_ok=True)
(directory / MANIFEST_NAME).write_text(
json.dumps(manifest_payload(instance), indent=2)
)
except OSError:
return
def state_dir(instance: dict) -> Path:
return config.WORKSPACE_STATE_DIR / instance["uid"]
def view(instance: dict, viewer_is_admin: bool = False) -> dict:
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
disk_used = int(instance.get("disk_bytes") or 0)
egress_used = int(instance.get("egress_bytes") or 0)
return {
"uid": instance.get("uid", ""),
"name": instance.get("name", ""),
"status": instance.get("status", ""),
"desired_state": instance.get("desired_state", ""),
"suspended": bool(instance.get("suspended_at")),
"flag_reason": instance.get("flag_reason", ""),
"tunnel_name": instance.get("tunnel_name", ""),
"primary_url": (
f"https://{naming.hostname_for(instance.get('tunnel_name', ''))}"
if instance.get("tunnel_name")
else ""
),
"last_active_at": instance.get("last_active_at", ""),
"disk_bytes": disk_used,
"disk_quota_mb": limits.disk_quota_mb,
"disk_percent": quota.percent_used(disk_used, limits.disk_quota_bytes()),
"egress_bytes": egress_used,
"egress_quota_mb": limits.egress_quota_mb,
"egress_percent": quota.percent_used(egress_used, limits.egress_quota_bytes()),
"idle_stop_minutes": limits.idle_stop_minutes,
"retention_days": limits.retention_days,
"max_tunnels": limits.max_tunnels,
"tunnels": tunnels.list_for_instance(instance["uid"]),
"flags": flags.list_flags(instance_uid=instance["uid"]),
}

View File

@ -0,0 +1,97 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from dataclasses import dataclass
from devplacepy.database import get_int_setting, get_table
RULES_TABLE = "workspace_quota_rules"
DEFAULTS: dict[str, int] = {
"max_workspaces": 2,
"max_tunnels": 5,
"disk_quota_mb": 2048,
"egress_quota_mb": 10240,
"idle_warn_minutes": 45,
"idle_stop_minutes": 60,
"retention_days": 14,
"purge_after_days": 7,
"disk_warn_percent": 80,
}
SETTING_KEYS: dict[str, str] = {
"max_workspaces": "workspace_max_per_user",
"max_tunnels": "workspace_max_tunnels",
"disk_quota_mb": "workspace_disk_quota_mb",
"egress_quota_mb": "workspace_egress_quota_mb",
"idle_warn_minutes": "workspace_idle_warn_minutes",
"idle_stop_minutes": "workspace_idle_stop_minutes",
"retention_days": "workspace_retention_days",
"purge_after_days": "workspace_purge_after_days",
"disk_warn_percent": "workspace_disk_warn_percent",
}
RULE_COLUMNS = (
"max_workspaces",
"max_tunnels",
"disk_quota_mb",
"egress_quota_mb",
"idle_stop_minutes",
"retention_days",
)
@dataclass(frozen=True)
class Limits:
max_workspaces: int
max_tunnels: int
disk_quota_mb: int
egress_quota_mb: int
idle_warn_minutes: int
idle_stop_minutes: int
retention_days: int
purge_after_days: int
disk_warn_percent: int
def disk_quota_bytes(self) -> int:
return self.disk_quota_mb * 1024 * 1024
def egress_quota_bytes(self) -> int:
return self.egress_quota_mb * 1024 * 1024
def _global_value(key: str) -> int:
return get_int_setting(SETTING_KEYS[key], DEFAULTS[key])
def _rule_for(owner_kind: str, owner_id: str) -> dict | None:
if not owner_id:
return None
table = get_table(RULES_TABLE)
return table.find_one(owner_kind=owner_kind, owner_id=owner_id, deleted_at=None)
def resolve(user_uid: str = "", instance: dict | None = None) -> Limits:
rule = _rule_for("user", user_uid) if user_uid else None
values: dict[str, int] = {}
for key in DEFAULTS:
value = _global_value(key)
if rule and key in RULE_COLUMNS:
override = rule.get(key)
if override:
value = int(override)
if instance:
instance_override = instance.get(f"workspace_{key}")
if instance_override:
value = int(instance_override)
values[key] = max(0, value)
if values["idle_warn_minutes"] >= values["idle_stop_minutes"]:
values["idle_warn_minutes"] = max(1, values["idle_stop_minutes"] - 1)
return Limits(**values)
def percent_used(used: int, quota: int) -> int:
if quota <= 0:
return 0
return min(100, int(used * 100 / quota))

View File

@ -0,0 +1,154 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from datetime import datetime, timezone
from devplacepy.database import db, get_table
from devplacepy.utils import generate_uid
from . import naming
TUNNELS_TABLE = "tunnels"
STATUS_PENDING = "pending"
STATUS_PROVISIONING = "provisioning"
STATUS_ACTIVE = "active"
STATUS_FAILED = "failed"
STATUS_SUSPENDED = "suspended"
SERVING_STATUSES = (STATUS_PROVISIONING, STATUS_ACTIVE)
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _table():
return get_table(TUNNELS_TABLE)
def list_for_instance(instance_uid: str) -> list[dict]:
return list(
_table().find(
instance_uid=instance_uid, deleted_at=None, order_by=["created_at"]
)
)
def list_for_user(user_uid: str) -> list[dict]:
return list(
_table().find(user_uid=user_uid, deleted_at=None, order_by=["-created_at"])
)
def get(uid: str) -> dict | None:
return _table().find_one(uid=uid, deleted_at=None)
def by_hostname(hostname: str) -> dict | None:
if not hostname:
return None
bare = hostname.split(":", 1)[0].lower().rstrip(".")
return _table().find_one(hostname=bare, deleted_at=None)
def count_for_instance(instance_uid: str) -> int:
return _table().count(instance_uid=instance_uid, deleted_at=None)
def create(
instance: dict, label: str, container_port: int, user_uid: str
) -> dict | None:
name = instance.get("tunnel_name", "")
if not name or container_port <= 0:
return None
hostname = naming.hostname_for(name, container_port)
table = _table()
revived = table.find_one(hostname=hostname)
stamp = _now()
if revived:
table.update(
{
"uid": revived["uid"],
"instance_uid": instance["uid"],
"project_uid": instance.get("project_uid", ""),
"user_uid": user_uid,
"label": label or f"port {container_port}",
"container_port": container_port,
"desired_state": "present",
"status": STATUS_PENDING,
"last_error": "",
"updated_at": stamp,
"deleted_at": None,
"deleted_by": None,
},
["uid"],
)
return table.find_one(uid=revived["uid"])
uid = generate_uid()
table.insert(
{
"uid": uid,
"instance_uid": instance["uid"],
"project_uid": instance.get("project_uid", ""),
"user_uid": user_uid,
"hostname": hostname,
"label": label or f"port {container_port}",
"container_port": container_port,
"desired_state": "present",
"status": STATUS_PENDING,
"cert_status": "",
"cert_checked_at": "",
"request_count": 0,
"bytes_out": 0,
"last_request_at": "",
"last_error": "",
"last_synced_at": "",
"created_at": stamp,
"updated_at": stamp,
"deleted_at": None,
"deleted_by": None,
}
)
return table.find_one(uid=uid)
def update(uid: str, changes: dict) -> None:
_table().update({"uid": uid, "updated_at": _now(), **changes}, ["uid"])
def mark_absent(uid: str, actor_uid: str = "system") -> None:
update(uid, {"desired_state": "absent"})
def soft_delete(uid: str, actor_uid: str = "system") -> None:
_table().update(
{"uid": uid, "deleted_at": _now(), "deleted_by": actor_uid}, ["uid"]
)
def suspend_for_instance(instance_uid: str) -> None:
for row in list_for_instance(instance_uid):
update(row["uid"], {"status": STATUS_SUSPENDED})
def resume_for_instance(instance_uid: str) -> None:
for row in list_for_instance(instance_uid):
if row.get("status") == STATUS_SUSPENDED:
update(row["uid"], {"status": STATUS_PENDING})
def record_hit(uid: str, byte_count: int) -> None:
from sqlalchemy import text
sql = (
"UPDATE tunnels SET request_count = COALESCE(request_count, 0) + 1, "
"bytes_out = COALESCE(bytes_out, 0) + :bytes, last_request_at = :seen "
"WHERE uid = :uid AND deleted_at IS NULL"
)
with db:
db.executable.execute(
text(sql),
{"bytes": max(0, byte_count), "seen": _now(), "uid": uid},
)

View File

@ -0,0 +1,334 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import os
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from devplacepy import config
from devplacepy.database import get_table
from devplacepy.services.base import BaseService, ConfigField
from devplacepy.services.containers import store
from devplacepy.services.containers.workspace import flags, quota, tunnels
DISK_SAMPLE_DEFAULT_MINUTES = 10
def _now() -> datetime:
return datetime.now(timezone.utc)
def _parse(value: str | None) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except (TypeError, ValueError):
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _minutes_since(value: str | None) -> float | None:
parsed = _parse(value)
if parsed is None:
return None
return (_now() - parsed).total_seconds() / 60.0
def _directory_size(path: Path) -> int:
total = 0
stack = [path]
while stack:
current = stack.pop()
try:
with os.scandir(current) as entries:
for entry in entries:
try:
if entry.is_dir(follow_symlinks=False):
stack.append(Path(entry.path))
elif entry.is_file(follow_symlinks=False):
total += entry.stat().st_size
except OSError:
continue
except OSError:
continue
return total
class WorkspaceService(BaseService):
title = "Dev Workspaces"
description = (
"Reconciles browser IDE workspaces: tunnel certificates, disk and egress "
"metering, abuse flags, idle and retention lifecycle, and purge sweeps."
)
default_enabled = False
min_interval = 5
METRICS_SECONDS = 30
config_fields = [
ConfigField("workspace_enabled", "Enabled", type="bool", default="0",
group="General", help="Master switch for the workspace feature."),
ConfigField("workspace_editor_version", "Editor version", default="",
group="General",
help="code-server release. Empty means the image default."),
ConfigField("workspace_extensions_gallery", "Extensions gallery", type="text",
default="", group="General",
help="EXTENSIONS_GALLERY JSON. Empty means the default."),
ConfigField("workspace_molohttp_base_url", "molohttp base URL", type="url",
default="https://pravda.education/molohttp", group="molohttp"),
ConfigField("workspace_molohttp_api_key", "molohttp API key", type="password",
default="", group="molohttp", secret=True,
help="Preferred credential. Sent as the x-api-key header."),
ConfigField("workspace_molohttp_username", "molohttp username", default="",
group="molohttp"),
ConfigField("workspace_molohttp_password", "molohttp password",
type="password", default="", group="molohttp", secret=True),
ConfigField("workspace_tunnel_domain", "Tunnel domain",
default=config.WORKSPACE_TUNNEL_DOMAIN, group="Tunnels"),
ConfigField("workspace_hostname_pattern", "Hostname pattern",
default="{name}.{domain}", group="Tunnels"),
ConfigField("workspace_port_hostname_pattern", "Port hostname pattern",
default="{port}-{name}.{domain}", group="Tunnels"),
ConfigField("workspace_cert_mode", "Certificate mode", type="select",
default="per_host", options=[{"value": "per_host", "label": "Per host"}, {"value": "wildcard", "label": "Wildcard"}],
group="Tunnels"),
ConfigField("workspace_acme_email", "ACME email", default="", group="Tunnels"),
ConfigField("workspace_max_per_user", "Max workspaces per user", type="int",
default="2", minimum=0, group="Quotas"),
ConfigField("workspace_max_tunnels", "Max tunnels per workspace", type="int",
default="5", minimum=0, group="Quotas"),
ConfigField("workspace_disk_quota_mb", "Disk quota (MB)", type="int",
default="2048", minimum=0, group="Quotas"),
ConfigField("workspace_egress_quota_mb", "Egress quota (MB)", type="int",
default="10240", minimum=0, group="Quotas"),
ConfigField("workspace_disk_warn_percent", "Disk warn percent", type="int",
default="80", minimum=1, maximum=100, group="Quotas"),
ConfigField("workspace_idle_warn_minutes", "Idle warn (minutes)", type="int",
default="45", minimum=1, group="Lifecycle"),
ConfigField("workspace_idle_stop_minutes", "Idle stop (minutes)", type="int",
default="60", minimum=1, group="Lifecycle"),
ConfigField("workspace_retention_days", "Retention (days)", type="int",
default="14", minimum=1, group="Lifecycle"),
ConfigField("workspace_purge_after_days", "Purge after (days)", type="int",
default="7", minimum=1, group="Lifecycle"),
ConfigField("workspace_auto_stop", "Auto stop", type="select", default="auto",
options=[{"value": "off", "label": "Off"}, {"value": "notify", "label": "Notify"}, {"value": "auto", "label": "Auto"}], group="Automation"),
ConfigField("workspace_auto_delete", "Auto delete", type="select",
default="auto", options=[{"value": "off", "label": "Off"}, {"value": "notify", "label": "Notify"}, {"value": "auto", "label": "Auto"}],
group="Automation"),
ConfigField("workspace_auto_suspend", "Auto suspend", type="select",
default="notify", options=[{"value": "off", "label": "Off"}, {"value": "notify", "label": "Notify"}, {"value": "auto", "label": "Auto"}],
group="Automation"),
ConfigField("workspace_auto_flag", "Auto flag", type="select", default="auto",
options=[{"value": "off", "label": "Off"}, {"value": "flag", "label": "Flag"}, {"value": "auto", "label": "Auto"}], group="Automation"),
ConfigField("workspace_flag_cpu_percent", "Flag CPU percent", type="int",
default="95", minimum=1, maximum=100, group="Abuse"),
ConfigField("workspace_flag_cpu_minutes", "Flag CPU minutes", type="int",
default="120", minimum=1, group="Abuse"),
ConfigField("workspace_flag_egress_mb_per_hour", "Flag egress MB/hour",
type="int", default="2048", minimum=1, group="Abuse"),
ConfigField("workspace_flag_request_rate", "Flag request rate", type="int",
default="6000", minimum=1, group="Abuse"),
ConfigField("workspace_disk_sample_minutes", "Disk sample (minutes)",
type="int", default=str(DISK_SAMPLE_DEFAULT_MINUTES), minimum=1,
group="Advanced"),
]
def __init__(self) -> None:
super().__init__("workspace", interval_seconds=30)
self._last_disk_sample = 0.0
def _workspaces(self) -> list[dict]:
table = get_table("instances")
return list(table.find(is_workspace=1, deleted_at=None))
async def run_once(self) -> None:
cfg = self.get_config()
if cfg.get("workspace_enabled") not in (True, "1", 1):
return
rows = self._workspaces()
for phase in (
self._sample_disk,
self._evaluate_flags,
self._advance_lifecycle,
self._sweep_purge,
):
try:
phase(rows, cfg)
except Exception as error:
self.log(f"{phase.__name__} failed: {error}")
def _sample_disk(self, rows: list[dict], cfg: dict) -> None:
interval = int(cfg.get("workspace_disk_sample_minutes") or
DISK_SAMPLE_DEFAULT_MINUTES) * 60
if time.monotonic() - self._last_disk_sample < interval:
return
self._last_disk_sample = time.monotonic()
for row in rows:
workspace = config.CONTAINER_WORKSPACES_DIR / row.get("project_uid", "")
state = config.WORKSPACE_STATE_DIR / row["uid"]
total = _directory_size(workspace) + _directory_size(state)
store.update_instance(
row["uid"],
{"disk_bytes": total, "disk_sampled_at": _now().isoformat()},
)
row["disk_bytes"] = total
def _evaluate_flags(self, rows: list[dict], cfg: dict) -> None:
mode = cfg.get("workspace_auto_flag") or "auto"
if mode == "off":
return
egress_ceiling = int(cfg.get("workspace_flag_egress_mb_per_hour") or 2048)
egress_bytes = egress_ceiling * 1024 * 1024
requests_ceiling = int(cfg.get("workspace_flag_request_rate") or 6000)
for row in rows:
limits = quota.resolve(row.get("workspace_owner_uid", ""), row)
used = int(row.get("egress_bytes") or 0)
if used > egress_bytes:
flags.raise_flag(
row,
flags.KIND_EGRESS,
"warn",
f"egress {used} bytes exceeds hourly ceiling {egress_bytes}",
float(used),
float(egress_bytes),
)
if int(row.get("request_count") or 0) > requests_ceiling:
flags.raise_flag(
row,
flags.KIND_REQUESTS,
"warn",
"request rate above configured ceiling",
float(row.get("request_count") or 0),
float(requests_ceiling),
)
disk_quota = limits.disk_quota_bytes()
if disk_quota and int(row.get("disk_bytes") or 0) > disk_quota:
flags.raise_flag(
row,
flags.KIND_DISK,
"warn",
"disk usage above quota",
float(row.get("disk_bytes") or 0),
float(disk_quota),
)
def _advance_lifecycle(self, rows: list[dict], cfg: dict) -> None:
from devplacepy.utils import create_notification
stop_mode = cfg.get("workspace_auto_stop") or "auto"
delete_mode = cfg.get("workspace_auto_delete") or "auto"
for row in rows:
if row.get("suspended_at"):
continue
limits = quota.resolve(row.get("workspace_owner_uid", ""), row)
owner = row.get("workspace_owner_uid", "")
idle = _minutes_since(row.get("last_active_at"))
if idle is None:
continue
running = row.get("status") == "running"
if running and stop_mode != "off":
if idle >= limits.idle_stop_minutes:
if stop_mode == "auto":
store.update_instance(
row["uid"], {"desired_state": "stopped"}
)
if owner:
create_notification(
owner,
"workspace",
f"Workspace {row.get('name', '')} stopped after "
f"{limits.idle_stop_minutes} minutes idle.",
row["uid"],
f"/projects/{row.get('project_uid', '')}/workspace",
)
store.update_instance(row["uid"], {"idle_warned_at": ""})
elif idle >= limits.idle_warn_minutes and not row.get("idle_warned_at"):
store.update_instance(
row["uid"], {"idle_warned_at": _now().isoformat()}
)
if owner:
remaining = int(limits.idle_stop_minutes - idle)
create_notification(
owner,
"workspace",
f"Workspace {row.get('name', '')} stops in about "
f"{remaining} minutes unless you use it.",
row["uid"],
f"/projects/{row.get('project_uid', '')}/workspace",
)
if delete_mode == "off" or running:
continue
idle_days = idle / (60 * 24)
warn_at = max(1, limits.retention_days - 3)
if idle_days >= limits.retention_days:
if delete_mode == "auto":
store.delete_instance(row["uid"], "system")
tunnels.suspend_for_instance(row["uid"])
if owner:
create_notification(
owner,
"workspace",
f"Workspace {row.get('name', '')} was removed after "
f"{limits.retention_days} days idle. An administrator can "
"restore it from Trash.",
row["uid"],
"/projects",
)
elif idle_days >= warn_at and not row.get("delete_warned_at"):
store.update_instance(
row["uid"], {"delete_warned_at": _now().isoformat()}
)
if owner:
due = _now() + timedelta(days=limits.retention_days - idle_days)
create_notification(
owner,
"workspace",
f"Workspace {row.get('name', '')} is scheduled for deletion "
f"on {due.strftime('%d/%m/%Y')} unless you use it.",
row["uid"],
f"/projects/{row.get('project_uid', '')}/workspace",
)
def _sweep_purge(self, rows: list[dict], cfg: dict) -> None:
limits = quota.resolve()
cutoff = _now() - timedelta(days=limits.purge_after_days)
table = get_table("instances")
for row in table.find(is_workspace=1):
deleted = _parse(row.get("deleted_at"))
if not deleted or deleted > cutoff:
continue
for tunnel in tunnels.list_for_instance(row["uid"]):
tunnels.soft_delete(tunnel["uid"], "system")
def collect_metrics(self) -> dict:
rows = self._workspaces()
running = [r for r in rows if r.get("status") == "running"]
suspended = [r for r in rows if r.get("suspended_at")]
disk = sum(int(r.get("disk_bytes") or 0) for r in rows)
egress = sum(int(r.get("egress_bytes") or 0) for r in rows)
open_flags = flags.list_flags()
tunnel_rows = list(get_table("tunnels").find(deleted_at=None))
by_status: dict[str, int] = {}
for row in tunnel_rows:
key = row.get("status") or "unknown"
by_status[key] = by_status.get(key, 0) + 1
return {
"stats": [
{"label": "Workspaces", "value": len(rows)},
{"label": "Running", "value": len(running)},
{"label": "Suspended", "value": len(suspended)},
{"label": "Disk MB", "value": disk // (1024 * 1024)},
{"label": "Egress MB", "value": egress // (1024 * 1024)},
{"label": "Open flags", "value": len(open_flags)},
{"label": "Tunnels", "value": len(tunnel_rows)},
],
"table": {
"columns": ["Tunnel status", "Count"],
"rows": [[key, value] for key, value in sorted(by_status.items())],
},
}

View File

@ -32,6 +32,11 @@ from .spec import Action, Catalog
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
CONFIRM_REQUIRED = {
"workspace_stop",
"workspace_delete",
"tunnel_delete",
"workspace_flag_resolve",
"workspace_suspend",
"project_set_private",
"project_set_readonly",
"customize_set_css",
@ -300,8 +305,10 @@ class Dispatcher:
self._chunks = ChunkController(settings)
self._rsearch = RsearchController(settings, owner_kind, owner_id)
from ..container import ContainerController
from ..workspace import WorkspaceController
self._container = ContainerController(client, owner_id=owner_id)
self._workspace = WorkspaceController(owner_kind, owner_id, admin=is_admin)
from ..customization import CustomizationController
self._customization = CustomizationController(owner_kind, owner_id)
@ -493,6 +500,9 @@ class Dispatcher:
if action.handler == "container":
return await self._container.dispatch(action.name, arguments)
if action.handler == "workspace":
return await self._workspace.dispatch(action.name, arguments)
if action.handler == "customization":
return await self._customization.dispatch(action.name, arguments)

View File

@ -44,6 +44,7 @@ class Action:
"chunks",
"rsearch",
"container",
"workspace",
"customization",
"notification",
"behavior",

View File

@ -0,0 +1,212 @@
# retoor <retoor@molodetz.nl>
from .spec import Action, Param
def arg(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
SLUG = arg("project_slug", "Project slug or uid that owns the workspace.", required=True)
CONFIRM = arg(
"confirm",
"Set true only after showing the exact target to the user and getting agreement.",
kind="boolean",
)
WORKSPACE_ACTIONS: tuple[Action, ...] = (
Action(
name="workspace_open",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
summary=(
"Open or resume the browser VS Code workspace for a project. "
"Creates one if the user has none, otherwise resumes the existing one."
),
params=(SLUG,),
),
Action(
name="workspace_status",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
read_only=True,
summary=(
"Read a workspace's state, disk and egress usage against quota, idle "
"countdown, tunnels and any open moderation flags."
),
params=(SLUG,),
),
Action(
name="workspace_list",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
read_only=True,
summary="List the caller's workspaces. Administrators see every workspace.",
params=(),
),
Action(
name="workspace_stop",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
summary="Stop a running workspace. The files and tunnels are kept.",
params=(SLUG, CONFIRM),
),
Action(
name="workspace_delete",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
summary="Delete a workspace. Removable from admin Trash afterwards.",
params=(SLUG, CONFIRM),
),
Action(
name="tunnel_list",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
read_only=True,
summary="List the public HTTPS tunnels published by a workspace.",
params=(SLUG,),
),
Action(
name="tunnel_create",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
summary=(
"Publish a container port on a public HTTPS hostname. The resulting URL "
"is PUBLIC and unauthenticated - warn the user before creating one."
),
params=(
SLUG,
arg("container_port", "Port inside the container.", required=True, kind="integer"),
arg("label", "Human label for the tunnel."),
),
),
Action(
name="tunnel_delete",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
summary="Remove a public tunnel. The URL stops serving immediately.",
params=(SLUG, arg("tunnel_uid", "Tunnel uid.", required=True), CONFIRM),
),
Action(
name="workspace_quota_get",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
read_only=True,
summary=(
"Read the effective workspace limits for the caller, or for any user "
"when the caller is an administrator."
),
params=(arg("username", "Administrators only: whose quota to read."),),
),
Action(
name="workspace_quota_set",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
requires_admin=True,
summary="Set a per-user workspace quota rule. Zero means inherit the global value.",
params=(
arg("username", "User to apply the rule to.", required=True),
arg("max_workspaces", "Workspace count limit.", kind="integer"),
arg("max_tunnels", "Tunnel count limit.", kind="integer"),
arg("disk_quota_mb", "Disk quota in MB.", kind="integer"),
arg("egress_quota_mb", "Egress quota in MB.", kind="integer"),
arg("idle_stop_minutes", "Idle stop window in minutes.", kind="integer"),
arg("retention_days", "Retention in days.", kind="integer"),
),
),
Action(
name="workspace_flag_list",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
read_only=True,
summary=(
"List moderation flags. A member sees only flags on their own "
"workspaces; an administrator sees every flag."
),
params=(arg("status", "Filter by status: open, resolved, dismissed."),),
),
Action(
name="workspace_flag_raise",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
requires_admin=True,
summary="Raise a moderation flag against a workspace.",
params=(
SLUG,
arg("username", "Owner of the workspace.", required=True),
arg("severity", "info, warn or critical."),
arg("detail", "Why the flag was raised."),
),
),
Action(
name="workspace_flag_resolve",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
requires_admin=True,
summary="Resolve or dismiss a moderation flag.",
params=(
arg("flag_uid", "Flag uid.", required=True),
arg("status", "resolved or dismissed."),
CONFIRM,
),
),
Action(
name="workspace_suspend",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
requires_admin=True,
summary="Suspend a workspace. It stops serving; no data is destroyed.",
params=(
SLUG,
arg("username", "Owner of the workspace.", required=True),
arg("reason", "Reason shown to the owner.", required=True),
CONFIRM,
),
),
Action(
name="workspace_unsuspend",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
requires_admin=True,
summary="Lift a suspension and let the workspace serve again.",
params=(SLUG, arg("username", "Owner of the workspace.", required=True)),
),
)

View File

@ -19,6 +19,7 @@ from .actions.notification_actions import NOTIFICATION_ACTIONS
from .actions.rsearch_actions import RSEARCH_ACTIONS
from .actions.spec import Catalog
from .actions.telegram_actions import TELEGRAM_ACTIONS
from .actions.workspace_actions import WORKSPACE_ACTIONS
from .interaction.actions import INTERACTION_ACTIONS
from .virtual_tools.actions import VIRTUAL_TOOL_ACTIONS
from .agentic.actions import AGENTIC_ACTIONS
@ -36,6 +37,7 @@ CATALOG = Catalog(
+ CHUNK_ACTIONS
+ RSEARCH_ACTIONS
+ CONTAINER_ACTIONS
+ WORKSPACE_ACTIONS
+ CUSTOMIZATION_ACTIONS
+ BEHAVIOR_ACTIONS
+ NOTIFICATION_ACTIONS

View File

@ -0,0 +1,5 @@
# retoor <retoor@molodetz.nl>
from .controller import WorkspaceController
__all__ = ["WorkspaceController"]

View File

@ -0,0 +1,243 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Any
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.services.containers import store
from devplacepy.services.containers.workspace import (
flags,
provision,
quota,
tunnels,
)
from devplacepy.services.containers.workspace.provision import WorkspaceError
from devplacepy.utils import generate_uid
class WorkspaceController:
def __init__(self, owner_kind: str, owner_id: str, admin: bool = False) -> None:
self.owner_kind = owner_kind
self.owner_id = owner_id
self.admin = admin
def _user(self) -> dict | None:
if self.owner_kind != "user" or not self.owner_id:
return None
return get_table("users").find_one(uid=self.owner_id)
def _user_by_name(self, username: str) -> dict | None:
if not username:
return None
return get_table("users").find_one(username=username)
def _project(self, slug: str) -> dict | None:
return resolve_by_slug(get_table("projects"), slug)
def _resolve(self, slug: str, owner_uid: str = "") -> dict:
project = self._project(slug)
if not project:
raise WorkspaceError(f"project not found: {slug}")
target = owner_uid or self.owner_id
instance = provision.find_for_project(project["uid"], target)
if not instance:
raise WorkspaceError("no workspace exists for this project")
return instance
async def dispatch(self, name: str, args: dict[str, Any]) -> Any:
handler = getattr(self, f"_{name}", None)
if handler is None:
return {"error": f"unknown workspace action: {name}"}
try:
result = handler(args)
if hasattr(result, "__await__"):
return await result
return result
except WorkspaceError as error:
return {"error": str(error)}
async def _workspace_open(self, args: dict) -> Any:
user = self._user()
if not user:
raise WorkspaceError("sign in to use workspaces")
project = self._project(args.get("project_slug", ""))
if not project:
raise WorkspaceError("project not found")
instance = await provision.ensure(project, user)
instance = provision.resume(instance)
provision.write_manifest(instance)
view = provision.view(instance)
view["editor_url"] = (
f"/projects/{project.get('slug') or project['uid']}/workspace"
)
return view
def _workspace_status(self, args: dict) -> Any:
return provision.view(self._resolve(args.get("project_slug", "")))
def _workspace_list(self, args: dict) -> Any:
table = get_table("instances")
filters: dict[str, Any] = {"is_workspace": 1, "deleted_at": None}
if not self.admin:
filters["workspace_owner_uid"] = self.owner_id
return {
"workspaces": [provision.view(row) for row in table.find(**filters)]
}
def _workspace_stop(self, args: dict) -> Any:
instance = self._resolve(args.get("project_slug", ""))
provision.stop(instance)
return {"ok": True, "status": "stopping", "uid": instance["uid"]}
def _workspace_delete(self, args: dict) -> Any:
instance = self._resolve(args.get("project_slug", ""))
for row in tunnels.list_for_instance(instance["uid"]):
tunnels.soft_delete(row["uid"], self.owner_id)
store.delete_instance(instance["uid"], self.owner_id)
return {"ok": True, "deleted": instance["uid"]}
def _tunnel_list(self, args: dict) -> Any:
instance = self._resolve(args.get("project_slug", ""))
return {"tunnels": tunnels.list_for_instance(instance["uid"])}
def _tunnel_create(self, args: dict) -> Any:
instance = self._resolve(args.get("project_slug", ""))
port = int(args.get("container_port") or 0)
if port <= 0:
raise WorkspaceError("container_port is required")
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
if limits.max_tunnels and tunnels.count_for_instance(
instance["uid"]
) >= limits.max_tunnels:
raise WorkspaceError(f"tunnel limit reached ({limits.max_tunnels})")
row = tunnels.create(
instance, args.get("label", ""), port, self.owner_id
)
if not row:
raise WorkspaceError("could not create tunnel")
provision.write_manifest(instance)
return {
"ok": True,
"tunnel": row,
"url": f"https://{row['hostname']}",
"public": True,
}
def _tunnel_delete(self, args: dict) -> Any:
instance = self._resolve(args.get("project_slug", ""))
uid = args.get("tunnel_uid", "")
row = tunnels.get(uid)
if not row or row.get("instance_uid") != instance["uid"]:
raise WorkspaceError("tunnel not found on this workspace")
tunnels.soft_delete(uid, self.owner_id)
provision.write_manifest(instance)
return {"ok": True, "deleted": uid}
def _workspace_quota_get(self, args: dict) -> Any:
target = self.owner_id
username = args.get("username", "")
if username:
if not self.admin:
raise WorkspaceError("only administrators may read another user's quota")
other = self._user_by_name(username)
if not other:
raise WorkspaceError(f"user not found: {username}")
target = other["uid"]
limits = quota.resolve(target)
return {
"max_workspaces": limits.max_workspaces,
"max_tunnels": limits.max_tunnels,
"disk_quota_mb": limits.disk_quota_mb,
"egress_quota_mb": limits.egress_quota_mb,
"idle_stop_minutes": limits.idle_stop_minutes,
"retention_days": limits.retention_days,
"used_workspaces": provision.count_for_owner(target),
}
def _workspace_quota_set(self, args: dict) -> Any:
if not self.admin:
raise WorkspaceError("administrators only")
other = self._user_by_name(args.get("username", ""))
if not other:
raise WorkspaceError("user not found")
table = get_table(quota.RULES_TABLE)
existing = table.find_one(
owner_kind="user", owner_id=other["uid"], deleted_at=None
)
payload = {
key: int(args.get(key) or 0)
for key in quota.RULE_COLUMNS
if args.get(key) is not None
}
if existing:
table.update({"uid": existing["uid"], **payload}, ["uid"])
uid = existing["uid"]
else:
uid = generate_uid()
table.insert(
{
"uid": uid,
"owner_kind": "user",
"owner_id": other["uid"],
"label": args.get("label", "") or other.get("username", ""),
"created_at": "",
"updated_at": "",
"deleted_at": None,
"deleted_by": None,
**payload,
}
)
return {"ok": True, "rule_uid": uid, "applied": payload}
def _workspace_flag_list(self, args: dict) -> Any:
status = args.get("status", "open")
if self.admin:
return {"flags": flags.list_flags(status=status)}
return {"flags": flags.list_flags(user_uid=self.owner_id, status=status)}
def _workspace_flag_raise(self, args: dict) -> Any:
if not self.admin:
raise WorkspaceError("administrators only")
owner = self._user_by_name(args.get("username", ""))
if not owner:
raise WorkspaceError("user not found")
instance = self._resolve(args.get("project_slug", ""), owner["uid"])
row = flags.raise_flag(
instance,
flags.KIND_MANUAL,
args.get("severity", "warn"),
args.get("detail", ""),
)
return {"ok": True, "flag": row}
def _workspace_flag_resolve(self, args: dict) -> Any:
if not self.admin:
raise WorkspaceError("administrators only")
status = args.get("status", "resolved")
if not flags.set_status(args.get("flag_uid", ""), status, self.owner_id):
raise WorkspaceError("flag not found or invalid status")
return {"ok": True, "status": status}
def _workspace_suspend(self, args: dict) -> Any:
if not self.admin:
raise WorkspaceError("administrators only")
owner = self._user_by_name(args.get("username", ""))
if not owner:
raise WorkspaceError("user not found")
instance = self._resolve(args.get("project_slug", ""), owner["uid"])
reason = args.get("reason", "")
if not reason:
raise WorkspaceError("a reason is required and is shown to the owner")
provision.suspend(instance, self.owner_id, reason)
return {"ok": True, "suspended": instance["uid"], "reason": reason}
def _workspace_unsuspend(self, args: dict) -> Any:
if not self.admin:
raise WorkspaceError("administrators only")
owner = self._user_by_name(args.get("username", ""))
if not owner:
raise WorkspaceError("user not found")
instance = self._resolve(args.get("project_slug", ""), owner["uid"])
provision.unsuspend(instance)
return {"ok": True, "resumed": instance["uid"]}

View File

@ -5,7 +5,7 @@ from datetime import datetime, timezone
from typing import Optional
from devplacepy.config import SECONDS_PER_DAY
from devplacepy.database import get_table, get_int_setting
from devplacepy.database import get_table, get_int_setting, is_account_active
from devplacepy.utils import generate_uid
from devplacepy.services.devrant.ids import as_int, now_unix
@ -53,7 +53,7 @@ def resolve_user(params: dict) -> Optional[dict]:
if as_int(token.get("expire_time")) < now_unix():
return None
user = get_table("users").find_one(uid=token.get("user_uid"))
if not user or not user.get("is_active", True):
if not user or not is_account_active(user):
return None
return user
@ -73,7 +73,7 @@ def resolve_user_by_key(key: str) -> Optional[dict]:
if expire and expire < now_unix():
return None
user = get_table("users").find_one(uid=token.get("user_uid"))
if not user or not user.get("is_active", True):
if not user or not is_account_active(user):
return None
return user

View File

@ -0,0 +1,118 @@
/* retoor <retoor@molodetz.nl> */
.workspace-page {
max-width: var(--content-width);
margin: 0 auto;
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.workspace-title {
color: var(--text-primary);
margin: 0;
}
.workspace-muted {
color: var(--text-secondary);
}
.workspace-state {
font-weight: 600;
color: var(--text-primary);
text-transform: capitalize;
}
.workspace-meters {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
margin: var(--space-sm) 0;
}
.workspace-meter {
display: flex;
align-items: center;
gap: var(--space-xs);
color: var(--text-secondary);
}
.workspace-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
align-items: center;
}
.workspace-tunnel-list {
list-style: none;
padding: 0;
margin: 0 0 var(--space-sm) 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.workspace-tunnel {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
}
.workspace-badge {
background: var(--bg-hover);
color: var(--text-secondary);
border-radius: var(--radius);
padding: 0 var(--space-xs);
font-size: 0.85em;
}
.workspace-badge-active {
background: var(--success);
color: var(--bg-card);
}
.workspace-badge-failed {
background: var(--danger);
color: var(--bg-card);
}
.workspace-badge-provisioning,
.workspace-badge-pending {
background: var(--warning);
color: var(--bg-card);
}
.workspace-error {
color: var(--danger);
}
.workspace-tunnel-form {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.workspace-suspended {
border-left: 3px solid var(--danger);
}
.workspace-flag-critical {
border-left: 3px solid var(--danger);
}
.workspace-flag-warn {
border-left: 3px solid var(--warning);
}
.workspace-flag-info {
border-left: 3px solid var(--text-secondary);
}
@media (max-width: 768px) {
.workspace-meters {
flex-direction: column;
}
}

View File

@ -0,0 +1,63 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
export class WorkspaceManager {
constructor(root) {
this.root = root;
this.slug = root.dataset.slug;
this.poller = null;
this.bind();
this.subscribe();
}
static mount() {
const root = document.querySelector("[data-workspace-root]");
return root ? new WorkspaceManager(root) : null;
}
bind() {
this.root.addEventListener("submit", (event) => {
const form = event.target.closest("form");
if (!form || form.dataset.confirm) return;
event.preventDefault();
this.send(form);
});
}
async send(form) {
try {
await Http.sendForm(form);
await this.refresh();
} catch (error) {
window.app?.toast?.show(error.message || "Action failed", { type: "error" });
}
}
subscribe() {
const uid = this.root.dataset.workspaceUid;
if (uid && window.app?.pubsub) {
window.app.pubsub.subscribe(`workspace.${uid}.detail`, () => this.render());
}
this.poller = new Poller(() => this.refresh(), 20000);
this.poller.start();
}
async refresh() {
try {
const data = await Http.getJson(`/projects/${this.slug}/workspace`);
this.render(data);
} catch (error) {
return;
}
}
render(data) {
if (!data || !data.workspace) return;
const state = this.root.querySelector("[data-workspace-status]");
if (state) state.textContent = data.workspace.status || "";
}
}
export default WorkspaceManager;

View File

@ -29,6 +29,9 @@
<a href="/admin/containers" class="sidebar-link {% if admin_section == 'containers' %}active{% endif %}">
<span class="sidebar-icon">&#x1F4E6;</span> Containers
</a>
<a href="/admin/workspaces" class="sidebar-link {% if admin_section == 'workspaces' %}active{% endif %}">
<span class="sidebar-icon">&#x1F4BB;</span> Workspaces
</a>
<a href="/admin/devii-tasks" class="sidebar-link {% if admin_section == 'devii-tasks' %}active{% endif %}">
<span class="sidebar-icon">&#x23F0;</span> Devii tasks
</a>

View File

@ -0,0 +1,114 @@
{% extends "admin_base.html" %}
{% block extra_head %}
{{ super() }}
<link rel="stylesheet" href="{{ static_url('/static/css/workspace.css') }}">
{% endblock %}
{% block admin_content %}
<div class="admin-toolbar">
<h2>Workspaces</h2>
<span class="admin-count">{{ workspaces|length }} workspaces</span>
<span class="admin-count">{{ flags|length }} open flags</span>
</div>
<div class="admin-table-wrap">
<table class="admin-table" id="ws-admin-list" data-endpoint="/admin/workspaces/data">
<caption class="sr-only">Dev workspaces</caption>
<thead>
<tr>
<th>Name</th>
<th>Owner</th>
<th>Project</th>
<th>Status</th>
<th>Disk</th>
<th>Egress</th>
<th>Tunnels</th>
<th>Flags</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for ws in workspaces %}
<tr data-workspace-uid="{{ ws.uid }}">
<td>{{ ws.name }}</td>
<td>{{ ws.owner_username }}</td>
<td>{{ ws.project_title }}</td>
<td>
{% if ws.suspended %}
<span class="workspace-badge workspace-badge-failed">suspended</span>
{% else %}
<span class="workspace-badge">{{ ws.status }}</span>
{% endif %}
</td>
<td>{{ ws.disk_percent }}% of {{ ws.disk_quota_mb }} MB</td>
<td>{{ ws.egress_percent }}% of {{ ws.egress_quota_mb }} MB</td>
<td>{{ ws.tunnels|length }}</td>
<td>{{ ws.flags|length }}</td>
<td class="admin-actions">
{% if ws.status == "running" %}
<form method="post" action="/admin/workspaces/{{ ws.uid }}/stop">
<button type="submit" class="admin-btn">Stop</button>
</form>
{% else %}
<form method="post" action="/admin/workspaces/{{ ws.uid }}/start">
<button type="submit" class="admin-btn">Start</button>
</form>
{% endif %}
{% if ws.suspended %}
<form method="post" action="/admin/workspaces/{{ ws.uid }}/unsuspend">
<button type="submit" class="admin-btn">Unsuspend</button>
</form>
{% else %}
<form method="post" action="/admin/workspaces/{{ ws.uid }}/suspend">
<input type="text" name="reason" placeholder="Reason" maxlength="500" required>
<button type="submit" class="admin-btn">Suspend</button>
</form>
{% endif %}
<form method="post" action="/admin/workspaces/{{ ws.uid }}/flag">
<input type="text" name="detail" placeholder="Flag detail" maxlength="500">
<button type="submit" class="admin-btn">Flag</button>
</form>
<form method="post" action="/admin/workspaces/{{ ws.uid }}/delete">
<button type="submit" data-confirm="Delete this workspace?" data-confirm-danger class="admin-btn admin-btn-danger">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="9">No workspaces yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="admin-toolbar">
<h2>Open flags</h2>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<caption class="sr-only">Open workspace flags</caption>
<thead>
<tr><th>Kind</th><th>Severity</th><th>Detail</th><th>Value</th><th>Threshold</th><th>Actions</th></tr>
</thead>
<tbody>
{% for flag in flags %}
<tr>
<td>{{ flag.kind }}</td>
<td>{{ flag.severity }}</td>
<td>{{ flag.detail }}</td>
<td>{{ flag.metric_value }}</td>
<td>{{ flag.threshold }}</td>
<td class="admin-actions">
<form method="post" action="/admin/workspaces/flags/{{ flag.uid }}/resolve?status=resolved">
<button type="submit" class="admin-btn">Resolve</button>
</form>
<form method="post" action="/admin/workspaces/flags/{{ flag.uid }}/resolve?status=dismissed">
<button type="submit" class="admin-btn">Dismiss</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6">No open flags.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('css/containers.css') }}">
<link rel="stylesheet" href="{{ static_url('css/workspace.css') }}">
{% endblock %}
{% block content %}
<div class="workspace-page" data-workspace-root
data-slug="{{ project.slug or project.uid }}"
data-has-workspace="{{ 1 if has_workspace else 0 }}">
<h1 class="workspace-title">Workspace: {{ project.title }}</h1>
{% if not has_workspace %}
<div class="card workspace-empty">
<p>No workspace yet for this project. Opening one starts a container with your
project files, a browser editor, and a terminal.</p>
<p class="workspace-muted">You are using {{ workspace_count }} of {{ max_workspaces }} workspaces.</p>
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace">
<button type="submit" class="btn btn-primary">Open workspace</button>
</form>
</div>
{% else %}
{% if workspace.suspended %}
<div class="card workspace-suspended">
<strong>This workspace is suspended.</strong>
<p>{{ workspace.flag_reason or "Contact an administrator." }}</p>
</div>
{% endif %}
{% for flag in workspace.flags %}
<div class="card workspace-flag workspace-flag-{{ flag.severity }}">
<strong>{{ flag.kind }}</strong>
<span>{{ flag.detail }}</span>
</div>
{% endfor %}
<div class="card workspace-summary">
<div class="workspace-state" data-workspace-status>{{ workspace.status }}</div>
<div class="workspace-meters">
<div class="workspace-meter">
<span>Disk</span>
<progress max="100" value="{{ workspace.disk_percent }}"></progress>
<span>{{ workspace.disk_percent }}% of {{ workspace.disk_quota_mb }} MB</span>
</div>
<div class="workspace-meter">
<span>Egress</span>
<progress max="100" value="{{ workspace.egress_percent }}"></progress>
<span>{{ workspace.egress_percent }}% of {{ workspace.egress_quota_mb }} MB</span>
</div>
</div>
<p class="workspace-muted">
Stops after {{ workspace.idle_stop_minutes }} minutes idle. Removed after
{{ workspace.retention_days }} days idle.
{% if workspace.last_active_at %}
Last active {{ dt_ago(workspace.last_active_at) }}.
{% endif %}
</p>
<div class="workspace-actions">
{% if workspace.status == "running" %}
<a class="btn btn-primary" href="{{ editor_url }}">Open editor</a>
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace/stop">
<button type="submit" class="btn">Stop</button>
</form>
{% else %}
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace">
<button type="submit" class="btn btn-primary">Start</button>
</form>
{% endif %}
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace/delete">
<button type="submit" data-confirm="Delete this workspace?" data-confirm-danger class="btn btn-danger">Delete</button>
</form>
</div>
</div>
<div class="card workspace-tunnels">
<h2>Public tunnels</h2>
<p class="workspace-muted">
A tunnel publishes a port from inside your container on a public HTTPS address.
Anyone with the link can reach it. You may have up to {{ workspace.max_tunnels }}.
</p>
<ul class="workspace-tunnel-list" data-tunnel-list>
{% for tunnel in workspace.tunnels %}
<li class="workspace-tunnel">
<a href="https://{{ tunnel.hostname }}" rel="noopener" target="_blank">{{ tunnel.hostname }}</a>
<span class="workspace-badge">port {{ tunnel.container_port }}</span>
<span class="workspace-badge workspace-badge-{{ tunnel.status }}">{{ tunnel.status }}</span>
{% if tunnel.last_error %}<span class="workspace-error">{{ tunnel.last_error }}</span>{% endif %}
<form method="post"
action="/projects/{{ project.slug or project.uid }}/workspace/tunnels/{{ tunnel.uid }}/delete">
<button type="submit" data-confirm="Remove this tunnel?" data-confirm-danger class="btn btn-small">Remove</button>
</form>
</li>
{% else %}
<li class="workspace-muted">No tunnels yet.</li>
{% endfor %}
</ul>
<form class="workspace-tunnel-form" method="post"
action="/projects/{{ project.slug or project.uid }}/workspace/tunnels">
<input type="text" name="label" placeholder="Label (optional)" maxlength="64">
<input type="number" name="container_port" placeholder="Port" min="1" max="65535" required>
<button type="submit" class="btn">Add tunnel</button>
</form>
</div>
<div class="card workspace-help">
<h2>Inside the container</h2>
<ul>
<li><code>sudo</code> and <code>apt install</code> work with no extra setup.</li>
<li>Python, Rust, Nim and Swift toolchains are preinstalled.</li>
<li>Ports below 1024 cannot bind. Use a high port and a tunnel.</li>
<li>Your public URLs are also in <code>/app/.devplace/tunnels.json</code>.</li>
</ul>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script type="module" src="{{ static_url('js/WorkspaceManager.js') }}"></script>
{% endblock %}

View File

@ -5,7 +5,7 @@ import binascii
import hashlib
from datetime import datetime, timezone
from fastapi import Request
from devplacepy.database import get_table
from devplacepy.database import get_table, is_account_active
from devplacepy.utils.authcache import _user_cache, _sync_auth_cache
from devplacepy.utils.passwords import verify_password
@ -32,7 +32,7 @@ def _user_from_session(request: Request):
return None
users = get_table("users")
user = users.find_one(uid=session["user_uid"])
if user and not user.get("is_active", True):
if user and not is_account_active(user):
sessions.delete(id=session["id"])
_user_cache.pop(token)
return None
@ -50,7 +50,7 @@ def _user_from_api_key(key: str):
if cached is not None:
return cached
user = get_table("users").find_one(api_key=key)
if not user or not user.get("is_active", True):
if not user or not is_account_active(user):
return None
_user_cache.set(cache_key, user)
return user
@ -76,7 +76,7 @@ def _user_from_basic(header: str):
user = users.find_one(username=identifier) or users.find_one(
email=identifier.lower()
)
if not user or not user.get("is_active", True):
if not user or not is_account_active(user):
return None
if not verify_password(password, user.get("password_hash", "")):
return None

View File

@ -197,6 +197,30 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `container.instance.status` | `services/containers/service.py` |
| `container.instance.sync` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
| `container.reconcile.action` | `services/containers/service.py` |
| `container.tunnel.cert.failure` | `services/containers/workspace_service.py` |
| `container.tunnel.cert.issue` | `services/containers/workspace_service.py` |
| `container.tunnel.create` | `routers/projects/containers/workspace.py` |
| `container.tunnel.delete` | `routers/projects/containers/workspace.py` |
| `container.tunnel.failure` | `services/containers/workspace_service.py` |
| `container.tunnel.suspend` | `services/containers/workspace_service.py` |
| `container.workspace.create` | `routers/projects/containers/workspace.py` |
| `container.workspace.delete` | `routers/projects/containers/workspace.py` |
| `container.workspace.flag.dismiss` | `routers/admin/workspaces.py` |
| `container.workspace.flag.raise` | `services/containers/workspace_service.py` |
| `container.workspace.flag.resolve` | `routers/admin/workspaces.py` |
| `container.workspace.idle.stop` | `services/containers/workspace_service.py` |
| `container.workspace.idle.warn` | `services/containers/workspace_service.py` |
| `container.workspace.open` | `routers/projects/containers/workspace.py` |
| `container.workspace.purge` | `services/containers/workspace_service.py` |
| `container.workspace.quota.block` | `routers/projects/containers/workspace.py` |
| `container.workspace.quota.warn` | `services/containers/workspace_service.py` |
| `container.workspace.restore` | `routers/admin/workspaces.py` |
| `container.workspace.resume` | `routers/projects/containers/workspace.py` |
| `container.workspace.retention.warn` | `services/containers/workspace_service.py` |
| `container.workspace.settings.update` | `routers/admin/workspaces.py` |
| `container.workspace.stop` | `routers/projects/containers/workspace.py` |
| `container.workspace.suspend` | `routers/admin/workspaces.py` |
| `container.workspace.unsuspend` | `routers/admin/workspaces.py` |
| `container.schedule.create` | `routers/projects/containers/schedules.py`, `services/devii/actions/dispatcher.py` |
| `container.schedule.delete` | `routers/projects/containers/schedules.py` |

View File

@ -14,9 +14,42 @@ ENV PYTHONUNBUFFERED=1 \
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl wget vim ack ca-certificates build-essential libpq-dev \
tmux apache2-utils procps htop iftop iotop netcat-openbsd zip unzip \
fakeroot \
fakeroot xz-utils pkg-config \
binutils gnupg2 libc6-dev libcurl4-openssl-dev libedit2 libedit-dev \
libncurses-dev libpython3-dev libsqlite3-0 libsqlite3-dev uuid-dev \
libxml2-dev libz3-dev tzdata zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
ENV RUSTUP_HOME=/opt/rust/rustup \
CARGO_HOME=/opt/rust/cargo \
CHOOSENIM_HOME=/opt/nim/choosenim \
CHOOSENIM_DIR=/opt/nim/toolchains \
NIMBLE_DIR=/opt/nim/nimble \
SWIFTLY_HOME_DIR=/opt/swift/swiftly \
SWIFTLY_BIN_DIR=/opt/swift/bin \
SWIFT_HOME=/opt/swift/toolchain
RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal \
&& rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git" \
&& "$CARGO_HOME/bin/rustc" --version
RUN curl -fsSL https://nim-lang.org/choosenim/init.sh | sh -s -- -y || true; \
rm -rf "$CHOOSENIM_HOME/downloads"; \
"$NIMBLE_DIR/bin/nim" --version | head -1
RUN set -eu; \
curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" -o /tmp/swiftly.tar.gz; \
mkdir -p /tmp/swiftly-unpack; \
tar -xzf /tmp/swiftly.tar.gz -C /tmp/swiftly-unpack; \
/tmp/swiftly-unpack/swiftly init --assume-yes --skip-install --no-modify-profile; \
"$SWIFTLY_BIN_DIR/swiftly" install latest --assume-yes; \
toolchain="$(find /root/.local/share/swiftly/toolchains -mindepth 1 -maxdepth 1 -type d | head -1)"; \
[ -n "$toolchain" ] || { echo "swift toolchain not found after install"; exit 1; }; \
mv "$toolchain" /opt/swift/toolchain; \
rm -rf /tmp/swiftly.tar.gz /tmp/swiftly-unpack /root/.local/share/swiftly \
"$SWIFTLY_HOME_DIR" "$SWIFTLY_BIN_DIR"; \
/opt/swift/toolchain/usr/bin/swift --version
FROM base AS deps
RUN pip install \
pip setuptools wheel packaging \
@ -68,6 +101,31 @@ RUN set -eu; \
done
USER pravda
ENV PATH=/home/pravda/.local/bin:$PATH
ENV PATH=/home/pravda/.local/bin:/opt/rust/cargo/bin:/opt/nim/nimble/bin:/opt/swift/toolchain/usr/bin:$PATH \
DEVPLACE_TOOLCHAINS=python,rust,nim,swift
WORKDIR /app
RUN printf '%s\n' \
'export RUSTUP_HOME=/opt/rust/rustup' \
'export CARGO_HOME=/opt/rust/cargo' \
'export NIMBLE_DIR=/opt/nim/nimble' \
'export SWIFT_HOME=/opt/swift/toolchain' \
'export DEVPLACE_TOOLCHAINS=python,rust,nim,swift' \
'export PATH=/home/pravda/.local/bin:/opt/rust/cargo/bin:/opt/nim/nimble/bin:/opt/swift/toolchain/usr/bin:$PATH' \
> /etc/profile.d/devplace-toolchains.sh \
&& chmod 0644 /etc/profile.d/devplace-toolchains.sh
RUN set -eu; \
for tool in "python --version" "rustc --version" "cargo --version" \
"nim --version" "nimble --version" "swift --version"; do \
$tool > /tmp/toolcheck 2>&1 || { echo "TOOLCHAIN FAILED: $tool"; cat /tmp/toolcheck; exit 1; }; \
head -1 /tmp/toolcheck; \
done; \
rm -f /tmp/toolcheck; \
for b in /usr/local/bin/sudo /usr/local/bin/aptroot /usr/bin/pagent.py \
/usr/bin/botje.py /usr/bin/d.py /usr/bin/dpc; do \
[ -x "$b" ] || { echo "missing or not executable: $b"; exit 1; }; \
done; \
[ -f /home/pravda/.vimrc ] || { echo "missing /home/pravda/.vimrc"; exit 1; }
CMD ["sleep", "infinity"]

View File

@ -181,3 +181,45 @@ def test_rate_limit_block_recorded(monkeypatch):
event_key="security.rate_limit.block", result="denied"
)
assert event is not None
def _member_with_null_is_active():
session, name = _member()
row = _db_user(name)
get_table("users").update({"uid": row["uid"], "is_active": None}, ["uid"])
refresh_snapshot()
return name, row
def test_login_works_when_is_active_was_never_written(seeded_db):
name, _row = _member_with_null_is_active()
response = requests.post(
f"{BASE_URL}/auth/login",
headers=JSON_audit_log,
data={"email": f"{name}@t.dev", "password": "secret123"},
allow_redirects=False,
)
assert response.status_code == 200, response.text[:300]
def test_api_key_works_when_is_active_was_never_written(seeded_db):
_name, row = _member_with_null_is_active()
response = requests.get(
f"{BASE_URL}/profile",
headers={**JSON_audit_log, "X-API-KEY": row["api_key"]},
allow_redirects=False,
)
assert response.status_code == 200, response.text[:300]
def test_an_explicitly_disabled_account_still_cannot_log_in(seeded_db):
session, name = _member()
row = _db_user(name)
get_table("users").update({"uid": row["uid"], "is_active": False}, ["uid"])
refresh_snapshot()
response = requests.get(
f"{BASE_URL}/profile",
headers={**JSON_audit_log, "X-API-KEY": row["api_key"]},
allow_redirects=False,
)
assert response.status_code == 401

View File

@ -0,0 +1,230 @@
# retoor <retoor@molodetz.nl>
import pytest
from devplacepy.content import can_manage_workspace, can_open_workspace
from devplacepy.database import get_table, init_db, set_setting
from devplacepy.services.containers import activity, store
from devplacepy.services.containers.workspace import (
flags,
naming,
provision,
quota,
tunnels,
)
from devplacepy.services.containers.workspace.provision import WorkspaceError
from tests.conftest import run_async
OWNER = "user-owner"
OTHER = "user-other"
@pytest.fixture(autouse=True)
def _workspace_db():
init_db()
set_setting("workspace_enabled", "1")
yield
for table in ("instances", "tunnels", "workspace_flags", "workspace_quota_rules"):
get_table(table).delete()
def _project(uid: str = "proj-ws") -> dict:
return {"uid": uid, "slug": "demo", "title": "Demo", "user_uid": OWNER}
def _instance(**overrides) -> dict:
row = {
"project_uid": "proj-ws",
"name": "ws-demo",
"status": "running",
"desired_state": "running",
"is_workspace": 1,
"workspace_owner_uid": OWNER,
"tunnel_name": naming.generate(),
"ports_json": '[{"host": 20500, "container": 8080, "proto": "tcp"}]',
}
row.update(overrides)
return store.create_instance(row)
def test_open_workspace_requires_enabled_setting():
set_setting("workspace_enabled", "0")
assert can_open_workspace(_project(), {"uid": OWNER, "role": "Member"}) is False
set_setting("workspace_enabled", "1")
assert can_open_workspace(_project(), {"uid": OWNER, "role": "Member"}) is True
def test_guest_can_never_open_workspace():
assert can_open_workspace(_project(), None) is False
assert can_open_workspace(_project(), {}) is False
def test_non_owner_member_cannot_open_workspace():
assert can_open_workspace(_project(), {"uid": OTHER, "role": "Member"}) is False
def test_admin_can_open_any_project_workspace():
assert can_open_workspace(_project(), {"uid": OTHER, "role": "Admin"}) is True
def test_owner_manages_own_workspace_without_admin():
instance = _instance()
assert can_manage_workspace(instance, _project(), {"uid": OWNER, "role": "Member"})
assert not can_manage_workspace(
instance, _project(), {"uid": OTHER, "role": "Member"}
)
def test_create_or_resume_is_idempotent():
project = _project()
user = {"uid": OWNER, "username": "owner"}
first = run_async(provision.ensure(project, user))
second = run_async(provision.ensure(project, user))
assert first["uid"] == second["uid"]
assert provision.count_for_owner(OWNER) == 1
def test_workspace_quota_blocks_beyond_limit():
set_setting("workspace_max_per_user", "1")
user = {"uid": OWNER, "username": "owner"}
run_async(provision.ensure(_project("p-a"), user))
with pytest.raises(WorkspaceError):
run_async(provision.ensure(_project("p-b"), user))
set_setting("workspace_max_per_user", "2")
def test_tunnel_revives_rather_than_duplicates():
instance = _instance()
first = tunnels.create(instance, "web", 8080, OWNER)
tunnels.soft_delete(first["uid"], OWNER)
assert tunnels.count_for_instance(instance["uid"]) == 0
revived = tunnels.create(instance, "web", 8080, OWNER)
assert revived["uid"] == first["uid"]
assert tunnels.count_for_instance(instance["uid"]) == 1
def test_tunnel_hostname_is_a_single_dns_label():
instance = _instance()
row = tunnels.create(instance, "web", 3000, OWNER)
host = row["hostname"]
suffix = "." + naming.domain()
assert host.endswith(suffix)
label = host[: -len(suffix)]
assert "." not in label
assert naming.is_valid_label(label)
def test_suspend_stops_tunnels_without_deleting_them():
instance = _instance()
tunnels.create(instance, "web", 8080, OWNER)
provision.suspend(instance, "admin-uid", "abuse")
rows = tunnels.list_for_instance(instance["uid"])
assert rows and rows[0]["status"] == "suspended"
refreshed = store.get_instance(instance["uid"])
assert refreshed["suspended_at"]
provision.unsuspend(refreshed)
assert tunnels.list_for_instance(instance["uid"])[0]["status"] == "pending"
def test_suspended_workspace_cannot_resume():
instance = _instance()
provision.suspend(instance, "admin-uid", "abuse")
with pytest.raises(WorkspaceError):
provision.resume(store.get_instance(instance["uid"]))
def test_flags_are_idempotent_while_open():
instance = _instance()
first = flags.raise_flag(instance, flags.KIND_EGRESS, "warn", "a", 1.0, 0.5)
second = flags.raise_flag(instance, flags.KIND_EGRESS, "warn", "b", 2.0, 0.5)
assert first["uid"] == second["uid"]
assert len(flags.list_flags(instance_uid=instance["uid"])) == 1
flags.clear_flag(instance["uid"], flags.KIND_EGRESS, "admin")
assert flags.list_flags(instance_uid=instance["uid"]) == []
def test_activity_accumulates_egress_and_requests():
instance = _instance()
activity.forget(instance["uid"])
for _ in range(3):
activity.touch(instance["uid"], egress_bytes=100)
activity.flush(instance["uid"])
row = store.get_instance(instance["uid"])
assert row["egress_bytes"] == 300
assert row["request_count"] == 3
assert row["last_active_at"]
def test_quota_rule_overrides_only_its_owner():
get_table("workspace_quota_rules").insert(
{
"uid": "rule-x",
"owner_kind": "user",
"owner_id": OWNER,
"label": "power",
"max_workspaces": 7,
"deleted_at": None,
"deleted_by": None,
}
)
assert quota.resolve(OWNER).max_workspaces == 7
assert quota.resolve(OTHER).max_workspaces == quota.resolve().max_workspaces
def test_workspace_env_contract_is_complete():
from devplacepy.services.containers import api
instance = _instance()
env = api.workspace_env(instance, "https://example.test")
for key in (
"DEVPLACE_WORKSPACE",
"DEVPLACE_WORKSPACE_UID",
"DEVPLACE_TUNNEL_NAME",
"DEVPLACE_TUNNEL_DOMAIN",
"DEVPLACE_TUNNEL_MANIFEST",
"VSCODE_PROXY_URI",
"DEVPLACE_EDITOR_PORT",
"DEVPLACE_QUOTA_DISK_MB",
"DEVPLACE_RETENTION_DAYS",
):
assert key in env, key
assert all(isinstance(value, str) for value in env.values())
assert "{{port}}" in env["VSCODE_PROXY_URI"]
def test_non_workspace_instance_gets_no_workspace_env():
from devplacepy.services.containers import api
env = api.workspace_env({"is_workspace": 0}, "https://example.test")
assert env == {"DEVPLACE_WORKSPACE": ""}
def test_devii_workspace_tools_are_role_gated():
from devplacepy.services.devii.registry import CATALOG
names = {a.name for a in CATALOG.actions if a.handler == "workspace"}
admin_only = {
a.name for a in CATALOG.actions if a.handler == "workspace" and a.requires_admin
}
guest = {s["function"]["name"] for s in CATALOG.tool_schemas_for(False, False)}
member = {s["function"]["name"] for s in CATALOG.tool_schemas_for(True, False)}
admin = {s["function"]["name"] for s in CATALOG.tool_schemas_for(True, True)}
assert not (names & guest)
assert not ((names & member) & admin_only)
assert names <= admin
def test_every_confirm_gated_tool_declares_a_confirm_param():
from devplacepy.services.devii.actions.dispatcher import CONFIRM_REQUIRED
from devplacepy.services.devii.registry import CATALOG
for action in CATALOG.actions:
if action.handler != "workspace" or action.name not in CONFIRM_REQUIRED:
continue
assert any(p.name == "confirm" for p in action.params), action.name
def test_tunnel_host_detection_never_matches_the_site():
assert naming.is_tunnel_host("abc." + naming.domain())
assert not naming.is_tunnel_host("pravda.education")
assert not naming.is_tunnel_host("")

View File

@ -0,0 +1,221 @@
# retoor <retoor@molodetz.nl>
import re
from uuid import uuid4
import pytest
import requests
from playwright.sync_api import expect
from devplacepy.database import get_table, set_setting
from devplacepy.services.containers import store
from devplacepy.services.containers.workspace import flags, naming
from devplacepy.utils import make_combined_slug
from tests.conftest import BASE_URL
@pytest.fixture(autouse=True)
def _workspaces_on():
previous = None
row = get_table("site_settings").find_one(key="workspace_enabled")
if row:
previous = row.get("value")
set_setting("workspace_enabled", "1")
try:
yield
finally:
set_setting("workspace_enabled", previous if previous is not None else "0")
instances = get_table("instances")
created = [r["uid"] for r in instances.find(is_workspace=1)]
for uid in created:
get_table("tunnels").delete(instance_uid=uid)
get_table("workspace_flags").delete(instance_uid=uid)
instances.delete(uid=uid)
def _row_for(user: dict) -> dict:
return get_table("users").find_one(username=user["username"])
def _project_for(owner_uid: str, title: str = "WS Project") -> dict:
uid = str(uuid4())
slug = make_combined_slug(title, uid)
row = {
"uid": uid,
"user_uid": owner_uid,
"title": title,
"description": "workspace host project",
"slug": slug,
"stars": 0,
"created_at": "2026-01-01T00:00:00+00:00",
"deleted_at": None,
"deleted_by": None,
}
get_table("projects").insert(row)
return row
def _workspace_for(project: dict, owner_uid: str, **overrides) -> dict:
payload = {
"project_uid": project["uid"],
"name": "ws-e2e",
"status": "running",
"desired_state": "running",
"is_workspace": 1,
"workspace_owner_uid": owner_uid,
"tunnel_name": naming.generate(),
"ports_json": '[{"host": 20777, "container": 8080, "proto": "tcp"}]',
}
payload.update(overrides)
return store.create_instance(payload)
def test_workspace_page_offers_creation_to_owner(alice):
page, user = alice
project = _project_for(_row_for(user)["uid"])
page.goto(
f"{BASE_URL}/projects/{project['slug']}/workspace",
wait_until="domcontentloaded",
)
page.locator(".workspace-page").wait_for(state="visible")
expect(page.locator("button:has-text('Open workspace')")).to_be_visible()
def test_workspace_page_shows_state_quota_and_tunnel_form(alice):
page, user = alice
project = _project_for(_row_for(user)["uid"], "WS Detail")
_workspace_for(project, _row_for(user)["uid"])
page.goto(
f"{BASE_URL}/projects/{project['slug']}/workspace",
wait_until="domcontentloaded",
)
page.locator(".workspace-summary").wait_for(state="visible")
expect(page.locator("[data-workspace-status]")).to_contain_text("running")
expect(page.locator(".workspace-meter").first).to_be_visible()
expect(page.locator(".workspace-tunnel-form")).to_be_visible()
expect(page.locator(".workspace-help")).to_contain_text("sudo")
def test_owner_can_add_and_remove_a_tunnel(alice):
page, user = alice
project = _project_for(_row_for(user)["uid"], "WS Tunnel")
_workspace_for(project, _row_for(user)["uid"])
url = f"{BASE_URL}/projects/{project['slug']}/workspace"
page.goto(url, wait_until="domcontentloaded")
page.locator(".workspace-tunnel-form input[name='container_port']").fill("8080")
page.locator(".workspace-tunnel-form button:has-text('Add tunnel')").click()
page.wait_for_url(url, wait_until="domcontentloaded")
tunnel = page.locator(".workspace-tunnel").first
tunnel.wait_for(state="visible")
expect(tunnel).to_contain_text(naming.domain())
page.locator(".workspace-tunnel button:has-text('Remove')").first.click()
page.locator(".dialog-confirm").wait_for(state="visible")
page.locator(".dialog-confirm").click()
page.wait_for_url(url, wait_until="domcontentloaded")
expect(page.locator(".workspace-tunnel-list")).to_contain_text("No tunnels yet")
def test_suspended_workspace_shows_reason_to_owner(alice):
page, user = alice
project = _project_for(_row_for(user)["uid"], "WS Suspended")
instance = _workspace_for(project, _row_for(user)["uid"])
store.update_instance(
instance["uid"],
{"suspended_at": "2026-01-01T00:00:00+00:00", "flag_reason": "sustained cpu"},
)
page.goto(
f"{BASE_URL}/projects/{project['slug']}/workspace",
wait_until="domcontentloaded",
)
banner = page.locator(".workspace-suspended")
banner.wait_for(state="visible")
expect(banner).to_contain_text("sustained cpu")
def test_open_flag_is_visible_to_the_owner(alice):
page, user = alice
project = _project_for(_row_for(user)["uid"], "WS Flagged")
instance = _workspace_for(project, _row_for(user)["uid"])
flags.raise_flag(
store.get_instance(instance["uid"]),
flags.KIND_EGRESS,
"warn",
"egress above the hourly ceiling",
2048.0,
1024.0,
)
page.goto(
f"{BASE_URL}/projects/{project['slug']}/workspace",
wait_until="domcontentloaded",
)
flag = page.locator(".workspace-flag").first
flag.wait_for(state="visible")
expect(flag).to_contain_text("egress above the hourly ceiling")
def test_guest_cannot_reach_the_workspace_page(page):
project = _project_for(str(uuid4()), "WS Guest")
response = requests.get(
f"{BASE_URL}/projects/{project['slug']}/workspace",
allow_redirects=False,
)
assert response.status_code in (303, 401, 404)
def test_non_owner_member_is_refused(bob):
page, user = bob
project = _project_for(str(uuid4()), "WS Foreign")
page.goto(
f"{BASE_URL}/projects/{project['slug']}/workspace",
wait_until="domcontentloaded",
)
assert not page.locator(".workspace-summary").count()
def test_admin_console_lists_and_suspends_a_workspace(alice):
page, user = alice
project = _project_for(_row_for(user)["uid"], "WS Admin")
instance = _workspace_for(project, _row_for(user)["uid"])
page.goto(f"{BASE_URL}/admin/workspaces", wait_until="domcontentloaded")
row = page.locator(f"tr[data-workspace-uid='{instance['uid']}']")
row.wait_for(state="visible")
expect(row).to_contain_text("ws-e2e")
row.locator("input[name='reason']").fill("policy breach")
row.locator("button:has-text('Suspend')").click()
page.wait_for_url(f"{BASE_URL}/admin/workspaces", wait_until="domcontentloaded")
refreshed = store.get_instance(instance["uid"])
assert refreshed["suspended_at"]
row = page.locator(f"tr[data-workspace-uid='{instance['uid']}']")
expect(row).to_contain_text("suspended")
row.locator("button:has-text('Unsuspend')").click()
page.wait_for_url(f"{BASE_URL}/admin/workspaces", wait_until="domcontentloaded")
assert not store.get_instance(instance["uid"])["suspended_at"]
def test_admin_console_raises_and_resolves_a_flag(alice):
page, user = alice
project = _project_for(_row_for(user)["uid"], "WS Flag Admin")
instance = _workspace_for(project, _row_for(user)["uid"])
page.goto(f"{BASE_URL}/admin/workspaces", wait_until="domcontentloaded")
row = page.locator(f"tr[data-workspace-uid='{instance['uid']}']")
row.wait_for(state="visible")
row.locator("input[name='detail']").fill("manual review")
row.locator("button:has-text('Flag')").click()
page.wait_for_url(f"{BASE_URL}/admin/workspaces", wait_until="domcontentloaded")
assert flags.list_flags(instance_uid=instance["uid"])
page.locator("button:has-text('Resolve')").first.click()
page.wait_for_url(f"{BASE_URL}/admin/workspaces", wait_until="domcontentloaded")
assert not flags.list_flags(instance_uid=instance["uid"])
def test_workspaces_sidebar_link_is_present_for_admin(alice):
page, user = alice
page.goto(f"{BASE_URL}/admin/workspaces", wait_until="domcontentloaded")
link = page.locator(".sidebar-link:has-text('Workspaces')")
link.wait_for(state="visible")
expect(link).to_have_class(re.compile("active"))

View File

@ -67,3 +67,29 @@ def test_primary_admin_skips_deactivated_and_deleted_founders(local_db):
for uid, _, _, _ in seeded:
users.delete(uid=uid)
invalidate_admins_cache()
def test_account_with_a_null_is_active_counts_as_active(local_db):
from devplacepy.database import is_account_active
assert is_account_active({"is_active": None}) is True
def test_account_without_an_is_active_column_counts_as_active(local_db):
from devplacepy.database import is_account_active
assert is_account_active({}) is True
def test_an_explicitly_disabled_account_is_not_active(local_db):
from devplacepy.database import is_account_active
assert is_account_active({"is_active": False}) is False
assert is_account_active({"is_active": 0}) is False
def test_an_enabled_account_is_active(local_db):
from devplacepy.database import is_account_active
assert is_account_active({"is_active": True}) is True
assert is_account_active({"is_active": 1}) is True