From 21f6ae06155080b227454c5a50ebcb0616b4c3c8 Mon Sep 17 00:00:00 2001 From: retoor Date: Fri, 7 Aug 2026 10:53:08 +0200 Subject: [PATCH] iUUUUpdatexz --- devplacepy/config.py | 8 + devplacepy/content.py | 39 ++ devplacepy/database/CLAUDE.md | 1 + devplacepy/database/__init__.py | 3 +- devplacepy/database/notifications.py | 1 + devplacepy/database/schema.py | 99 ++++++ devplacepy/database/soft_delete.py | 2 + devplacepy/database/users.py | 10 +- devplacepy/docs_api/groups/__init__.py | 2 + devplacepy/docs_api/groups/workspaces.py | 167 +++++++++ devplacepy/main.py | 40 ++- devplacepy/models.py | 26 ++ devplacepy/routers/admin/__init__.py | 2 + devplacepy/routers/admin/users.py | 3 +- devplacepy/routers/admin/workspaces.py | 282 +++++++++++++++ devplacepy/routers/auth/login.py | 4 +- devplacepy/routers/auth/token.py | 4 +- devplacepy/routers/devrant/auth.py | 4 +- .../routers/projects/containers/__init__.py | 3 +- .../routers/projects/containers/workspace.py | 278 +++++++++++++++ devplacepy/routers/proxy.py | 143 +------- devplacepy/routers/tunnel.py | 67 ++++ devplacepy/schemas/__init__.py | 5 + devplacepy/schemas/containers.py | 72 ++++ devplacepy/services/access_tokens.py | 4 +- devplacepy/services/containers/CLAUDE.md | 70 +++- devplacepy/services/containers/activity.py | 56 +++ devplacepy/services/containers/api.py | 114 +++++- .../services/containers/backend/base.py | 1 + devplacepy/services/containers/forward.py | 173 +++++++++ devplacepy/services/containers/store.py | 23 ++ .../services/containers/workspace/__init__.py | 5 + .../services/containers/workspace/flags.py | 134 +++++++ .../services/containers/workspace/naming.py | 83 +++++ .../containers/workspace/provision.py | 188 ++++++++++ .../services/containers/workspace/quota.py | 97 +++++ .../services/containers/workspace/tunnels.py | 154 ++++++++ .../services/containers/workspace_service.py | 334 ++++++++++++++++++ .../services/devii/actions/dispatcher.py | 10 + devplacepy/services/devii/actions/spec.py | 1 + .../devii/actions/workspace_actions.py | 212 +++++++++++ devplacepy/services/devii/registry.py | 2 + .../services/devii/workspace/__init__.py | 5 + .../services/devii/workspace/controller.py | 243 +++++++++++++ devplacepy/services/devrant/tokens.py | 6 +- devplacepy/static/css/workspace.css | 118 +++++++ devplacepy/static/js/WorkspaceManager.js | 63 ++++ devplacepy/templates/admin_base.html | 3 + devplacepy/templates/admin_workspaces.html | 114 ++++++ devplacepy/templates/workspace.html | 124 +++++++ devplacepy/utils/auth.py | 8 +- events.md | 24 ++ ppy.Dockerfile | 62 +++- tests/api/auth/login.py | 42 +++ tests/api/projects/workspace.py | 230 ++++++++++++ tests/e2e/projects/workspace.py | 221 ++++++++++++ tests/unit/database/users.py | 26 ++ 57 files changed, 4050 insertions(+), 165 deletions(-) create mode 100644 devplacepy/docs_api/groups/workspaces.py create mode 100644 devplacepy/routers/admin/workspaces.py create mode 100644 devplacepy/routers/projects/containers/workspace.py create mode 100644 devplacepy/routers/tunnel.py create mode 100644 devplacepy/services/containers/activity.py create mode 100644 devplacepy/services/containers/forward.py create mode 100644 devplacepy/services/containers/workspace/__init__.py create mode 100644 devplacepy/services/containers/workspace/flags.py create mode 100644 devplacepy/services/containers/workspace/naming.py create mode 100644 devplacepy/services/containers/workspace/provision.py create mode 100644 devplacepy/services/containers/workspace/quota.py create mode 100644 devplacepy/services/containers/workspace/tunnels.py create mode 100644 devplacepy/services/containers/workspace_service.py create mode 100644 devplacepy/services/devii/actions/workspace_actions.py create mode 100644 devplacepy/services/devii/workspace/__init__.py create mode 100644 devplacepy/services/devii/workspace/controller.py create mode 100644 devplacepy/static/css/workspace.css create mode 100644 devplacepy/static/js/WorkspaceManager.js create mode 100644 devplacepy/templates/admin_workspaces.html create mode 100644 devplacepy/templates/workspace.html create mode 100644 tests/api/projects/workspace.py create mode 100644 tests/e2e/projects/workspace.py diff --git a/devplacepy/config.py b/devplacepy/config.py index 85607809..8f93c00a 100644 --- a/devplacepy/config.py +++ b/devplacepy/config.py @@ -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, diff --git a/devplacepy/content.py b/devplacepy/content.py index 0df59d13..e4b70a6b 100644 --- a/devplacepy/content.py +++ b/devplacepy/content.py @@ -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: diff --git a/devplacepy/database/CLAUDE.md b/devplacepy/database/CLAUDE.md index dc9e7cd8..b2739809 100644 --- a/devplacepy/database/CLAUDE.md +++ b/devplacepy/database/CLAUDE.md @@ -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 ... ` 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`). diff --git a/devplacepy/database/__init__.py b/devplacepy/database/__init__.py index 90898d4f..da55287d 100644 --- a/devplacepy/database/__init__.py +++ b/devplacepy/database/__init__.py @@ -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", diff --git a/devplacepy/database/notifications.py b/devplacepy/database/notifications.py index 748f84f0..98c77988 100644 --- a/devplacepy/database/notifications.py +++ b/devplacepy/database/notifications.py @@ -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)"}, ] diff --git a/devplacepy/database/schema.py b/devplacepy/database/schema.py index 8f8eb7e8..ced73d6c 100644 --- a/devplacepy/database/schema.py +++ b/devplacepy/database/schema.py @@ -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"]) diff --git a/devplacepy/database/soft_delete.py b/devplacepy/database/soft_delete.py index 7081233e..e1d159f5 100644 --- a/devplacepy/database/soft_delete.py +++ b/devplacepy/database/soft_delete.py @@ -23,6 +23,8 @@ SOFT_DELETE_TABLES = [ "sessions", "instances", "instance_schedules", + "tunnels", + "workspace_flags", "backup_schedules", "devii_conversations", "devii_tasks", diff --git a/devplacepy/database/users.py b/devplacepy/database/users.py index bcf8e1e3..140735e2 100644 --- a/devplacepy/database/users.py +++ b/devplacepy/database/users.py @@ -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(): diff --git a/devplacepy/docs_api/groups/__init__.py b/devplacepy/docs_api/groups/__init__.py index 17586725..098db81c 100644 --- a/devplacepy/docs_api/groups/__init__.py +++ b/devplacepy/docs_api/groups/__init__.py @@ -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, diff --git a/devplacepy/docs_api/groups/workspaces.py b/devplacepy/docs_api/groups/workspaces.py new file mode 100644 index 00000000..356cb085 --- /dev/null +++ b/devplacepy/docs_api/groups/workspaces.py @@ -0,0 +1,167 @@ +# retoor + +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 +`-.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"}, + ), + ], +} diff --git a/devplacepy/main.py b/devplacepy/main.py index f44256ad..891623a6 100644 --- a/devplacepy/main.py +++ b/devplacepy/main.py @@ -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) diff --git a/devplacepy/models.py b/devplacepy/models.py index 3efc3652..f64e267b 100644 --- a/devplacepy/models.py +++ b/devplacepy/models.py @@ -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) diff --git a/devplacepy/routers/admin/__init__.py b/devplacepy/routers/admin/__init__.py index 1c1c4a21..e0080dce 100644 --- a/devplacepy/routers/admin/__init__.py +++ b/devplacepy/routers/admin/__init__.py @@ -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) diff --git a/devplacepy/routers/admin/users.py b/devplacepy/routers/admin/users.py index fadccfca..7079f77e 100644 --- a/devplacepy/routers/admin/users.py +++ b/devplacepy/routers/admin/users.py @@ -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( diff --git a/devplacepy/routers/admin/workspaces.py b/devplacepy/routers/admin/workspaces.py new file mode 100644 index 00000000..a6ea3cc5 --- /dev/null +++ b/devplacepy/routers/admin/workspaces.py @@ -0,0 +1,282 @@ +# retoor + +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}) diff --git a/devplacepy/routers/auth/login.py b/devplacepy/routers/auth/login.py index 92b23308..6a1ca429 100644 --- a/devplacepy/routers/auth/login.py +++ b/devplacepy/routers/auth/login.py @@ -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: diff --git a/devplacepy/routers/auth/token.py b/devplacepy/routers/auth/token.py index 42ab073d..784c47ed 100644 --- a/devplacepy/routers/auth/token.py +++ b/devplacepy/routers/auth/token.py @@ -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", diff --git a/devplacepy/routers/devrant/auth.py b/devplacepy/routers/devrant/auth.py index 169620a0..e4a010ff 100644 --- a/devplacepy/routers/devrant/auth.py +++ b/devplacepy/routers/devrant/auth.py @@ -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( diff --git a/devplacepy/routers/projects/containers/__init__.py b/devplacepy/routers/projects/containers/__init__.py index c7b6c53f..29c071bf 100644 --- a/devplacepy/routers/projects/containers/__init__.py +++ b/devplacepy/routers/projects/containers/__init__.py @@ -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) diff --git a/devplacepy/routers/projects/containers/workspace.py b/devplacepy/routers/projects/containers/workspace.py new file mode 100644 index 00000000..6ee613c2 --- /dev/null +++ b/devplacepy/routers/projects/containers/workspace.py @@ -0,0 +1,278 @@ +# retoor + +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) diff --git a/devplacepy/routers/proxy.py b/devplacepy/routers/proxy.py index 2364dc82..1cd94b2e 100644 --- a/devplacepy/routers/proxy.py +++ b/devplacepy/routers/proxy.py @@ -1,70 +1,18 @@ # retoor -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"'.encode() - head = lowered.find(b"", head) - if head != -1 - else lowered.find(b">", lowered.find(b" 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) diff --git a/devplacepy/routers/tunnel.py b/devplacepy/routers/tunnel.py new file mode 100644 index 00000000..4d9cae3d --- /dev/null +++ b/devplacepy/routers/tunnel.py @@ -0,0 +1,67 @@ +# retoor + +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) diff --git a/devplacepy/schemas/__init__.py b/devplacepy/schemas/__init__.py index a3a01d3f..d30868f6 100644 --- a/devplacepy/schemas/__init__.py +++ b/devplacepy/schemas/__init__.py @@ -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, diff --git a/devplacepy/schemas/containers.py b/devplacepy/schemas/containers.py index 4e3263f6..c38a9326 100644 --- a/devplacepy/schemas/containers.py +++ b/devplacepy/schemas/containers.py @@ -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 diff --git a/devplacepy/services/access_tokens.py b/devplacepy/services/access_tokens.py index 55dafa3e..940e2fab 100644 --- a/devplacepy/services/access_tokens.py +++ b/devplacepy/services/access_tokens.py @@ -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 diff --git a/devplacepy/services/containers/CLAUDE.md b/devplacepy/services/containers/CLAUDE.md index f462ebaa..e05590aa 100644 --- a/devplacepy/services/containers/CLAUDE.md +++ b/devplacepy/services/containers/CLAUDE.md @@ -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, +`` 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/`, 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. diff --git a/devplacepy/services/containers/activity.py b/devplacepy/services/containers/activity.py new file mode 100644 index 00000000..e50721da --- /dev/null +++ b/devplacepy/services/containers/activity.py @@ -0,0 +1,56 @@ +# retoor + +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) diff --git a/devplacepy/services/containers/api.py b/devplacepy/services/containers/api.py index 00890cef..7e25a0d6 100644 --- a/devplacepy/services/containers/api.py +++ b/devplacepy/services/containers/api.py @@ -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: diff --git a/devplacepy/services/containers/backend/base.py b/devplacepy/services/containers/backend/base.py index 1c67f10d..12bbbbd4 100644 --- a/devplacepy/services/containers/backend/base.py +++ b/devplacepy/services/containers/backend/base.py @@ -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 diff --git a/devplacepy/services/containers/forward.py b/devplacepy/services/containers/forward.py new file mode 100644 index 00000000..13d72870 --- /dev/null +++ b/devplacepy/services/containers/forward.py @@ -0,0 +1,173 @@ +# retoor + +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"'.encode() + head = lowered.find(b"", head) + if head != -1 + else lowered.find(b">", lowered.find(b" 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()) diff --git a/devplacepy/services/containers/store.py b/devplacepy/services/containers/store.py index 0d84564d..66b94e62 100644 --- a/devplacepy/services/containers/store.py +++ b/devplacepy/services/containers/store.py @@ -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"] diff --git a/devplacepy/services/containers/workspace/__init__.py b/devplacepy/services/containers/workspace/__init__.py new file mode 100644 index 00000000..3567994d --- /dev/null +++ b/devplacepy/services/containers/workspace/__init__.py @@ -0,0 +1,5 @@ +# retoor + +from . import flags, naming, provision, quota, tunnels + +__all__ = ["flags", "naming", "provision", "quota", "tunnels"] diff --git a/devplacepy/services/containers/workspace/flags.py b/devplacepy/services/containers/workspace/flags.py new file mode 100644 index 00000000..d535e70a --- /dev/null +++ b/devplacepy/services/containers/workspace/flags.py @@ -0,0 +1,134 @@ +# retoor + +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 diff --git a/devplacepy/services/containers/workspace/naming.py b/devplacepy/services/containers/workspace/naming.py new file mode 100644 index 00000000..74a83aa1 --- /dev/null +++ b/devplacepy/services/containers/workspace/naming.py @@ -0,0 +1,83 @@ +# retoor + +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) diff --git a/devplacepy/services/containers/workspace/provision.py b/devplacepy/services/containers/workspace/provision.py new file mode 100644 index 00000000..96587796 --- /dev/null +++ b/devplacepy/services/containers/workspace/provision.py @@ -0,0 +1,188 @@ +# retoor + +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"]), + } diff --git a/devplacepy/services/containers/workspace/quota.py b/devplacepy/services/containers/workspace/quota.py new file mode 100644 index 00000000..ed6df13f --- /dev/null +++ b/devplacepy/services/containers/workspace/quota.py @@ -0,0 +1,97 @@ +# retoor + +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)) diff --git a/devplacepy/services/containers/workspace/tunnels.py b/devplacepy/services/containers/workspace/tunnels.py new file mode 100644 index 00000000..97e9aab2 --- /dev/null +++ b/devplacepy/services/containers/workspace/tunnels.py @@ -0,0 +1,154 @@ +# retoor + +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}, + ) diff --git a/devplacepy/services/containers/workspace_service.py b/devplacepy/services/containers/workspace_service.py new file mode 100644 index 00000000..76317553 --- /dev/null +++ b/devplacepy/services/containers/workspace_service.py @@ -0,0 +1,334 @@ +# retoor + +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())], + }, + } diff --git a/devplacepy/services/devii/actions/dispatcher.py b/devplacepy/services/devii/actions/dispatcher.py index 3ff9a67c..3f57d456 100644 --- a/devplacepy/services/devii/actions/dispatcher.py +++ b/devplacepy/services/devii/actions/dispatcher.py @@ -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) diff --git a/devplacepy/services/devii/actions/spec.py b/devplacepy/services/devii/actions/spec.py index ec903d8a..aac70eae 100644 --- a/devplacepy/services/devii/actions/spec.py +++ b/devplacepy/services/devii/actions/spec.py @@ -44,6 +44,7 @@ class Action: "chunks", "rsearch", "container", + "workspace", "customization", "notification", "behavior", diff --git a/devplacepy/services/devii/actions/workspace_actions.py b/devplacepy/services/devii/actions/workspace_actions.py new file mode 100644 index 00000000..030aa7fd --- /dev/null +++ b/devplacepy/services/devii/actions/workspace_actions.py @@ -0,0 +1,212 @@ +# retoor + +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)), + ), +) diff --git a/devplacepy/services/devii/registry.py b/devplacepy/services/devii/registry.py index 03f4be52..2464605d 100644 --- a/devplacepy/services/devii/registry.py +++ b/devplacepy/services/devii/registry.py @@ -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 diff --git a/devplacepy/services/devii/workspace/__init__.py b/devplacepy/services/devii/workspace/__init__.py new file mode 100644 index 00000000..d22ca58c --- /dev/null +++ b/devplacepy/services/devii/workspace/__init__.py @@ -0,0 +1,5 @@ +# retoor + +from .controller import WorkspaceController + +__all__ = ["WorkspaceController"] diff --git a/devplacepy/services/devii/workspace/controller.py b/devplacepy/services/devii/workspace/controller.py new file mode 100644 index 00000000..f0ebd364 --- /dev/null +++ b/devplacepy/services/devii/workspace/controller.py @@ -0,0 +1,243 @@ +# retoor + +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"]} diff --git a/devplacepy/services/devrant/tokens.py b/devplacepy/services/devrant/tokens.py index ddf7684b..d433b569 100644 --- a/devplacepy/services/devrant/tokens.py +++ b/devplacepy/services/devrant/tokens.py @@ -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 diff --git a/devplacepy/static/css/workspace.css b/devplacepy/static/css/workspace.css new file mode 100644 index 00000000..311820e4 --- /dev/null +++ b/devplacepy/static/css/workspace.css @@ -0,0 +1,118 @@ +/* retoor */ + +.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; + } +} diff --git a/devplacepy/static/js/WorkspaceManager.js b/devplacepy/static/js/WorkspaceManager.js new file mode 100644 index 00000000..8822a84d --- /dev/null +++ b/devplacepy/static/js/WorkspaceManager.js @@ -0,0 +1,63 @@ +// retoor + +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; diff --git a/devplacepy/templates/admin_base.html b/devplacepy/templates/admin_base.html index 3ab9c6e5..52032c27 100644 --- a/devplacepy/templates/admin_base.html +++ b/devplacepy/templates/admin_base.html @@ -29,6 +29,9 @@ 📦 Containers + + 💻 Workspaces + Devii tasks diff --git a/devplacepy/templates/admin_workspaces.html b/devplacepy/templates/admin_workspaces.html new file mode 100644 index 00000000..cff15d18 --- /dev/null +++ b/devplacepy/templates/admin_workspaces.html @@ -0,0 +1,114 @@ +{% extends "admin_base.html" %} +{% block extra_head %} +{{ super() }} + +{% endblock %} +{% block admin_content %} +
+

Workspaces

+ {{ workspaces|length }} workspaces + {{ flags|length }} open flags +
+ +
+ + + + + + + + + + + + + + + + + {% for ws in workspaces %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
Dev workspaces
NameOwnerProjectStatusDiskEgressTunnelsFlagsActions
{{ ws.name }}{{ ws.owner_username }}{{ ws.project_title }} + {% if ws.suspended %} + suspended + {% else %} + {{ ws.status }} + {% endif %} + {{ ws.disk_percent }}% of {{ ws.disk_quota_mb }} MB{{ ws.egress_percent }}% of {{ ws.egress_quota_mb }} MB{{ ws.tunnels|length }}{{ ws.flags|length }} + {% if ws.status == "running" %} +
+ +
+ {% else %} +
+ +
+ {% endif %} + {% if ws.suspended %} +
+ +
+ {% else %} +
+ + +
+ {% endif %} +
+ + +
+
+ +
+
No workspaces yet.
+
+ +
+

Open flags

+
+
+ + + + + + + {% for flag in flags %} + + + + + + + + + {% else %} + + {% endfor %} + +
Open workspace flags
KindSeverityDetailValueThresholdActions
{{ flag.kind }}{{ flag.severity }}{{ flag.detail }}{{ flag.metric_value }}{{ flag.threshold }} +
+ +
+
+ +
+
No open flags.
+
+{% endblock %} diff --git a/devplacepy/templates/workspace.html b/devplacepy/templates/workspace.html new file mode 100644 index 00000000..1a23e926 --- /dev/null +++ b/devplacepy/templates/workspace.html @@ -0,0 +1,124 @@ +{% extends "base.html" %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+ +

Workspace: {{ project.title }}

+ + {% if not has_workspace %} +
+

No workspace yet for this project. Opening one starts a container with your + project files, a browser editor, and a terminal.

+

You are using {{ workspace_count }} of {{ max_workspaces }} workspaces.

+
+ +
+
+ {% else %} + + {% if workspace.suspended %} +
+ This workspace is suspended. +

{{ workspace.flag_reason or "Contact an administrator." }}

+
+ {% endif %} + + {% for flag in workspace.flags %} +
+ {{ flag.kind }} + {{ flag.detail }} +
+ {% endfor %} + +
+
{{ workspace.status }}
+
+
+ Disk + + {{ workspace.disk_percent }}% of {{ workspace.disk_quota_mb }} MB +
+
+ Egress + + {{ workspace.egress_percent }}% of {{ workspace.egress_quota_mb }} MB +
+
+

+ 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 %} +

+
+ {% if workspace.status == "running" %} + Open editor +
+ +
+ {% else %} +
+ +
+ {% endif %} +
+ +
+
+
+ +
+

Public tunnels

+

+ 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 }}. +

+
    + {% for tunnel in workspace.tunnels %} +
  • + {{ tunnel.hostname }} + port {{ tunnel.container_port }} + {{ tunnel.status }} + {% if tunnel.last_error %}{{ tunnel.last_error }}{% endif %} +
    + +
    +
  • + {% else %} +
  • No tunnels yet.
  • + {% endfor %} +
+
+ + + +
+
+ +
+

Inside the container

+
    +
  • sudo and apt install work with no extra setup.
  • +
  • Python, Rust, Nim and Swift toolchains are preinstalled.
  • +
  • Ports below 1024 cannot bind. Use a high port and a tunnel.
  • +
  • Your public URLs are also in /app/.devplace/tunnels.json.
  • +
+
+ + {% endif %} +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/devplacepy/utils/auth.py b/devplacepy/utils/auth.py index 63761981..5ef4c95e 100644 --- a/devplacepy/utils/auth.py +++ b/devplacepy/utils/auth.py @@ -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 diff --git a/events.md b/events.md index bfcc5ce5..ed07e081 100644 --- a/events.md +++ b/events.md @@ -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` | diff --git a/ppy.Dockerfile b/ppy.Dockerfile index 5929e01c..8177b913 100644 --- a/ppy.Dockerfile +++ b/ppy.Dockerfile @@ -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"] diff --git a/tests/api/auth/login.py b/tests/api/auth/login.py index eec4a466..cf91fb57 100644 --- a/tests/api/auth/login.py +++ b/tests/api/auth/login.py @@ -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 diff --git a/tests/api/projects/workspace.py b/tests/api/projects/workspace.py new file mode 100644 index 00000000..866c2154 --- /dev/null +++ b/tests/api/projects/workspace.py @@ -0,0 +1,230 @@ +# retoor + +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("") diff --git a/tests/e2e/projects/workspace.py b/tests/e2e/projects/workspace.py new file mode 100644 index 00000000..22b5b06a --- /dev/null +++ b/tests/e2e/projects/workspace.py @@ -0,0 +1,221 @@ +# retoor + +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")) diff --git a/tests/unit/database/users.py b/tests/unit/database/users.py index 43ce0a81..9c9c8f91 100644 --- a/tests/unit/database/users.py +++ b/tests/unit/database/users.py @@ -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