This commit is contained in:
parent
b777a5b9d0
commit
21f6ae0615
@ -16,6 +16,7 @@ UPLOADS_DIR = DATA_DIR / "uploads"
|
|||||||
ATTACHMENTS_DIR = UPLOADS_DIR / "attachments"
|
ATTACHMENTS_DIR = UPLOADS_DIR / "attachments"
|
||||||
PROJECT_FILES_DIR = UPLOADS_DIR / "project_files"
|
PROJECT_FILES_DIR = UPLOADS_DIR / "project_files"
|
||||||
CONTAINER_WORKSPACES_DIR = DATA_DIR / "container_workspaces"
|
CONTAINER_WORKSPACES_DIR = DATA_DIR / "container_workspaces"
|
||||||
|
WORKSPACE_STATE_DIR = DATA_DIR / "workspace_state"
|
||||||
ZIPS_DIR = DATA_DIR / "zips"
|
ZIPS_DIR = DATA_DIR / "zips"
|
||||||
ZIP_STAGING_DIR = DATA_DIR / "zip_staging"
|
ZIP_STAGING_DIR = DATA_DIR / "zip_staging"
|
||||||
FORK_STAGING_DIR = DATA_DIR / "fork_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_IMAGE = environ.get("DEVPLACE_CONTAINER_IMAGE", "ppy:latest")
|
||||||
CONTAINER_PROXY_HOST = environ.get("DEVPLACE_CONTAINER_PROXY_HOST", "").strip()
|
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_FILE = KEYS_DIR / "notification-private.pem"
|
||||||
VAPID_PRIVATE_KEY_PKCS8_FILE = KEYS_DIR / "notification-private.pkcs8.pem"
|
VAPID_PRIVATE_KEY_PKCS8_FILE = KEYS_DIR / "notification-private.pkcs8.pem"
|
||||||
VAPID_PUBLIC_KEY_FILE = KEYS_DIR / "notification-public.pem"
|
VAPID_PUBLIC_KEY_FILE = KEYS_DIR / "notification-public.pem"
|
||||||
@ -118,6 +125,7 @@ DATA_PATHS: dict[str, Path] = {
|
|||||||
"attachments": ATTACHMENTS_DIR,
|
"attachments": ATTACHMENTS_DIR,
|
||||||
"project_files": PROJECT_FILES_DIR,
|
"project_files": PROJECT_FILES_DIR,
|
||||||
"container_workspaces": CONTAINER_WORKSPACES_DIR,
|
"container_workspaces": CONTAINER_WORKSPACES_DIR,
|
||||||
|
"workspace_state": WORKSPACE_STATE_DIR,
|
||||||
"zips": ZIPS_DIR,
|
"zips": ZIPS_DIR,
|
||||||
"zip_staging": ZIP_STAGING_DIR,
|
"zip_staging": ZIP_STAGING_DIR,
|
||||||
"fork_staging": FORK_STAGING_DIR,
|
"fork_staging": FORK_STAGING_DIR,
|
||||||
|
|||||||
@ -136,6 +136,45 @@ def can_manage_instance(
|
|||||||
return is_primary_admin(user) or owns_instance(instance, project, user)
|
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(
|
def canonical_redirect(
|
||||||
area: str, item: dict, requested: str
|
area: str, item: dict, requested: str
|
||||||
) -> RedirectResponse | None:
|
) -> RedirectResponse | None:
|
||||||
|
|||||||
@ -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.
|
- `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).
|
- `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`.
|
- `guest_disabled(user)` -> emits ` disabled aria-disabled="true" title="Log in to participate"` for guests (empty for members); `login_hint(user)` -> a small login link. Both return `Markup`.
|
||||||
|
- **Account enabled/disabled is `database.is_account_active(row)` - never read `is_active` inline.** `is_active` is a nullable column, so a row written before it existed (or by any insert that omits it) holds SQL `NULL`, and the obvious `bool(row.get("is_active"))` reads that as *disabled*. `.get("is_active", True)` is no better: the default only applies when the key is **absent**, and a `SELECT *` row always has the key with value `None`. The predicate is `is_active is None or bool(is_active)` - unknown means active, only an explicit `0`/`False` disables. This is the same NULL-vs-0 trap as the `COALESCE` rule for atomic updates. It gates session auth, API-key auth, Basic auth, the login and access-token routes, the devRant token/auth paths, the admin enable/disable toggle, and `_can_hold_primary_admin`; every one of them goes through this single function. A NULL row previously could not log in at all, and the admin toggle could not disable it. (The `is_active` on a `gateway_models` row is a different table with its own semantics and is deliberately not routed through this.)
|
||||||
- **Role values are stored capitalized:** `users.role` is exactly `"Admin"` or `"Member"` (first registered user is `"Admin"`, `auth.py`); `is_admin` compares `== "Admin"` case-sensitively. The CLI is the only lowercase surface (`devplace role set ... <member|admin>` writes `role.capitalize()`; `role get` prints `.lower()`). A lowercase role in the DB silently defeats every admin check - never write a raw lowercase role.
|
- **Role values are stored capitalized:** `users.role` is exactly `"Admin"` or `"Member"` (first registered user is `"Admin"`, `auth.py`); `is_admin` compares `== "Admin"` case-sensitively. The CLI is the only lowercase surface (`devplace role set ... <member|admin>` writes `role.capitalize()`; `role get` prints `.lower()`). A lowercase role in the DB silently defeats every admin check - never write a raw lowercase role.
|
||||||
- **The shadow rule generalizes beyond `is_owner` to ANY Jinja global** (`is_admin`, `avatar_url`, `format_date`, `is_self`, `owns`, `guest_disabled`): `respond(req, tmpl, ctx, model=XOut)` hands the **same** `ctx` to the Pydantic model and the template, and a context key shadows the same-named global across the whole `base.html` chain. A bool named `is_admin` in the context makes `base.html`'s `{% if is_admin(user) %}` raise `TypeError: 'bool' object is not callable` - a 500 that only fires for the branch invoking the global (logged-in users, not guests, which is why guest-only smoke tests miss it). Name viewer/permission flags distinctly (`viewer_is_admin`) in both schema and context. Real issue fixed on `/issues/{number}`; regression-guarded by `tests/api/issues/create.py::test_issue_detail_renders_for_{member,admin}` (they render the page as an authenticated Member/Admin and assert 200 + the admin-only control).
|
- **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`).
|
- **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`).
|
||||||
|
|||||||
@ -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 .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 .atomic import conditional_update_row
|
||||||
from .settings import _settings_cache, get_setting, get_int_setting, set_setting, clear_settings_cache, internal_gateway_key
|
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 .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 .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
|
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",
|
"set_last_seen",
|
||||||
"get_online_users",
|
"get_online_users",
|
||||||
"get_primary_admin_uid",
|
"get_primary_admin_uid",
|
||||||
|
"is_account_active",
|
||||||
"search_users_by_username",
|
"search_users_by_username",
|
||||||
"_relations_cache",
|
"_relations_cache",
|
||||||
"get_user_relations",
|
"get_user_relations",
|
||||||
|
|||||||
@ -19,6 +19,7 @@ NOTIFICATION_TYPES = [
|
|||||||
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
|
{"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": "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": "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)"},
|
{"key": "system", "label": "System alerts", "description": "Platform infrastructure alerts (e.g. the AI gateway going down)"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -568,10 +568,109 @@ def init_db():
|
|||||||
("boot_language", "none"),
|
("boot_language", "none"),
|
||||||
("boot_script", ""),
|
("boot_script", ""),
|
||||||
("start_on_boot", 0),
|
("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):
|
if not instances.has_column(column):
|
||||||
instances.create_column_by_example(column, example)
|
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_project", ["project_uid"])
|
||||||
_index(db, "instances", "idx_instances_slug", ["slug"])
|
_index(db, "instances", "idx_instances_slug", ["slug"])
|
||||||
_index(db, "instances", "idx_instances_name", ["name"])
|
_index(db, "instances", "idx_instances_name", ["name"])
|
||||||
|
|||||||
@ -23,6 +23,8 @@ SOFT_DELETE_TABLES = [
|
|||||||
"sessions",
|
"sessions",
|
||||||
"instances",
|
"instances",
|
||||||
"instance_schedules",
|
"instance_schedules",
|
||||||
|
"tunnels",
|
||||||
|
"workspace_flags",
|
||||||
"backup_schedules",
|
"backup_schedules",
|
||||||
"devii_conversations",
|
"devii_conversations",
|
||||||
"devii_tasks",
|
"devii_tasks",
|
||||||
|
|||||||
@ -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):
|
def _can_hold_primary_admin(row, tracks_active):
|
||||||
if row.get("deleted_at"):
|
if row.get("deleted_at"):
|
||||||
return False
|
return False
|
||||||
if not tracks_active:
|
return not tracks_active or is_account_active(row)
|
||||||
return True
|
|
||||||
is_active = row.get("is_active")
|
|
||||||
return is_active is None or bool(is_active)
|
|
||||||
|
|
||||||
|
|
||||||
def get_primary_admin_uid():
|
def get_primary_admin_uid():
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from . import (
|
|||||||
uploads,
|
uploads,
|
||||||
project_files,
|
project_files,
|
||||||
containers,
|
containers,
|
||||||
|
workspaces,
|
||||||
tools,
|
tools,
|
||||||
push,
|
push,
|
||||||
issues,
|
issues,
|
||||||
@ -34,6 +35,7 @@ ORDERED_GROUPS = [
|
|||||||
uploads.GROUP,
|
uploads.GROUP,
|
||||||
project_files.GROUP,
|
project_files.GROUP,
|
||||||
containers.GROUP,
|
containers.GROUP,
|
||||||
|
workspaces.GROUP,
|
||||||
tools.GROUP,
|
tools.GROUP,
|
||||||
push.GROUP,
|
push.GROUP,
|
||||||
issues.GROUP,
|
issues.GROUP,
|
||||||
|
|||||||
167
devplacepy/docs_api/groups/workspaces.py
Normal file
167
devplacepy/docs_api/groups/workspaces.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from .._shared import endpoint, field
|
||||||
|
|
||||||
|
GROUP = {
|
||||||
|
"slug": "workspaces",
|
||||||
|
"title": "Dev Workspaces",
|
||||||
|
"intro": """
|
||||||
|
# Dev Workspaces
|
||||||
|
|
||||||
|
A workspace is a browser VS Code environment attached to one of your projects. It runs your project
|
||||||
|
files, a terminal, and preinstalled Python, Rust, Nim and Swift toolchains. `sudo` and
|
||||||
|
`apt install` work with no extra setup; ports below 1024 cannot bind, so use a high port and publish
|
||||||
|
it through a tunnel.
|
||||||
|
|
||||||
|
A **tunnel** publishes one port from inside your container on a public HTTPS hostname of the form
|
||||||
|
`<port>-<name>.tunnel.pravda.education`. **Tunnel URLs are public and unauthenticated** - anyone with
|
||||||
|
the link can reach whatever you are serving.
|
||||||
|
|
||||||
|
Workspaces are bounded: a count limit per user, a disk quota, an egress quota, and a tunnel limit.
|
||||||
|
An idle workspace is warned about, then stopped, then warned again, then removed. Every warning
|
||||||
|
arrives as a `workspace` notification and states exactly what happens next and when.
|
||||||
|
""",
|
||||||
|
"endpoints": [
|
||||||
|
endpoint(
|
||||||
|
id="workspace-get",
|
||||||
|
method="GET",
|
||||||
|
path="/projects/{slug}/workspace",
|
||||||
|
title="Read workspace",
|
||||||
|
summary=(
|
||||||
|
"State, quota usage, idle countdown, tunnels and open moderation flags "
|
||||||
|
"for your workspace on this project."
|
||||||
|
),
|
||||||
|
auth="user",
|
||||||
|
params=[
|
||||||
|
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||||
|
],
|
||||||
|
sample_response={
|
||||||
|
"has_workspace": True,
|
||||||
|
"viewer_can_workspace": True,
|
||||||
|
"workspace_count": 1,
|
||||||
|
"max_workspaces": 2,
|
||||||
|
"editor_url": "/projects/my-project/containers/instances/INSTANCE_UID/code/",
|
||||||
|
"workspace": {
|
||||||
|
"uid": "INSTANCE_UID",
|
||||||
|
"status": "running",
|
||||||
|
"suspended": False,
|
||||||
|
"tunnel_name": "brave-otter",
|
||||||
|
"primary_url": "https://brave-otter.tunnel.pravda.education",
|
||||||
|
"disk_bytes": 5242880,
|
||||||
|
"disk_quota_mb": 2048,
|
||||||
|
"disk_percent": 1,
|
||||||
|
"egress_bytes": 10240,
|
||||||
|
"egress_quota_mb": 10240,
|
||||||
|
"egress_percent": 0,
|
||||||
|
"idle_stop_minutes": 60,
|
||||||
|
"retention_days": 14,
|
||||||
|
"max_tunnels": 5,
|
||||||
|
"tunnels": [],
|
||||||
|
"flags": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
endpoint(
|
||||||
|
id="workspace-open",
|
||||||
|
method="POST",
|
||||||
|
path="/projects/{slug}/workspace",
|
||||||
|
title="Open or resume workspace",
|
||||||
|
summary=(
|
||||||
|
"Create the workspace if you have none for this project, otherwise resume "
|
||||||
|
"it. Idempotent. Refused when you are at your workspace limit, over disk "
|
||||||
|
"quota, or suspended."
|
||||||
|
),
|
||||||
|
auth="user",
|
||||||
|
params=[
|
||||||
|
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||||
|
],
|
||||||
|
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
||||||
|
),
|
||||||
|
endpoint(
|
||||||
|
id="workspace-stop",
|
||||||
|
method="POST",
|
||||||
|
path="/projects/{slug}/workspace/stop",
|
||||||
|
title="Stop workspace",
|
||||||
|
summary="Stop the container. Files and tunnels are kept.",
|
||||||
|
auth="user",
|
||||||
|
params=[
|
||||||
|
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||||
|
],
|
||||||
|
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
||||||
|
),
|
||||||
|
endpoint(
|
||||||
|
id="workspace-delete",
|
||||||
|
method="POST",
|
||||||
|
path="/projects/{slug}/workspace/delete",
|
||||||
|
title="Delete workspace",
|
||||||
|
summary="Remove the workspace and its tunnels. An administrator can restore it.",
|
||||||
|
auth="user",
|
||||||
|
params=[
|
||||||
|
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||||
|
],
|
||||||
|
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
||||||
|
),
|
||||||
|
endpoint(
|
||||||
|
id="workspace-tunnels-list",
|
||||||
|
method="GET",
|
||||||
|
path="/projects/{slug}/workspace/tunnels",
|
||||||
|
title="List tunnels",
|
||||||
|
summary="Every public tunnel published by this workspace.",
|
||||||
|
auth="user",
|
||||||
|
params=[
|
||||||
|
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||||
|
],
|
||||||
|
sample_response={
|
||||||
|
"tunnels": [
|
||||||
|
{
|
||||||
|
"uid": "TUNNEL_UID",
|
||||||
|
"hostname": "8080-brave-otter.tunnel.pravda.education",
|
||||||
|
"label": "web",
|
||||||
|
"container_port": 8080,
|
||||||
|
"status": "active",
|
||||||
|
"cert_status": "valid",
|
||||||
|
"request_count": 12,
|
||||||
|
"bytes_out": 40960,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
endpoint(
|
||||||
|
id="workspace-tunnel-create",
|
||||||
|
method="POST",
|
||||||
|
path="/projects/{slug}/workspace/tunnels",
|
||||||
|
title="Create tunnel",
|
||||||
|
summary=(
|
||||||
|
"Publish a container port on a public HTTPS hostname. The URL is public "
|
||||||
|
"and unauthenticated. Refused past the tunnel limit."
|
||||||
|
),
|
||||||
|
auth="user",
|
||||||
|
params=[
|
||||||
|
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||||
|
field("container_port", "body", "integer", True, 8080, "Port inside the container."),
|
||||||
|
field("label", "body", "string", False, "web", "Human label."),
|
||||||
|
],
|
||||||
|
sample_response={
|
||||||
|
"ok": True,
|
||||||
|
"data": {
|
||||||
|
"uid": "TUNNEL_UID",
|
||||||
|
"hostname": "8080-brave-otter.tunnel.pravda.education",
|
||||||
|
"status": "pending",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
endpoint(
|
||||||
|
id="workspace-tunnel-delete",
|
||||||
|
method="POST",
|
||||||
|
path="/projects/{slug}/workspace/tunnels/{uid}/delete",
|
||||||
|
title="Delete tunnel",
|
||||||
|
summary="Remove a tunnel. The public URL stops serving immediately.",
|
||||||
|
auth="user",
|
||||||
|
params=[
|
||||||
|
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||||
|
field("uid", "path", "string", True, "TUNNEL_UID", "Tunnel uid."),
|
||||||
|
],
|
||||||
|
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
@ -8,7 +8,7 @@ import time
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from contextlib import asynccontextmanager, contextmanager
|
from contextlib import asynccontextmanager, contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request, WebSocket
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.exceptions import RequestValidationError
|
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.jobs.isslop.service import IsslopService
|
||||||
from devplacepy.services.gitea.service import IssueTrackerService
|
from devplacepy.services.gitea.service import IssueTrackerService
|
||||||
from devplacepy.services.containers.service import ContainerService
|
from devplacepy.services.containers.service import ContainerService
|
||||||
|
from devplacepy.services.containers.workspace_service import WorkspaceService
|
||||||
from devplacepy.services.xmlrpc import XmlrpcService
|
from devplacepy.services.xmlrpc import XmlrpcService
|
||||||
from devplacepy.services.audit import AuditService
|
from devplacepy.services.audit import AuditService
|
||||||
from devplacepy.services.audit import record as audit
|
from devplacepy.services.audit import record as audit
|
||||||
@ -274,6 +275,7 @@ async def lifespan(app: FastAPI):
|
|||||||
service_manager.register(PlanningReportService())
|
service_manager.register(PlanningReportService())
|
||||||
service_manager.register(IssueTrackerService())
|
service_manager.register(IssueTrackerService())
|
||||||
service_manager.register(ContainerService())
|
service_manager.register(ContainerService())
|
||||||
|
service_manager.register(WorkspaceService())
|
||||||
service_manager.register(XmlrpcService())
|
service_manager.register(XmlrpcService())
|
||||||
service_manager.register(AuditService())
|
service_manager.register(AuditService())
|
||||||
service_manager.register(PushService())
|
service_manager.register(PushService())
|
||||||
@ -615,6 +617,42 @@ async def response_timing(request: Request, call_next):
|
|||||||
return response
|
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)
|
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=5)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -885,3 +885,29 @@ class QuizImportForm(BaseModel):
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError("document must be valid JSON") from exc
|
raise ValueError("document must be valid JSON") from exc
|
||||||
return value
|
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)
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from devplacepy.routers.admin import (
|
|||||||
backups,
|
backups,
|
||||||
bots,
|
bots,
|
||||||
containers,
|
containers,
|
||||||
|
workspaces,
|
||||||
devii_tasks,
|
devii_tasks,
|
||||||
game,
|
game,
|
||||||
gateway_configs,
|
gateway_configs,
|
||||||
@ -42,3 +43,4 @@ router.include_router(devii_tasks.router)
|
|||||||
router.include_router(game.router)
|
router.include_router(game.router)
|
||||||
router.include_router(services.router, prefix="/services")
|
router.include_router(services.router, prefix="/services")
|
||||||
router.include_router(containers.router, prefix="/containers")
|
router.include_router(containers.router, prefix="/containers")
|
||||||
|
router.include_router(workspaces.router)
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from devplacepy.database import (
|
|||||||
build_pagination,
|
build_pagination,
|
||||||
get_post_counts_by_user_uids,
|
get_post_counts_by_user_uids,
|
||||||
invalidate_admins_cache,
|
invalidate_admins_cache,
|
||||||
|
is_account_active,
|
||||||
)
|
)
|
||||||
from devplacepy.utils import (
|
from devplacepy.utils import (
|
||||||
require_admin,
|
require_admin,
|
||||||
@ -196,7 +197,7 @@ async def admin_user_toggle(request: Request, uid: str):
|
|||||||
if _is_senior_admin(admin, user):
|
if _is_senior_admin(admin, user):
|
||||||
return _deny_senior(request, admin, uid, user, "admin.user.active.disable")
|
return _deny_senior(request, admin, uid, user, "admin.user.active.disable")
|
||||||
if user:
|
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"])
|
users.update({"uid": uid, "is_active": new_state}, ["uid"])
|
||||||
clear_user_cache(uid)
|
clear_user_cache(uid)
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
282
devplacepy/routers/admin/workspaces.py
Normal file
282
devplacepy/routers/admin/workspaces.py
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from devplacepy.database import db, get_table, get_users_by_uids
|
||||||
|
from devplacepy.dependencies import json_or_form
|
||||||
|
from devplacepy.models import (
|
||||||
|
WorkspaceFlagForm,
|
||||||
|
WorkspaceQuotaForm,
|
||||||
|
WorkspaceSuspendForm,
|
||||||
|
)
|
||||||
|
from devplacepy.responses import action_result, json_error, respond
|
||||||
|
from devplacepy.schemas import AdminWorkspacesOut
|
||||||
|
from devplacepy.seo import base_seo_context
|
||||||
|
from devplacepy.services.audit import record as audit
|
||||||
|
from devplacepy.services.containers import store
|
||||||
|
from devplacepy.services.containers.workspace import flags, provision, quota, tunnels
|
||||||
|
from devplacepy.utils import create_notification, generate_uid, not_found, require_admin
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _decorate(rows: list[dict]) -> list[dict]:
|
||||||
|
owner_uids = {row.get("workspace_owner_uid") for row in rows if row.get("workspace_owner_uid")}
|
||||||
|
owners = get_users_by_uids(list(owner_uids)) if owner_uids else {}
|
||||||
|
projects = {}
|
||||||
|
if "projects" in db.tables:
|
||||||
|
project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")}
|
||||||
|
for uid in project_uids:
|
||||||
|
found = get_table("projects").find_one(uid=uid)
|
||||||
|
if found:
|
||||||
|
projects[uid] = found
|
||||||
|
decorated = []
|
||||||
|
for row in rows:
|
||||||
|
view = provision.view(row)
|
||||||
|
owner = owners.get(row.get("workspace_owner_uid", "")) or {}
|
||||||
|
project = projects.get(row.get("project_uid", "")) or {}
|
||||||
|
view["owner_username"] = owner.get("username", "")
|
||||||
|
view["project_title"] = project.get("title", "")
|
||||||
|
view["project_slug"] = project.get("slug", "") or project.get("uid", "")
|
||||||
|
decorated.append(view)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def _all_workspaces() -> list[dict]:
|
||||||
|
return list(get_table("instances").find(is_workspace=1, deleted_at=None))
|
||||||
|
|
||||||
|
|
||||||
|
def _instance_or_404(uid: str) -> dict:
|
||||||
|
instance = store.get_instance(uid)
|
||||||
|
if not instance or not instance.get("is_workspace"):
|
||||||
|
raise not_found("Workspace not found")
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(request: Request, admin: dict, event_key: str, instance: dict, **extra):
|
||||||
|
audit.record(
|
||||||
|
request,
|
||||||
|
event_key,
|
||||||
|
user=admin,
|
||||||
|
target_type="instance",
|
||||||
|
target_uid=instance["uid"],
|
||||||
|
target_label=instance.get("name"),
|
||||||
|
summary=f"{admin['username']} {event_key} workspace {instance.get('name')}",
|
||||||
|
links=[audit.instance(instance["uid"], instance.get("name"))],
|
||||||
|
**extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/workspaces")
|
||||||
|
async def admin_workspaces(request: Request):
|
||||||
|
admin = require_admin(request)
|
||||||
|
if not isinstance(admin, dict):
|
||||||
|
return admin
|
||||||
|
context = {
|
||||||
|
"workspaces": _decorate(_all_workspaces()),
|
||||||
|
"flags": flags.list_flags(),
|
||||||
|
"admin_section": "workspaces",
|
||||||
|
"user": admin,
|
||||||
|
**base_seo_context(
|
||||||
|
request,
|
||||||
|
title="Workspaces - Admin",
|
||||||
|
description="Administer dev workspaces.",
|
||||||
|
robots="noindex,nofollow",
|
||||||
|
breadcrumbs=[
|
||||||
|
{"name": "Home", "url": "/feed"},
|
||||||
|
{"name": "Admin", "url": "/admin"},
|
||||||
|
{"name": "Workspaces", "url": "/admin/workspaces"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return respond(request, "admin_workspaces.html", context, model=AdminWorkspacesOut)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/workspaces/data")
|
||||||
|
async def admin_workspaces_data(request: Request):
|
||||||
|
admin = require_admin(request)
|
||||||
|
if not isinstance(admin, dict):
|
||||||
|
return admin
|
||||||
|
return JSONResponse(
|
||||||
|
{"workspaces": _decorate(_all_workspaces()), "flags": flags.list_flags()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/workspaces/{uid}/suspend")
|
||||||
|
async def admin_workspace_suspend(
|
||||||
|
request: Request,
|
||||||
|
uid: str,
|
||||||
|
data: Annotated[WorkspaceSuspendForm, Depends(json_or_form(WorkspaceSuspendForm))],
|
||||||
|
):
|
||||||
|
admin = require_admin(request)
|
||||||
|
if not isinstance(admin, dict):
|
||||||
|
return admin
|
||||||
|
instance = _instance_or_404(uid)
|
||||||
|
reason = (data.reason or "").strip()
|
||||||
|
if not reason:
|
||||||
|
return json_error("a reason is required and is shown to the owner", 400)
|
||||||
|
provision.suspend(instance, admin["uid"], reason)
|
||||||
|
_audit(request, admin, "container.workspace.suspend", instance, metadata={"reason": reason})
|
||||||
|
owner = instance.get("workspace_owner_uid", "")
|
||||||
|
if owner:
|
||||||
|
create_notification(
|
||||||
|
owner,
|
||||||
|
"workspace",
|
||||||
|
f"Workspace {instance.get('name', '')} was suspended: {reason}",
|
||||||
|
instance["uid"],
|
||||||
|
"/projects",
|
||||||
|
)
|
||||||
|
return action_result(request, "/admin/workspaces")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/workspaces/{uid}/unsuspend")
|
||||||
|
async def admin_workspace_unsuspend(request: Request, uid: str):
|
||||||
|
admin = require_admin(request)
|
||||||
|
if not isinstance(admin, dict):
|
||||||
|
return admin
|
||||||
|
instance = _instance_or_404(uid)
|
||||||
|
provision.unsuspend(instance)
|
||||||
|
_audit(request, admin, "container.workspace.unsuspend", instance)
|
||||||
|
owner = instance.get("workspace_owner_uid", "")
|
||||||
|
if owner:
|
||||||
|
create_notification(
|
||||||
|
owner,
|
||||||
|
"workspace",
|
||||||
|
f"Workspace {instance.get('name', '')} is available again.",
|
||||||
|
instance["uid"],
|
||||||
|
"/projects",
|
||||||
|
)
|
||||||
|
return action_result(request, "/admin/workspaces")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/workspaces/{uid}/stop")
|
||||||
|
async def admin_workspace_stop(request: Request, uid: str):
|
||||||
|
admin = require_admin(request)
|
||||||
|
if not isinstance(admin, dict):
|
||||||
|
return admin
|
||||||
|
instance = _instance_or_404(uid)
|
||||||
|
provision.stop(instance)
|
||||||
|
_audit(request, admin, "container.workspace.stop", instance)
|
||||||
|
return action_result(request, "/admin/workspaces")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/workspaces/{uid}/start")
|
||||||
|
async def admin_workspace_start(request: Request, uid: str):
|
||||||
|
admin = require_admin(request)
|
||||||
|
if not isinstance(admin, dict):
|
||||||
|
return admin
|
||||||
|
instance = _instance_or_404(uid)
|
||||||
|
store.update_instance(instance["uid"], {"desired_state": "running"})
|
||||||
|
_audit(request, admin, "container.workspace.resume", instance)
|
||||||
|
return action_result(request, "/admin/workspaces")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/workspaces/{uid}/delete")
|
||||||
|
async def admin_workspace_delete(request: Request, uid: str):
|
||||||
|
admin = require_admin(request)
|
||||||
|
if not isinstance(admin, dict):
|
||||||
|
return admin
|
||||||
|
instance = _instance_or_404(uid)
|
||||||
|
for row in tunnels.list_for_instance(instance["uid"]):
|
||||||
|
tunnels.soft_delete(row["uid"], admin["uid"])
|
||||||
|
store.delete_instance(instance["uid"], admin["uid"])
|
||||||
|
_audit(request, admin, "container.workspace.delete", instance)
|
||||||
|
return action_result(request, "/admin/workspaces")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/workspaces/{uid}/flag")
|
||||||
|
async def admin_workspace_flag(
|
||||||
|
request: Request,
|
||||||
|
uid: str,
|
||||||
|
data: Annotated[WorkspaceFlagForm, Depends(json_or_form(WorkspaceFlagForm))],
|
||||||
|
):
|
||||||
|
admin = require_admin(request)
|
||||||
|
if not isinstance(admin, dict):
|
||||||
|
return admin
|
||||||
|
instance = _instance_or_404(uid)
|
||||||
|
row = flags.raise_flag(
|
||||||
|
instance, data.kind or flags.KIND_MANUAL, data.severity, data.detail
|
||||||
|
)
|
||||||
|
_audit(request, admin, "container.workspace.flag.raise", instance,
|
||||||
|
metadata={"kind": data.kind, "severity": data.severity})
|
||||||
|
owner = instance.get("workspace_owner_uid", "")
|
||||||
|
if owner:
|
||||||
|
create_notification(
|
||||||
|
owner,
|
||||||
|
"workspace",
|
||||||
|
f"Workspace {instance.get('name', '')} was flagged: {data.detail or data.kind}",
|
||||||
|
instance["uid"],
|
||||||
|
"/projects",
|
||||||
|
)
|
||||||
|
return action_result(request, "/admin/workspaces", data=row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/workspaces/flags/{flag_uid}/resolve")
|
||||||
|
async def admin_flag_resolve(request: Request, flag_uid: str, status: str = "resolved"):
|
||||||
|
admin = require_admin(request)
|
||||||
|
if not isinstance(admin, dict):
|
||||||
|
return admin
|
||||||
|
if not flags.set_status(flag_uid, status, admin["uid"]):
|
||||||
|
raise not_found("Flag not found")
|
||||||
|
event = (
|
||||||
|
"container.workspace.flag.dismiss"
|
||||||
|
if status == "dismissed"
|
||||||
|
else "container.workspace.flag.resolve"
|
||||||
|
)
|
||||||
|
audit.record(
|
||||||
|
request,
|
||||||
|
event,
|
||||||
|
user=admin,
|
||||||
|
target_type="workspace_flag",
|
||||||
|
target_uid=flag_uid,
|
||||||
|
summary=f"{admin['username']} set flag {flag_uid} to {status}",
|
||||||
|
)
|
||||||
|
return action_result(request, "/admin/workspaces")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/workspaces/quota")
|
||||||
|
async def admin_workspace_quota(
|
||||||
|
request: Request,
|
||||||
|
data: Annotated[WorkspaceQuotaForm, Depends(json_or_form(WorkspaceQuotaForm))],
|
||||||
|
):
|
||||||
|
admin = require_admin(request)
|
||||||
|
if not isinstance(admin, dict):
|
||||||
|
return admin
|
||||||
|
if not data.owner_id:
|
||||||
|
return json_error("owner_id is required", 400)
|
||||||
|
table = get_table(quota.RULES_TABLE)
|
||||||
|
existing = table.find_one(
|
||||||
|
owner_kind="user", owner_id=data.owner_id, deleted_at=None
|
||||||
|
)
|
||||||
|
payload = {key: getattr(data, key) for key in quota.RULE_COLUMNS}
|
||||||
|
if existing:
|
||||||
|
table.update({"uid": existing["uid"], "label": data.label, **payload}, ["uid"])
|
||||||
|
uid = existing["uid"]
|
||||||
|
else:
|
||||||
|
uid = generate_uid()
|
||||||
|
table.insert(
|
||||||
|
{
|
||||||
|
"uid": uid,
|
||||||
|
"owner_kind": "user",
|
||||||
|
"owner_id": data.owner_id,
|
||||||
|
"label": data.label,
|
||||||
|
"created_at": "",
|
||||||
|
"updated_at": "",
|
||||||
|
"deleted_at": None,
|
||||||
|
"deleted_by": None,
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
audit.record(
|
||||||
|
request,
|
||||||
|
"container.workspace.settings.update",
|
||||||
|
user=admin,
|
||||||
|
target_type="user",
|
||||||
|
target_uid=data.owner_id,
|
||||||
|
summary=f"{admin['username']} updated workspace quota",
|
||||||
|
metadata=payload,
|
||||||
|
)
|
||||||
|
return action_result(request, "/admin/workspaces", data={"uid": uid, **payload})
|
||||||
@ -4,7 +4,7 @@ import logging
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from fastapi import Depends, APIRouter, Request
|
from fastapi import Depends, APIRouter, Request
|
||||||
from fastapi.responses import HTMLResponse
|
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.templating import templates
|
||||||
from devplacepy.utils import (
|
from devplacepy.utils import (
|
||||||
verify_password_async,
|
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"]):
|
if not user or not await verify_password_async(password, user["password_hash"]):
|
||||||
errors.append("Invalid email or password")
|
errors.append("Invalid email or password")
|
||||||
elif not user.get("is_active", True):
|
elif not is_account_active(user):
|
||||||
errors.append("Account is deactivated")
|
errors.append("Account is deactivated")
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from typing import Annotated
|
|||||||
from fastapi import Depends, APIRouter, Request
|
from fastapi import Depends, APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse
|
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.utils import verify_password_async, get_current_user
|
||||||
from devplacepy.models import LoginForm
|
from devplacepy.models import LoginForm
|
||||||
from devplacepy.dependencies import json_or_form
|
from devplacepy.dependencies import json_or_form
|
||||||
@ -59,7 +59,7 @@ async def token(
|
|||||||
status_code=401,
|
status_code=401,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not user.get("is_active", True):
|
if not is_account_active(user):
|
||||||
audit.record(
|
audit.record(
|
||||||
request,
|
request,
|
||||||
"auth.token.failure",
|
"auth.token.failure",
|
||||||
|
|||||||
@ -9,7 +9,7 @@ from fastapi.responses import Response
|
|||||||
|
|
||||||
from devplacepy.cache import TTLCache
|
from devplacepy.cache import TTLCache
|
||||||
from devplacepy.config import SECONDS_PER_DAY
|
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.utils import verify_password_async, register_account_async
|
||||||
from devplacepy.services.audit import record as audit
|
from devplacepy.services.audit import record as audit
|
||||||
from devplacepy.services.devrant.params import merge_params
|
from devplacepy.services.devrant.params import merge_params
|
||||||
@ -42,7 +42,7 @@ async def auth_token(request: Request):
|
|||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
not user
|
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"])
|
or not await verify_password_async(password, user["password_hash"])
|
||||||
):
|
):
|
||||||
audit.record(
|
audit.record(
|
||||||
|
|||||||
@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from devplacepy.routers.projects.containers import instances, schedules
|
from devplacepy.routers.projects.containers import instances, schedules, workspace
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(instances.router)
|
router.include_router(instances.router)
|
||||||
router.include_router(schedules.router)
|
router.include_router(schedules.router)
|
||||||
|
router.include_router(workspace.router)
|
||||||
|
|||||||
278
devplacepy/routers/projects/containers/workspace.py
Normal file
278
devplacepy/routers/projects/containers/workspace.py
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Form, Request, WebSocket
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from devplacepy.content import (
|
||||||
|
can_manage_workspace,
|
||||||
|
can_open_workspace,
|
||||||
|
)
|
||||||
|
from devplacepy.database import get_table, resolve_by_slug
|
||||||
|
from devplacepy.models import TunnelForm
|
||||||
|
from devplacepy.responses import action_result, json_error, respond
|
||||||
|
from devplacepy.schemas import WorkspaceOut
|
||||||
|
from devplacepy.services.audit import record as audit
|
||||||
|
from devplacepy.services.containers import activity, forward, store
|
||||||
|
from devplacepy.services.containers.workspace import provision, quota, tunnels
|
||||||
|
from devplacepy.services.containers.workspace.provision import WorkspaceError
|
||||||
|
from devplacepy.utils import not_found, require_user
|
||||||
|
|
||||||
|
from ._shared import audit_instance
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _project_or_404(slug: str) -> dict:
|
||||||
|
project = resolve_by_slug(get_table("projects"), slug)
|
||||||
|
if not project:
|
||||||
|
raise not_found("Project not found")
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace_or_404(project: dict, user: dict) -> dict:
|
||||||
|
instance = provision.find_for_project(project["uid"], user["uid"])
|
||||||
|
if not instance:
|
||||||
|
raise not_found("No workspace for this project")
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
def _guard(request: Request, project: dict, user: dict, event_key: str) -> None:
|
||||||
|
if can_open_workspace(project, user):
|
||||||
|
return
|
||||||
|
audit.record(
|
||||||
|
request,
|
||||||
|
event_key,
|
||||||
|
user=user,
|
||||||
|
target_type="project",
|
||||||
|
target_uid=project["uid"],
|
||||||
|
target_label=project.get("title"),
|
||||||
|
summary=f"{user['username']} denied workspace access",
|
||||||
|
result="denied",
|
||||||
|
)
|
||||||
|
raise not_found("Workspaces are not available for this project")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{slug}/workspace")
|
||||||
|
async def workspace_page(request: Request, slug: str):
|
||||||
|
user = require_user(request)
|
||||||
|
if isinstance(user, Response):
|
||||||
|
return user
|
||||||
|
project = _project_or_404(slug)
|
||||||
|
_guard(request, project, user, "container.workspace.open")
|
||||||
|
instance = provision.find_for_project(project["uid"], user["uid"])
|
||||||
|
limits = quota.resolve(user["uid"])
|
||||||
|
context = {
|
||||||
|
"project": project,
|
||||||
|
"workspace": provision.view(instance) if instance else None,
|
||||||
|
"has_workspace": bool(instance),
|
||||||
|
"viewer_can_workspace": True,
|
||||||
|
"workspace_count": provision.count_for_owner(user["uid"]),
|
||||||
|
"max_workspaces": limits.max_workspaces,
|
||||||
|
"editor_url": (
|
||||||
|
f"/projects/{slug}/containers/instances/{instance['uid']}/code/"
|
||||||
|
if instance
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"user": user,
|
||||||
|
}
|
||||||
|
return respond(request, "workspace.html", context, model=WorkspaceOut)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{slug}/workspace")
|
||||||
|
async def workspace_open(request: Request, slug: str):
|
||||||
|
user = require_user(request)
|
||||||
|
if isinstance(user, Response):
|
||||||
|
return user
|
||||||
|
project = _project_or_404(slug)
|
||||||
|
_guard(request, project, user, "container.workspace.quota.block")
|
||||||
|
try:
|
||||||
|
instance = await provision.ensure(project, user)
|
||||||
|
instance = provision.resume(instance)
|
||||||
|
except WorkspaceError as error:
|
||||||
|
audit.record(
|
||||||
|
request,
|
||||||
|
"container.workspace.quota.block",
|
||||||
|
user=user,
|
||||||
|
target_type="project",
|
||||||
|
target_uid=project["uid"],
|
||||||
|
summary=str(error),
|
||||||
|
result="denied",
|
||||||
|
)
|
||||||
|
return json_error(str(error), 400)
|
||||||
|
audit_instance(
|
||||||
|
request,
|
||||||
|
user,
|
||||||
|
"container.workspace.create",
|
||||||
|
instance,
|
||||||
|
project,
|
||||||
|
summary=f"{user['username']} opened workspace for {project.get('title')}",
|
||||||
|
)
|
||||||
|
provision.write_manifest(instance)
|
||||||
|
return action_result(
|
||||||
|
request, f"/projects/{slug}/workspace", data=provision.view(instance)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{slug}/workspace/stop")
|
||||||
|
async def workspace_stop(request: Request, slug: str):
|
||||||
|
user = require_user(request)
|
||||||
|
if isinstance(user, Response):
|
||||||
|
return user
|
||||||
|
project = _project_or_404(slug)
|
||||||
|
instance = _workspace_or_404(project, user)
|
||||||
|
if not can_manage_workspace(instance, project, user):
|
||||||
|
return json_error("Not allowed to manage this workspace", 403)
|
||||||
|
provision.stop(instance)
|
||||||
|
audit_instance(request, user, "container.workspace.stop", instance, project)
|
||||||
|
return action_result(request, f"/projects/{slug}/workspace")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{slug}/workspace/delete")
|
||||||
|
async def workspace_delete(request: Request, slug: str):
|
||||||
|
user = require_user(request)
|
||||||
|
if isinstance(user, Response):
|
||||||
|
return user
|
||||||
|
project = _project_or_404(slug)
|
||||||
|
instance = _workspace_or_404(project, user)
|
||||||
|
if not can_manage_workspace(instance, project, user):
|
||||||
|
return json_error("Not allowed to manage this workspace", 403)
|
||||||
|
for row in tunnels.list_for_instance(instance["uid"]):
|
||||||
|
tunnels.soft_delete(row["uid"], user["uid"])
|
||||||
|
store.delete_instance(instance["uid"], user["uid"])
|
||||||
|
audit_instance(request, user, "container.workspace.delete", instance, project)
|
||||||
|
return action_result(request, f"/projects/{slug}/workspace")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{slug}/workspace/tunnels")
|
||||||
|
async def tunnel_list(request: Request, slug: str):
|
||||||
|
user = require_user(request)
|
||||||
|
if isinstance(user, Response):
|
||||||
|
return user
|
||||||
|
project = _project_or_404(slug)
|
||||||
|
instance = _workspace_or_404(project, user)
|
||||||
|
if not can_manage_workspace(instance, project, user):
|
||||||
|
return json_error("Not allowed to manage this workspace", 403)
|
||||||
|
return {"tunnels": tunnels.list_for_instance(instance["uid"])}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{slug}/workspace/tunnels")
|
||||||
|
async def tunnel_create(
|
||||||
|
request: Request, slug: str, data: Annotated[TunnelForm, Form()]
|
||||||
|
):
|
||||||
|
user = require_user(request)
|
||||||
|
if isinstance(user, Response):
|
||||||
|
return user
|
||||||
|
project = _project_or_404(slug)
|
||||||
|
instance = _workspace_or_404(project, user)
|
||||||
|
if not can_manage_workspace(instance, project, user):
|
||||||
|
return json_error("Not allowed to manage this workspace", 403)
|
||||||
|
if data.container_port <= 0:
|
||||||
|
return json_error("container_port must be between 1 and 65535", 400)
|
||||||
|
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
|
||||||
|
if limits.max_tunnels and tunnels.count_for_instance(
|
||||||
|
instance["uid"]
|
||||||
|
) >= limits.max_tunnels:
|
||||||
|
return json_error(f"tunnel limit reached ({limits.max_tunnels})", 400)
|
||||||
|
row = tunnels.create(instance, data.label, data.container_port, user["uid"])
|
||||||
|
if not row:
|
||||||
|
return json_error("could not create tunnel", 400)
|
||||||
|
audit_instance(
|
||||||
|
request,
|
||||||
|
user,
|
||||||
|
"container.tunnel.create",
|
||||||
|
instance,
|
||||||
|
project,
|
||||||
|
metadata={"hostname": row["hostname"], "port": data.container_port},
|
||||||
|
)
|
||||||
|
provision.write_manifest(instance)
|
||||||
|
return action_result(request, f"/projects/{slug}/workspace", data=row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{slug}/workspace/tunnels/{uid}")
|
||||||
|
@router.post("/{slug}/workspace/tunnels/{uid}/delete")
|
||||||
|
async def tunnel_delete(request: Request, slug: str, uid: str):
|
||||||
|
user = require_user(request)
|
||||||
|
if isinstance(user, Response):
|
||||||
|
return user
|
||||||
|
project = _project_or_404(slug)
|
||||||
|
instance = _workspace_or_404(project, user)
|
||||||
|
if not can_manage_workspace(instance, project, user):
|
||||||
|
return json_error("Not allowed to manage this workspace", 403)
|
||||||
|
row = tunnels.get(uid)
|
||||||
|
if not row or row.get("instance_uid") != instance["uid"]:
|
||||||
|
raise not_found("Tunnel not found")
|
||||||
|
tunnels.soft_delete(uid, user["uid"])
|
||||||
|
audit_instance(
|
||||||
|
request,
|
||||||
|
user,
|
||||||
|
"container.tunnel.delete",
|
||||||
|
instance,
|
||||||
|
project,
|
||||||
|
metadata={"hostname": row.get("hostname", "")},
|
||||||
|
)
|
||||||
|
provision.write_manifest(instance)
|
||||||
|
return action_result(request, f"/projects/{slug}/workspace")
|
||||||
|
|
||||||
|
|
||||||
|
def _editor_guard(request: Request, slug: str, uid: str):
|
||||||
|
user = require_user(request)
|
||||||
|
if isinstance(user, Response):
|
||||||
|
return None, None, user
|
||||||
|
project = _project_or_404(slug)
|
||||||
|
instance = store.get_instance(uid)
|
||||||
|
if not instance or not instance.get("is_workspace"):
|
||||||
|
raise not_found("Workspace not found")
|
||||||
|
if not can_manage_workspace(instance, project, user):
|
||||||
|
return None, None, json_error("Not allowed to open this workspace", 403)
|
||||||
|
return project, instance, None
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route(
|
||||||
|
"/{slug}/containers/instances/{uid}/code", methods=forward.METHODS
|
||||||
|
)
|
||||||
|
@router.api_route(
|
||||||
|
"/{slug}/containers/instances/{uid}/code/{path:path}", methods=forward.METHODS
|
||||||
|
)
|
||||||
|
async def editor_proxy(request: Request, slug: str, uid: str, path: str = ""):
|
||||||
|
project, instance, denial = _editor_guard(request, slug, uid)
|
||||||
|
if denial is not None:
|
||||||
|
return denial
|
||||||
|
if instance.get("suspended_at"):
|
||||||
|
return Response("this workspace is suspended", status_code=403)
|
||||||
|
if instance.get("status") != store.ST_RUNNING:
|
||||||
|
return Response("this workspace is not running", status_code=409)
|
||||||
|
host, port = provision.editor_target(instance)
|
||||||
|
if not host or not port:
|
||||||
|
return Response("the editor has no reachable port", status_code=502)
|
||||||
|
activity.touch(instance["uid"])
|
||||||
|
prefix = f"/projects/{slug}/containers/instances/{uid}/code"
|
||||||
|
return await forward.proxy_http(request, host, port, path, prefix=prefix)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/{slug}/containers/instances/{uid}/code")
|
||||||
|
@router.websocket("/{slug}/containers/instances/{uid}/code/{path:path}")
|
||||||
|
async def editor_proxy_ws(
|
||||||
|
websocket: WebSocket, slug: str, uid: str, path: str = ""
|
||||||
|
):
|
||||||
|
from devplacepy.utils import get_current_user
|
||||||
|
|
||||||
|
user = get_current_user(websocket)
|
||||||
|
project = resolve_by_slug(get_table("projects"), slug)
|
||||||
|
instance = store.get_instance(uid)
|
||||||
|
if not user or not project or not instance or not instance.get("is_workspace"):
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
if not can_manage_workspace(instance, project, user):
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
if instance.get("suspended_at") or instance.get("status") != store.ST_RUNNING:
|
||||||
|
await websocket.close(code=1011)
|
||||||
|
return
|
||||||
|
host, port = provision.editor_target(instance)
|
||||||
|
if not host or not port:
|
||||||
|
await websocket.close(code=1011)
|
||||||
|
return
|
||||||
|
activity.touch(instance["uid"])
|
||||||
|
await forward.proxy_ws(websocket, host, port, path)
|
||||||
@ -1,70 +1,18 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import httpx
|
|
||||||
import websockets
|
|
||||||
from fastapi import APIRouter, Request, WebSocket
|
from fastapi import APIRouter, Request, WebSocket
|
||||||
from starlette.responses import Response
|
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.audit import record as audit
|
||||||
|
from devplacepy.services.containers import api, forward, store
|
||||||
|
from devplacepy.utils import not_found
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
HOP_HEADERS = {
|
METHODS = forward.METHODS
|
||||||
"connection",
|
|
||||||
"keep-alive",
|
|
||||||
"proxy-authenticate",
|
|
||||||
"proxy-authorization",
|
|
||||||
"te",
|
|
||||||
"trailers",
|
|
||||||
"transfer-encoding",
|
|
||||||
"upgrade",
|
|
||||||
"host",
|
|
||||||
"content-length",
|
|
||||||
"content-encoding",
|
|
||||||
}
|
|
||||||
METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
|
||||||
|
|
||||||
|
|
||||||
def _forward_headers(request: Request, prefix: str) -> dict:
|
|
||||||
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_HEADERS}
|
|
||||||
headers["X-Forwarded-Prefix"] = prefix
|
|
||||||
headers["X-Script-Name"] = prefix
|
|
||||||
headers["X-Forwarded-Host"] = request.headers.get(
|
|
||||||
"host", request.url.hostname or ""
|
|
||||||
)
|
|
||||||
headers["X-Forwarded-Proto"] = request.headers.get(
|
|
||||||
"x-forwarded-proto", request.url.scheme
|
|
||||||
)
|
|
||||||
headers["Accept-Encoding"] = "identity"
|
|
||||||
return headers
|
|
||||||
|
|
||||||
|
|
||||||
def _inject_base(body: bytes, prefix: str) -> bytes:
|
|
||||||
lowered = body.lower()
|
|
||||||
if b"<base" in lowered:
|
|
||||||
return body
|
|
||||||
tag = f'<base href="{prefix}/">'.encode()
|
|
||||||
head = lowered.find(b"<head")
|
|
||||||
anchor = (
|
|
||||||
lowered.find(b">", head)
|
|
||||||
if head != -1
|
|
||||||
else lowered.find(b">", lowered.find(b"<html"))
|
|
||||||
)
|
|
||||||
if anchor == -1:
|
|
||||||
return tag + body
|
|
||||||
return body[: anchor + 1] + tag + body[anchor + 1 :]
|
|
||||||
|
|
||||||
|
|
||||||
def _rewrite_location(value: str, prefix: str) -> str:
|
|
||||||
if value.startswith("/") and not value.startswith("//"):
|
|
||||||
return prefix + value
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve(slug: str):
|
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}",
|
summary=f"request proxied to instance {instance.get('name')} via ingress {slug}",
|
||||||
links=[audit.instance(instance["uid"], instance.get("name"))],
|
links=[audit.instance(instance["uid"], instance.get("name"))],
|
||||||
)
|
)
|
||||||
prefix = f"/p/{slug}"
|
return await forward.proxy_http(
|
||||||
url = f"http://{host}:{port}/{path}"
|
request, host, port, path, prefix=f"/p/{slug}", timeout=60.0
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
for cookie in upstream.headers.get_list("set-cookie"):
|
|
||||||
response.headers.append("set-cookie", cookie)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/{slug}")
|
@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:
|
if instance is None or not host or not port:
|
||||||
await websocket.close(code=1011)
|
await websocket.close(code=1011)
|
||||||
return
|
return
|
||||||
upstream_url = f"ws://{host}:{port}/{path}"
|
|
||||||
if websocket.url.query:
|
|
||||||
upstream_url += f"?{websocket.url.query}"
|
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
audit.record(
|
audit.record(
|
||||||
websocket,
|
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}",
|
summary=f"websocket proxied to instance {instance.get('name')} via ingress {slug}",
|
||||||
links=[audit.instance(instance["uid"], instance.get("name"))],
|
links=[audit.instance(instance["uid"], instance.get("name"))],
|
||||||
)
|
)
|
||||||
try:
|
await forward.proxy_ws(websocket, host, port, path, accepted=True)
|
||||||
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())
|
|
||||||
|
|||||||
67
devplacepy/routers/tunnel.py
Normal file
67
devplacepy/routers/tunnel.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, WebSocket
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from devplacepy.services.containers import activity, api, forward, store
|
||||||
|
from devplacepy.services.containers.workspace import naming, tunnels
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
METHODS = forward.METHODS
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(host: str):
|
||||||
|
if not naming.is_tunnel_host(host):
|
||||||
|
return None, None, None, None
|
||||||
|
row = tunnels.by_hostname(host)
|
||||||
|
if not row or row.get("status") not in tunnels.SERVING_STATUSES:
|
||||||
|
return None, None, None, None
|
||||||
|
instance = store.get_instance(row.get("instance_uid", ""))
|
||||||
|
if not instance or instance.get("deleted_at"):
|
||||||
|
return None, None, None, None
|
||||||
|
if instance.get("suspended_at"):
|
||||||
|
return row, instance, None, None
|
||||||
|
if instance.get("status") != store.ST_RUNNING:
|
||||||
|
return row, instance, None, None
|
||||||
|
gateway, _ = api.proxy_target(instance)
|
||||||
|
host_port = _published_host_port(instance, int(row.get("container_port") or 0))
|
||||||
|
return row, instance, gateway, host_port
|
||||||
|
|
||||||
|
|
||||||
|
def _published_host_port(instance: dict, container_port: int) -> int:
|
||||||
|
import json
|
||||||
|
|
||||||
|
for mapping in json.loads(instance.get("ports_json") or "[]"):
|
||||||
|
if int(mapping.get("container") or 0) == container_port:
|
||||||
|
return int(mapping.get("host") or 0)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_http(request: Request, path: str) -> Response:
|
||||||
|
host = request.headers.get("host", "")
|
||||||
|
row, instance, gateway, port = resolve(host)
|
||||||
|
if row is None:
|
||||||
|
return Response("no tunnel is published at this address", status_code=404)
|
||||||
|
if instance is not None and instance.get("suspended_at"):
|
||||||
|
return Response("this workspace is suspended", status_code=403)
|
||||||
|
if not gateway or not port:
|
||||||
|
return Response("the tunnel has no reachable port", status_code=502)
|
||||||
|
response = await forward.proxy_http(request, gateway, port, path)
|
||||||
|
size = len(response.body) if hasattr(response, "body") and response.body else 0
|
||||||
|
activity.touch(instance["uid"], egress_bytes=size)
|
||||||
|
tunnels.record_hit(row["uid"], size)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_ws(websocket: WebSocket, path: str) -> None:
|
||||||
|
host = websocket.headers.get("host", "")
|
||||||
|
row, instance, gateway, port = resolve(host)
|
||||||
|
if row is None or instance is None or not gateway or not port:
|
||||||
|
await websocket.close(code=1011)
|
||||||
|
return
|
||||||
|
activity.touch(instance["uid"])
|
||||||
|
await forward.proxy_ws(websocket, gateway, port, path)
|
||||||
@ -71,10 +71,15 @@ from devplacepy.schemas.containers import (
|
|||||||
AdminContainerEditOut,
|
AdminContainerEditOut,
|
||||||
AdminContainerInstanceOut,
|
AdminContainerInstanceOut,
|
||||||
AdminContainersOut,
|
AdminContainersOut,
|
||||||
|
AdminWorkspacesOut,
|
||||||
BotFrameOut,
|
BotFrameOut,
|
||||||
ContainersOut,
|
ContainersOut,
|
||||||
InstanceOut,
|
InstanceOut,
|
||||||
ScheduleOut,
|
ScheduleOut,
|
||||||
|
TunnelOut,
|
||||||
|
WorkspaceFlagOut,
|
||||||
|
WorkspaceOut,
|
||||||
|
WorkspaceViewOut,
|
||||||
)
|
)
|
||||||
from devplacepy.schemas.jobs import (
|
from devplacepy.schemas.jobs import (
|
||||||
DbQueryJobOut,
|
DbQueryJobOut,
|
||||||
|
|||||||
@ -99,3 +99,75 @@ class AdminBotsOut(_Out):
|
|||||||
service_status: str = ""
|
service_status: str = ""
|
||||||
admin_section: Optional[str] = None
|
admin_section: Optional[str] = None
|
||||||
user: Optional[Any] = 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
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import secrets
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
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.config import SECONDS_PER_DAY
|
||||||
from devplacepy.utils import generate_uid
|
from devplacepy.utils import generate_uid
|
||||||
|
|
||||||
@ -77,7 +77,7 @@ def resolve_token(token: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
user = get_table("users").find_one(uid=row.get("user_uid"))
|
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 None
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|||||||
@ -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 **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 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 **`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`**.
|
- 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`).
|
- 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`.
|
- 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.
|
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.
|
**The agents are gateway-only:** `dpc`/`d.py` and `botje.py`/`bot.py` use a single `molodetz` backend pointed at `DEVPLACE_OPENAI_URL` (the gateway); the former direct `api.deepseek.com` fallback backend was removed so every in-container AI call is ledgered under the run-as user and nothing bypasses `gateway_usage_ledger`. `pagent`/`.vimrc` already posted to the gateway URL (using `DEEPSEEK_API_KEY` only as a key fallback, never the DeepSeek endpoint). Rebuild the image (`make ppy`) for the change to reach running containers.
|
||||||
|
|
||||||
|
## Dev Workspaces (`workspace/`, `workspace_service.py`, `activity.py`, `forward.py`)
|
||||||
|
|
||||||
|
A **workspace** is a member-facing container running `code-server`, layered on this same runtime. The
|
||||||
|
admin container manager's authorization is unchanged; workspaces add their own narrower predicates.
|
||||||
|
|
||||||
|
**Two planes, deliberately distinct.**
|
||||||
|
|
||||||
|
| | Plane A: editor | Plane B: user's servers |
|
||||||
|
|---|---|---|
|
||||||
|
| Entry | `/projects/{slug}/containers/instances/{uid}/code/...` | `{port}-{name}.tunnel.pravda.education` |
|
||||||
|
| Auth | session + `can_manage_workspace` | none, public by design |
|
||||||
|
| Backend | code-server, `--auth none`, bound in-container | whatever the user runs |
|
||||||
|
|
||||||
|
Plane B routing is **one static molohttp site** `*.tunnel.pravda.education -> 127.0.0.1:10500`;
|
||||||
|
DevPlace resolves the instance from the `Host` header. There is no molohttp object per tunnel.
|
||||||
|
`TunnelDispatchMiddleware` (`main.py`) is ASGI-level and pre-empts the router for both HTTP and
|
||||||
|
WebSocket, so a tunnel host can never render the application. molohttp's `HostIndex` glob matches
|
||||||
|
**exactly one label**, which is why the pattern is `{port}-{name}`, never `{port}.{name}`.
|
||||||
|
|
||||||
|
**One forwarding core.** `forward.py` owns header filtering, prefix stripping, `Location` rewriting,
|
||||||
|
`<base>` injection and the bidirectional WS pump. `/p/{slug}`, the editor route and the tunnel route
|
||||||
|
all call it. Never write a second proxy.
|
||||||
|
|
||||||
|
**Editor persistence.** code-server's user-data and extensions live in
|
||||||
|
`config.WORKSPACE_STATE_DIR/<instance uid>`, bind-mounted at `WORKSPACE_STATE_MOUNT`, so extensions
|
||||||
|
survive container recreation. `api.editor_command` builds the argv; `run_spec_for` prefers it over
|
||||||
|
the boot-script/boot-command chain when `is_workspace` and `editor_port` are set.
|
||||||
|
|
||||||
|
**Activity and egress are the presence pattern.** `activity.py` keeps a per-worker monotonic dict and
|
||||||
|
writes at most once per `WORKSPACE_ACTIVITY_WRITE_SECONDS`, accumulating egress and request counts
|
||||||
|
into one atomic `COALESCE` UPDATE (`store.record_activity`). Both proxy planes call `touch`; because
|
||||||
|
plane B traverses DevPlace, public traffic is observed directly rather than inferred.
|
||||||
|
|
||||||
|
**`workspace/` package.** `quota.py` resolves limits instance -> user rule -> setting -> default
|
||||||
|
through ONE resolver (never read a workspace setting at a call site). `flags.py` is the abuse ledger
|
||||||
|
and is **idempotent per `(instance_uid, kind)` while a flag is open**, so a sustained condition is one
|
||||||
|
row, not one per tick. `naming.py` generates faker labels with collision retry and owns the hostname
|
||||||
|
patterns plus `is_tunnel_host`. `tunnels.py` is CRUD with revive-not-duplicate. `provision.py` is the
|
||||||
|
create/resume/stop/suspend/view surface and writes `/app/.devplace/tunnels.json`.
|
||||||
|
|
||||||
|
**`WorkspaceService`** is the only new service: lock-owner, `default_enabled=False`, four wrapped
|
||||||
|
phases (disk sample on its own slow cadence, flag evaluation, lifecycle, purge sweep). It is a
|
||||||
|
reconciler, not a `JobService`.
|
||||||
|
|
||||||
|
**Two contracts that bite:**
|
||||||
|
- A `ConfigField` with `type="select"` needs `options=[{"value": ..., "label": ...}]`. Plain strings
|
||||||
|
crash `docs_api.build_services_group`, which `docs_search` indexes, so the whole docs search page
|
||||||
|
500s. No other service used `select` before this one.
|
||||||
|
- A route taking `Annotated[Form, Form()]` validates **before** the handler's auth guard, so a
|
||||||
|
required field makes an anonymous request 422 instead of 401 and `tests/api/auth/matrix.py` fails.
|
||||||
|
Give the field a default and validate it inside the handler after `require_user`.
|
||||||
|
|
||||||
|
**Admin console** is `/admin/workspaces` (`routers/admin/workspaces.py`, `admin_workspaces.html`):
|
||||||
|
list, start/stop, suspend/unsuspend with a required reason, raise/resolve/dismiss flags, per-user
|
||||||
|
quota rules. `base_seo_context` takes `breadcrumbs`/`schemas`, not `canonical`/`schema`.
|
||||||
|
|
||||||
|
**Devii** has 15 tools under `handler="workspace"`; five are in `CONFIRM_REQUIRED` and every one of
|
||||||
|
them declares a `confirm` param (schemas are `additionalProperties: false`, so a gated tool without it
|
||||||
|
loops forever).
|
||||||
|
|
||||||
|
**Toolchains in `ppy`.** Rust (rustup), Nim (choosenim), Swift (swiftly, then the toolchain is moved
|
||||||
|
to a fixed `/opt/swift/toolchain` because swiftly's proxy resolves against `$HOME` and breaks for
|
||||||
|
`pravda` at runtime). The choosenim installer **exits 1 even on success**, so its `RUN` ends with
|
||||||
|
`|| true` plus a real version check. The build smoke test must not pipe (`cmd | head -1` returns
|
||||||
|
`head`'s status and masks a broken toolchain - this hid a non-working Swift through a full build).
|
||||||
|
`/etc/profile.d/devplace-toolchains.sh` re-exports the PATH because a login shell resets it, which is
|
||||||
|
what the container terminal uses.
|
||||||
|
|||||||
56
devplacepy/services/containers/activity.py
Normal file
56
devplacepy/services/containers/activity.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from devplacepy.config import WORKSPACE_ACTIVITY_WRITE_SECONDS
|
||||||
|
from devplacepy.services.containers import store
|
||||||
|
|
||||||
|
_last_write: dict[str, float] = {}
|
||||||
|
_pending_egress: dict[str, int] = {}
|
||||||
|
_pending_requests: dict[str, int] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def touch(instance_uid: str, egress_bytes: int = 0) -> None:
|
||||||
|
if not instance_uid:
|
||||||
|
return
|
||||||
|
if egress_bytes > 0:
|
||||||
|
_pending_egress[instance_uid] = _pending_egress.get(instance_uid, 0) + egress_bytes
|
||||||
|
_pending_requests[instance_uid] = _pending_requests.get(instance_uid, 0) + 1
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - _last_write.get(instance_uid, 0.0) < WORKSPACE_ACTIVITY_WRITE_SECONDS:
|
||||||
|
return
|
||||||
|
_last_write[instance_uid] = now
|
||||||
|
flush(instance_uid)
|
||||||
|
|
||||||
|
|
||||||
|
def flush(instance_uid: str) -> None:
|
||||||
|
egress = _pending_egress.pop(instance_uid, 0)
|
||||||
|
requests = _pending_requests.pop(instance_uid, 0)
|
||||||
|
store.record_activity(
|
||||||
|
instance_uid,
|
||||||
|
datetime.now(timezone.utc).isoformat(),
|
||||||
|
egress,
|
||||||
|
requests,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def flush_all() -> None:
|
||||||
|
for instance_uid in list(_pending_requests) + list(_pending_egress):
|
||||||
|
if instance_uid in _pending_requests or instance_uid in _pending_egress:
|
||||||
|
flush(instance_uid)
|
||||||
|
|
||||||
|
|
||||||
|
def pending(instance_uid: str) -> tuple[int, int]:
|
||||||
|
return (
|
||||||
|
_pending_egress.get(instance_uid, 0),
|
||||||
|
_pending_requests.get(instance_uid, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def forget(instance_uid: str) -> None:
|
||||||
|
_last_write.pop(instance_uid, None)
|
||||||
|
_pending_egress.pop(instance_uid, None)
|
||||||
|
_pending_requests.pop(instance_uid, None)
|
||||||
@ -10,6 +10,7 @@ from devplacepy import config, project_files, stealth
|
|||||||
from devplacepy.services.containers import store
|
from devplacepy.services.containers import store
|
||||||
from devplacepy.services.containers.backend.base import (
|
from devplacepy.services.containers.backend.base import (
|
||||||
WORKSPACE_MOUNT,
|
WORKSPACE_MOUNT,
|
||||||
|
WORKSPACE_STATE_MOUNT,
|
||||||
Mount,
|
Mount,
|
||||||
PortMapping,
|
PortMapping,
|
||||||
RunSpec,
|
RunSpec,
|
||||||
@ -414,7 +415,7 @@ def pravda_env(instance: dict) -> dict:
|
|||||||
break
|
break
|
||||||
slug = instance.get("ingress_slug") or ""
|
slug = instance.get("ingress_slug") or ""
|
||||||
ingress_url = (f"{base_url}/p/{slug}" if base_url else f"/p/{slug}") if slug else ""
|
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_BASE_URL": base_url,
|
||||||
"DEVPLACE_OPENAI_URL": f"{base_url}/openai/v1" if base_url else "",
|
"DEVPLACE_OPENAI_URL": f"{base_url}/openai/v1" if base_url else "",
|
||||||
"DEVPLACE_API_KEY": api_key,
|
"DEVPLACE_API_KEY": api_key,
|
||||||
@ -423,6 +424,109 @@ def pravda_env(instance: dict) -> dict:
|
|||||||
"DEVPLACE_CONTAINER_UID": instance.get("uid") or "",
|
"DEVPLACE_CONTAINER_UID": instance.get("uid") or "",
|
||||||
"DEVPLACE_INGRESS_URL": ingress_url,
|
"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:
|
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 "[]")
|
for p in json.loads(instance.get("ports_json") or "[]")
|
||||||
]
|
]
|
||||||
mounts = [Mount(instance["workspace_dir"], WORKSPACE_MOUNT, "rw")]
|
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 "[]"):
|
for extra in json.loads(instance.get("volumes_json") or "[]"):
|
||||||
if isinstance(extra, dict) and extra.get("host") and extra.get("container"):
|
if isinstance(extra, dict) and extra.get("host") and extra.get("container"):
|
||||||
mounts.append(
|
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()
|
language = (instance.get("boot_language") or "none").strip().lower()
|
||||||
boot = (instance.get("boot_command") or "").strip()
|
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]}"
|
script_path = f"{WORKSPACE_MOUNT}/{BOOT_SCRIPT_FILES[language]}"
|
||||||
command = [BOOT_SCRIPT_RUNNERS[language], script_path]
|
command = [BOOT_SCRIPT_RUNNERS[language], script_path]
|
||||||
elif boot:
|
elif boot:
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from typing import Awaitable, Callable, Optional
|
|||||||
LogCallback = Callable[[str], Awaitable[None]]
|
LogCallback = Callable[[str], Awaitable[None]]
|
||||||
|
|
||||||
WORKSPACE_MOUNT = "/app"
|
WORKSPACE_MOUNT = "/app"
|
||||||
|
WORKSPACE_STATE_MOUNT = "/home/pravda/.workspace-state"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
173
devplacepy/services/containers/forward.py
Normal file
173
devplacepy/services/containers/forward.py
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import websockets
|
||||||
|
from fastapi import Request, WebSocket
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
HOP_HEADERS = {
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailers",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
"host",
|
||||||
|
"content-length",
|
||||||
|
"content-encoding",
|
||||||
|
}
|
||||||
|
|
||||||
|
METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 300.0
|
||||||
|
|
||||||
|
|
||||||
|
def forward_headers(request: Request, prefix: str = "") -> dict:
|
||||||
|
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_HEADERS}
|
||||||
|
if prefix:
|
||||||
|
headers["X-Forwarded-Prefix"] = prefix
|
||||||
|
headers["X-Script-Name"] = prefix
|
||||||
|
headers["X-Forwarded-Host"] = request.headers.get(
|
||||||
|
"host", request.url.hostname or ""
|
||||||
|
)
|
||||||
|
headers["X-Forwarded-Proto"] = request.headers.get(
|
||||||
|
"x-forwarded-proto", request.url.scheme
|
||||||
|
)
|
||||||
|
headers["Accept-Encoding"] = "identity"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def inject_base(body: bytes, prefix: str) -> bytes:
|
||||||
|
lowered = body.lower()
|
||||||
|
if b"<base" in lowered:
|
||||||
|
return body
|
||||||
|
tag = f'<base href="{prefix}/">'.encode()
|
||||||
|
head = lowered.find(b"<head")
|
||||||
|
anchor = (
|
||||||
|
lowered.find(b">", head)
|
||||||
|
if head != -1
|
||||||
|
else lowered.find(b">", lowered.find(b"<html"))
|
||||||
|
)
|
||||||
|
if anchor == -1:
|
||||||
|
return tag + body
|
||||||
|
return body[: anchor + 1] + tag + body[anchor + 1 :]
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_location(value: str, prefix: str) -> str:
|
||||||
|
if not prefix:
|
||||||
|
return value
|
||||||
|
if value.startswith("/") and not value.startswith("//"):
|
||||||
|
return prefix + value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def proxy_http(
|
||||||
|
request: Request,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
prefix: str = "",
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
rewrite_html: bool = True,
|
||||||
|
) -> Response:
|
||||||
|
url = f"http://{host}:{port}/{path}"
|
||||||
|
headers = forward_headers(request, prefix)
|
||||||
|
body = await request.body()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=timeout, follow_redirects=False
|
||||||
|
) as client:
|
||||||
|
upstream = await client.request(
|
||||||
|
request.method,
|
||||||
|
url,
|
||||||
|
params=request.query_params,
|
||||||
|
headers=headers,
|
||||||
|
content=body,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as error:
|
||||||
|
return Response(f"upstream error: {error}", status_code=502)
|
||||||
|
out_headers = {
|
||||||
|
k: v
|
||||||
|
for k, v in upstream.headers.items()
|
||||||
|
if k.lower() not in HOP_HEADERS and k.lower() != "set-cookie"
|
||||||
|
}
|
||||||
|
if "location" in out_headers:
|
||||||
|
out_headers["location"] = rewrite_location(out_headers["location"], prefix)
|
||||||
|
content_type = upstream.headers.get("content-type", "")
|
||||||
|
content = upstream.content
|
||||||
|
if prefix and rewrite_html and "text/html" in content_type.lower():
|
||||||
|
content = inject_base(content, prefix)
|
||||||
|
response = Response(
|
||||||
|
content=content,
|
||||||
|
status_code=upstream.status_code,
|
||||||
|
headers=out_headers,
|
||||||
|
media_type=content_type or None,
|
||||||
|
)
|
||||||
|
for cookie in upstream.headers.get_list("set-cookie"):
|
||||||
|
response.headers.append("set-cookie", cookie)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def proxy_ws(
|
||||||
|
websocket: WebSocket, host: str, port: int, path: str, *, accepted: bool = False
|
||||||
|
) -> None:
|
||||||
|
upstream_url = f"ws://{host}:{port}/{path}"
|
||||||
|
if websocket.url.query:
|
||||||
|
upstream_url += f"?{websocket.url.query}"
|
||||||
|
if not accepted:
|
||||||
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
async with websockets.connect(
|
||||||
|
upstream_url, open_timeout=10, max_size=None
|
||||||
|
) as upstream:
|
||||||
|
await pump(websocket, upstream)
|
||||||
|
except Exception as error:
|
||||||
|
logger.debug("ws proxy to %s:%s failed: %s", host, port, error)
|
||||||
|
try:
|
||||||
|
await websocket.close(code=1011)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def pump(client_ws: WebSocket, upstream) -> None:
|
||||||
|
async def client_to_upstream():
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
message = await client_ws.receive()
|
||||||
|
if message["type"] == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
if message.get("text") is not None:
|
||||||
|
await upstream.send(message["text"])
|
||||||
|
elif message.get("bytes") is not None:
|
||||||
|
await upstream.send(message["bytes"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
await upstream.close()
|
||||||
|
|
||||||
|
async def upstream_to_client():
|
||||||
|
try:
|
||||||
|
async for message in upstream:
|
||||||
|
if isinstance(message, (bytes, bytearray)):
|
||||||
|
await client_ws.send_bytes(bytes(message))
|
||||||
|
else:
|
||||||
|
await client_ws.send_text(message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await client_ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await asyncio.gather(client_to_upstream(), upstream_to_client())
|
||||||
@ -109,6 +109,29 @@ def update_instance(uid: str, changes: dict) -> None:
|
|||||||
get_table("instances").update({"uid": uid, "updated_at": now(), **changes}, ["uid"])
|
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:
|
def delete_instance(uid: str, deleted_by: str = "system") -> None:
|
||||||
get_table("instances").update(
|
get_table("instances").update(
|
||||||
{"uid": uid, "deleted_at": now(), "deleted_by": deleted_by}, ["uid"]
|
{"uid": uid, "deleted_at": now(), "deleted_by": deleted_by}, ["uid"]
|
||||||
|
|||||||
5
devplacepy/services/containers/workspace/__init__.py
Normal file
5
devplacepy/services/containers/workspace/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from . import flags, naming, provision, quota, tunnels
|
||||||
|
|
||||||
|
__all__ = ["flags", "naming", "provision", "quota", "tunnels"]
|
||||||
134
devplacepy/services/containers/workspace/flags.py
Normal file
134
devplacepy/services/containers/workspace/flags.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from devplacepy.database import get_table
|
||||||
|
from devplacepy.utils import generate_uid
|
||||||
|
|
||||||
|
FLAGS_TABLE = "workspace_flags"
|
||||||
|
|
||||||
|
SEVERITIES = ("info", "warn", "critical")
|
||||||
|
STATUSES = ("open", "acknowledged", "resolved", "dismissed")
|
||||||
|
|
||||||
|
KIND_CPU = "cpu_sustained"
|
||||||
|
KIND_EGRESS = "egress_spike"
|
||||||
|
KIND_REQUESTS = "request_rate"
|
||||||
|
KIND_ERRORS = "error_ratio"
|
||||||
|
KIND_DISK = "disk_growth"
|
||||||
|
KIND_TUNNEL_CHURN = "tunnel_churn"
|
||||||
|
KIND_MANUAL = "manual"
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def open_flag(instance_uid: str, kind: str) -> dict | None:
|
||||||
|
return get_table(FLAGS_TABLE).find_one(
|
||||||
|
instance_uid=instance_uid, kind=kind, status="open", deleted_at=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def raise_flag(
|
||||||
|
instance: dict,
|
||||||
|
kind: str,
|
||||||
|
severity: str = "warn",
|
||||||
|
detail: str = "",
|
||||||
|
metric_value: float = 0.0,
|
||||||
|
threshold: float = 0.0,
|
||||||
|
) -> dict | None:
|
||||||
|
if severity not in SEVERITIES:
|
||||||
|
severity = "warn"
|
||||||
|
instance_uid = instance.get("uid", "")
|
||||||
|
if not instance_uid or not kind:
|
||||||
|
return None
|
||||||
|
table = get_table(FLAGS_TABLE)
|
||||||
|
existing = open_flag(instance_uid, kind)
|
||||||
|
if existing:
|
||||||
|
table.update(
|
||||||
|
{
|
||||||
|
"uid": existing["uid"],
|
||||||
|
"severity": severity,
|
||||||
|
"detail": detail,
|
||||||
|
"metric_value": float(metric_value),
|
||||||
|
"threshold": float(threshold),
|
||||||
|
"updated_at": _now(),
|
||||||
|
},
|
||||||
|
["uid"],
|
||||||
|
)
|
||||||
|
return table.find_one(uid=existing["uid"])
|
||||||
|
uid = generate_uid()
|
||||||
|
table.insert(
|
||||||
|
{
|
||||||
|
"uid": uid,
|
||||||
|
"instance_uid": instance_uid,
|
||||||
|
"user_uid": instance.get("workspace_owner_uid", ""),
|
||||||
|
"kind": kind,
|
||||||
|
"severity": severity,
|
||||||
|
"detail": detail,
|
||||||
|
"metric_value": float(metric_value),
|
||||||
|
"threshold": float(threshold),
|
||||||
|
"status": "open",
|
||||||
|
"resolved_by": "",
|
||||||
|
"resolved_at": "",
|
||||||
|
"created_at": _now(),
|
||||||
|
"updated_at": _now(),
|
||||||
|
"deleted_at": None,
|
||||||
|
"deleted_by": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return table.find_one(uid=uid)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_flag(instance_uid: str, kind: str, resolved_by: str = "system") -> bool:
|
||||||
|
existing = open_flag(instance_uid, kind)
|
||||||
|
if not existing:
|
||||||
|
return False
|
||||||
|
get_table(FLAGS_TABLE).update(
|
||||||
|
{
|
||||||
|
"uid": existing["uid"],
|
||||||
|
"status": "resolved",
|
||||||
|
"resolved_by": resolved_by,
|
||||||
|
"resolved_at": _now(),
|
||||||
|
"updated_at": _now(),
|
||||||
|
},
|
||||||
|
["uid"],
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def set_status(uid: str, status: str, actor_uid: str) -> bool:
|
||||||
|
if status not in STATUSES:
|
||||||
|
return False
|
||||||
|
table = get_table(FLAGS_TABLE)
|
||||||
|
row = table.find_one(uid=uid, deleted_at=None)
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
changes = {"uid": uid, "status": status, "updated_at": _now()}
|
||||||
|
if status in ("resolved", "dismissed"):
|
||||||
|
changes["resolved_by"] = actor_uid
|
||||||
|
changes["resolved_at"] = _now()
|
||||||
|
table.update(changes, ["uid"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def list_flags(
|
||||||
|
instance_uid: str = "", user_uid: str = "", status: str = "open"
|
||||||
|
) -> list[dict]:
|
||||||
|
filters: dict[str, object] = {"deleted_at": None}
|
||||||
|
if instance_uid:
|
||||||
|
filters["instance_uid"] = instance_uid
|
||||||
|
if user_uid:
|
||||||
|
filters["user_uid"] = user_uid
|
||||||
|
if status:
|
||||||
|
filters["status"] = status
|
||||||
|
return list(get_table(FLAGS_TABLE).find(order_by=["-created_at"], **filters))
|
||||||
|
|
||||||
|
|
||||||
|
def has_critical(instance_uid: str) -> bool:
|
||||||
|
for row in list_flags(instance_uid=instance_uid):
|
||||||
|
if row.get("severity") == "critical":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
83
devplacepy/services/containers/workspace/naming.py
Normal file
83
devplacepy/services/containers/workspace/naming.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from faker import Faker
|
||||||
|
|
||||||
|
from devplacepy.config import WORKSPACE_TUNNEL_DOMAIN
|
||||||
|
from devplacepy.database import get_setting, get_table
|
||||||
|
|
||||||
|
LABEL = re.compile(r"^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$")
|
||||||
|
MAX_ATTEMPTS = 12
|
||||||
|
|
||||||
|
_faker = Faker()
|
||||||
|
|
||||||
|
|
||||||
|
def domain() -> str:
|
||||||
|
return get_setting("workspace_tunnel_domain", WORKSPACE_TUNNEL_DOMAIN).strip(".")
|
||||||
|
|
||||||
|
|
||||||
|
def host_pattern() -> str:
|
||||||
|
return get_setting("workspace_hostname_pattern", "{name}.{domain}")
|
||||||
|
|
||||||
|
|
||||||
|
def port_pattern() -> str:
|
||||||
|
return get_setting("workspace_port_hostname_pattern", "{port}-{name}.{domain}")
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_label(value: str) -> bool:
|
||||||
|
return bool(value) and bool(LABEL.match(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(value: str) -> str:
|
||||||
|
cleaned = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||||
|
return cleaned[:49].strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
def candidate(index: int) -> str:
|
||||||
|
words = [_slugify(_faker.word()) for _ in range(2)]
|
||||||
|
words = [word for word in words if word]
|
||||||
|
if not words:
|
||||||
|
words = ["workspace"]
|
||||||
|
name = "-".join(words)
|
||||||
|
if index:
|
||||||
|
name = f"{name}-{index}"
|
||||||
|
return name if is_valid_label(name) else "workspace"
|
||||||
|
|
||||||
|
|
||||||
|
def taken(name: str) -> bool:
|
||||||
|
return bool(get_table("instances").find_one(tunnel_name=name, deleted_at=None))
|
||||||
|
|
||||||
|
|
||||||
|
def generate(fallback_uid: str = "") -> str:
|
||||||
|
for attempt in range(MAX_ATTEMPTS):
|
||||||
|
name = candidate(attempt)
|
||||||
|
if not taken(name):
|
||||||
|
return name
|
||||||
|
tail = (fallback_uid or "").replace("-", "")[-8:]
|
||||||
|
return f"workspace-{tail}" if tail else "workspace"
|
||||||
|
|
||||||
|
|
||||||
|
def hostname_for(name: str, port: int = 0) -> str:
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
pattern = port_pattern() if port else host_pattern()
|
||||||
|
return pattern.format(name=name, domain=domain(), port=port)
|
||||||
|
|
||||||
|
|
||||||
|
def proxy_uri_template(name: str) -> str:
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
return "https://" + port_pattern().format(
|
||||||
|
name=name, domain=domain(), port="{{port}}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_tunnel_host(host: str) -> bool:
|
||||||
|
if not host:
|
||||||
|
return False
|
||||||
|
bare = host.split(":", 1)[0].lower().rstrip(".")
|
||||||
|
suffix = "." + domain().lower()
|
||||||
|
return bare.endswith(suffix)
|
||||||
188
devplacepy/services/containers/workspace/provision.py
Normal file
188
devplacepy/services/containers/workspace/provision.py
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from devplacepy import config
|
||||||
|
from devplacepy.database import get_table
|
||||||
|
from devplacepy.services.containers import api, store
|
||||||
|
|
||||||
|
from . import flags, naming, quota, tunnels
|
||||||
|
|
||||||
|
MANIFEST_DIRECTORY = ".devplace"
|
||||||
|
MANIFEST_NAME = "tunnels.json"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def find_for_project(project_uid: str, owner_uid: str) -> dict | None:
|
||||||
|
return get_table("instances").find_one(
|
||||||
|
project_uid=project_uid,
|
||||||
|
workspace_owner_uid=owner_uid,
|
||||||
|
is_workspace=1,
|
||||||
|
deleted_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def count_for_owner(owner_uid: str) -> int:
|
||||||
|
return get_table("instances").count(
|
||||||
|
workspace_owner_uid=owner_uid, is_workspace=1, deleted_at=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure(project: dict, user: dict) -> dict:
|
||||||
|
owner_uid = user["uid"]
|
||||||
|
existing = find_for_project(project["uid"], owner_uid)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
limits = quota.resolve(owner_uid)
|
||||||
|
if limits.max_workspaces and count_for_owner(owner_uid) >= limits.max_workspaces:
|
||||||
|
raise WorkspaceError(
|
||||||
|
f"workspace limit reached ({limits.max_workspaces}); "
|
||||||
|
"delete one before creating another"
|
||||||
|
)
|
||||||
|
instance = await api.create_instance(
|
||||||
|
project,
|
||||||
|
name=f"ws-{project.get('slug') or project['uid']}"[:64],
|
||||||
|
actor=("user", owner_uid),
|
||||||
|
ports=[f"{api.EDITOR_DEFAULT_PORT}"],
|
||||||
|
)
|
||||||
|
store.update_instance(
|
||||||
|
instance["uid"],
|
||||||
|
{
|
||||||
|
"is_workspace": 1,
|
||||||
|
"workspace_owner_uid": owner_uid,
|
||||||
|
"editor_port": api.EDITOR_DEFAULT_PORT,
|
||||||
|
"tunnel_name": naming.generate(instance["uid"]),
|
||||||
|
"desired_state": "running",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return store.get_instance(instance["uid"])
|
||||||
|
|
||||||
|
|
||||||
|
def resume(instance: dict) -> dict:
|
||||||
|
if instance.get("suspended_at"):
|
||||||
|
raise WorkspaceError("this workspace is suspended; contact an administrator")
|
||||||
|
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
|
||||||
|
disk_quota = limits.disk_quota_bytes()
|
||||||
|
if disk_quota and int(instance.get("disk_bytes") or 0) >= disk_quota:
|
||||||
|
raise WorkspaceError(
|
||||||
|
"disk quota reached; free space before starting this workspace again"
|
||||||
|
)
|
||||||
|
store.update_instance(
|
||||||
|
instance["uid"],
|
||||||
|
{"desired_state": "running", "idle_warned_at": "", "delete_warned_at": ""},
|
||||||
|
)
|
||||||
|
return store.get_instance(instance["uid"])
|
||||||
|
|
||||||
|
|
||||||
|
def stop(instance: dict) -> dict:
|
||||||
|
store.update_instance(instance["uid"], {"desired_state": "stopped"})
|
||||||
|
return store.get_instance(instance["uid"])
|
||||||
|
|
||||||
|
|
||||||
|
def suspend(instance: dict, actor_uid: str, reason: str) -> dict:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
store.update_instance(
|
||||||
|
instance["uid"],
|
||||||
|
{
|
||||||
|
"desired_state": "stopped",
|
||||||
|
"suspended_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"suspended_by": actor_uid,
|
||||||
|
"flag_reason": reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tunnels.suspend_for_instance(instance["uid"])
|
||||||
|
return store.get_instance(instance["uid"])
|
||||||
|
|
||||||
|
|
||||||
|
def unsuspend(instance: dict) -> dict:
|
||||||
|
store.update_instance(
|
||||||
|
instance["uid"], {"suspended_at": "", "suspended_by": "", "flag_reason": ""}
|
||||||
|
)
|
||||||
|
tunnels.resume_for_instance(instance["uid"])
|
||||||
|
return store.get_instance(instance["uid"])
|
||||||
|
|
||||||
|
|
||||||
|
def editor_target(instance: dict) -> tuple[str, int]:
|
||||||
|
host, port = api.proxy_target(instance)
|
||||||
|
return host, port
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_payload(instance: dict) -> dict:
|
||||||
|
rows = tunnels.list_for_instance(instance["uid"])
|
||||||
|
return {
|
||||||
|
"workspace": {
|
||||||
|
"uid": instance.get("uid", ""),
|
||||||
|
"name": instance.get("name", ""),
|
||||||
|
"tunnel_name": instance.get("tunnel_name", ""),
|
||||||
|
"domain": naming.domain(),
|
||||||
|
"project_uid": instance.get("project_uid", ""),
|
||||||
|
},
|
||||||
|
"tunnels": [
|
||||||
|
{
|
||||||
|
"label": row.get("label", ""),
|
||||||
|
"hostname": row.get("hostname", ""),
|
||||||
|
"url": f"https://{row.get('hostname', '')}",
|
||||||
|
"container_port": int(row.get("container_port") or 0),
|
||||||
|
"status": row.get("status", ""),
|
||||||
|
"cert_status": row.get("cert_status", ""),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_manifest(instance: dict) -> None:
|
||||||
|
workspace_dir = instance.get("workspace_dir")
|
||||||
|
if not workspace_dir:
|
||||||
|
return
|
||||||
|
directory = Path(workspace_dir) / MANIFEST_DIRECTORY
|
||||||
|
try:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
(directory / MANIFEST_NAME).write_text(
|
||||||
|
json.dumps(manifest_payload(instance), indent=2)
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def state_dir(instance: dict) -> Path:
|
||||||
|
return config.WORKSPACE_STATE_DIR / instance["uid"]
|
||||||
|
|
||||||
|
|
||||||
|
def view(instance: dict, viewer_is_admin: bool = False) -> dict:
|
||||||
|
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
|
||||||
|
disk_used = int(instance.get("disk_bytes") or 0)
|
||||||
|
egress_used = int(instance.get("egress_bytes") or 0)
|
||||||
|
return {
|
||||||
|
"uid": instance.get("uid", ""),
|
||||||
|
"name": instance.get("name", ""),
|
||||||
|
"status": instance.get("status", ""),
|
||||||
|
"desired_state": instance.get("desired_state", ""),
|
||||||
|
"suspended": bool(instance.get("suspended_at")),
|
||||||
|
"flag_reason": instance.get("flag_reason", ""),
|
||||||
|
"tunnel_name": instance.get("tunnel_name", ""),
|
||||||
|
"primary_url": (
|
||||||
|
f"https://{naming.hostname_for(instance.get('tunnel_name', ''))}"
|
||||||
|
if instance.get("tunnel_name")
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"last_active_at": instance.get("last_active_at", ""),
|
||||||
|
"disk_bytes": disk_used,
|
||||||
|
"disk_quota_mb": limits.disk_quota_mb,
|
||||||
|
"disk_percent": quota.percent_used(disk_used, limits.disk_quota_bytes()),
|
||||||
|
"egress_bytes": egress_used,
|
||||||
|
"egress_quota_mb": limits.egress_quota_mb,
|
||||||
|
"egress_percent": quota.percent_used(egress_used, limits.egress_quota_bytes()),
|
||||||
|
"idle_stop_minutes": limits.idle_stop_minutes,
|
||||||
|
"retention_days": limits.retention_days,
|
||||||
|
"max_tunnels": limits.max_tunnels,
|
||||||
|
"tunnels": tunnels.list_for_instance(instance["uid"]),
|
||||||
|
"flags": flags.list_flags(instance_uid=instance["uid"]),
|
||||||
|
}
|
||||||
97
devplacepy/services/containers/workspace/quota.py
Normal file
97
devplacepy/services/containers/workspace/quota.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from devplacepy.database import get_int_setting, get_table
|
||||||
|
|
||||||
|
RULES_TABLE = "workspace_quota_rules"
|
||||||
|
|
||||||
|
DEFAULTS: dict[str, int] = {
|
||||||
|
"max_workspaces": 2,
|
||||||
|
"max_tunnels": 5,
|
||||||
|
"disk_quota_mb": 2048,
|
||||||
|
"egress_quota_mb": 10240,
|
||||||
|
"idle_warn_minutes": 45,
|
||||||
|
"idle_stop_minutes": 60,
|
||||||
|
"retention_days": 14,
|
||||||
|
"purge_after_days": 7,
|
||||||
|
"disk_warn_percent": 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
SETTING_KEYS: dict[str, str] = {
|
||||||
|
"max_workspaces": "workspace_max_per_user",
|
||||||
|
"max_tunnels": "workspace_max_tunnels",
|
||||||
|
"disk_quota_mb": "workspace_disk_quota_mb",
|
||||||
|
"egress_quota_mb": "workspace_egress_quota_mb",
|
||||||
|
"idle_warn_minutes": "workspace_idle_warn_minutes",
|
||||||
|
"idle_stop_minutes": "workspace_idle_stop_minutes",
|
||||||
|
"retention_days": "workspace_retention_days",
|
||||||
|
"purge_after_days": "workspace_purge_after_days",
|
||||||
|
"disk_warn_percent": "workspace_disk_warn_percent",
|
||||||
|
}
|
||||||
|
|
||||||
|
RULE_COLUMNS = (
|
||||||
|
"max_workspaces",
|
||||||
|
"max_tunnels",
|
||||||
|
"disk_quota_mb",
|
||||||
|
"egress_quota_mb",
|
||||||
|
"idle_stop_minutes",
|
||||||
|
"retention_days",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Limits:
|
||||||
|
max_workspaces: int
|
||||||
|
max_tunnels: int
|
||||||
|
disk_quota_mb: int
|
||||||
|
egress_quota_mb: int
|
||||||
|
idle_warn_minutes: int
|
||||||
|
idle_stop_minutes: int
|
||||||
|
retention_days: int
|
||||||
|
purge_after_days: int
|
||||||
|
disk_warn_percent: int
|
||||||
|
|
||||||
|
def disk_quota_bytes(self) -> int:
|
||||||
|
return self.disk_quota_mb * 1024 * 1024
|
||||||
|
|
||||||
|
def egress_quota_bytes(self) -> int:
|
||||||
|
return self.egress_quota_mb * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _global_value(key: str) -> int:
|
||||||
|
return get_int_setting(SETTING_KEYS[key], DEFAULTS[key])
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_for(owner_kind: str, owner_id: str) -> dict | None:
|
||||||
|
if not owner_id:
|
||||||
|
return None
|
||||||
|
table = get_table(RULES_TABLE)
|
||||||
|
return table.find_one(owner_kind=owner_kind, owner_id=owner_id, deleted_at=None)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(user_uid: str = "", instance: dict | None = None) -> Limits:
|
||||||
|
rule = _rule_for("user", user_uid) if user_uid else None
|
||||||
|
values: dict[str, int] = {}
|
||||||
|
for key in DEFAULTS:
|
||||||
|
value = _global_value(key)
|
||||||
|
if rule and key in RULE_COLUMNS:
|
||||||
|
override = rule.get(key)
|
||||||
|
if override:
|
||||||
|
value = int(override)
|
||||||
|
if instance:
|
||||||
|
instance_override = instance.get(f"workspace_{key}")
|
||||||
|
if instance_override:
|
||||||
|
value = int(instance_override)
|
||||||
|
values[key] = max(0, value)
|
||||||
|
if values["idle_warn_minutes"] >= values["idle_stop_minutes"]:
|
||||||
|
values["idle_warn_minutes"] = max(1, values["idle_stop_minutes"] - 1)
|
||||||
|
return Limits(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def percent_used(used: int, quota: int) -> int:
|
||||||
|
if quota <= 0:
|
||||||
|
return 0
|
||||||
|
return min(100, int(used * 100 / quota))
|
||||||
154
devplacepy/services/containers/workspace/tunnels.py
Normal file
154
devplacepy/services/containers/workspace/tunnels.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from devplacepy.database import db, get_table
|
||||||
|
from devplacepy.utils import generate_uid
|
||||||
|
|
||||||
|
from . import naming
|
||||||
|
|
||||||
|
TUNNELS_TABLE = "tunnels"
|
||||||
|
|
||||||
|
STATUS_PENDING = "pending"
|
||||||
|
STATUS_PROVISIONING = "provisioning"
|
||||||
|
STATUS_ACTIVE = "active"
|
||||||
|
STATUS_FAILED = "failed"
|
||||||
|
STATUS_SUSPENDED = "suspended"
|
||||||
|
|
||||||
|
SERVING_STATUSES = (STATUS_PROVISIONING, STATUS_ACTIVE)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _table():
|
||||||
|
return get_table(TUNNELS_TABLE)
|
||||||
|
|
||||||
|
|
||||||
|
def list_for_instance(instance_uid: str) -> list[dict]:
|
||||||
|
return list(
|
||||||
|
_table().find(
|
||||||
|
instance_uid=instance_uid, deleted_at=None, order_by=["created_at"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_for_user(user_uid: str) -> list[dict]:
|
||||||
|
return list(
|
||||||
|
_table().find(user_uid=user_uid, deleted_at=None, order_by=["-created_at"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get(uid: str) -> dict | None:
|
||||||
|
return _table().find_one(uid=uid, deleted_at=None)
|
||||||
|
|
||||||
|
|
||||||
|
def by_hostname(hostname: str) -> dict | None:
|
||||||
|
if not hostname:
|
||||||
|
return None
|
||||||
|
bare = hostname.split(":", 1)[0].lower().rstrip(".")
|
||||||
|
return _table().find_one(hostname=bare, deleted_at=None)
|
||||||
|
|
||||||
|
|
||||||
|
def count_for_instance(instance_uid: str) -> int:
|
||||||
|
return _table().count(instance_uid=instance_uid, deleted_at=None)
|
||||||
|
|
||||||
|
|
||||||
|
def create(
|
||||||
|
instance: dict, label: str, container_port: int, user_uid: str
|
||||||
|
) -> dict | None:
|
||||||
|
name = instance.get("tunnel_name", "")
|
||||||
|
if not name or container_port <= 0:
|
||||||
|
return None
|
||||||
|
hostname = naming.hostname_for(name, container_port)
|
||||||
|
table = _table()
|
||||||
|
revived = table.find_one(hostname=hostname)
|
||||||
|
stamp = _now()
|
||||||
|
if revived:
|
||||||
|
table.update(
|
||||||
|
{
|
||||||
|
"uid": revived["uid"],
|
||||||
|
"instance_uid": instance["uid"],
|
||||||
|
"project_uid": instance.get("project_uid", ""),
|
||||||
|
"user_uid": user_uid,
|
||||||
|
"label": label or f"port {container_port}",
|
||||||
|
"container_port": container_port,
|
||||||
|
"desired_state": "present",
|
||||||
|
"status": STATUS_PENDING,
|
||||||
|
"last_error": "",
|
||||||
|
"updated_at": stamp,
|
||||||
|
"deleted_at": None,
|
||||||
|
"deleted_by": None,
|
||||||
|
},
|
||||||
|
["uid"],
|
||||||
|
)
|
||||||
|
return table.find_one(uid=revived["uid"])
|
||||||
|
uid = generate_uid()
|
||||||
|
table.insert(
|
||||||
|
{
|
||||||
|
"uid": uid,
|
||||||
|
"instance_uid": instance["uid"],
|
||||||
|
"project_uid": instance.get("project_uid", ""),
|
||||||
|
"user_uid": user_uid,
|
||||||
|
"hostname": hostname,
|
||||||
|
"label": label or f"port {container_port}",
|
||||||
|
"container_port": container_port,
|
||||||
|
"desired_state": "present",
|
||||||
|
"status": STATUS_PENDING,
|
||||||
|
"cert_status": "",
|
||||||
|
"cert_checked_at": "",
|
||||||
|
"request_count": 0,
|
||||||
|
"bytes_out": 0,
|
||||||
|
"last_request_at": "",
|
||||||
|
"last_error": "",
|
||||||
|
"last_synced_at": "",
|
||||||
|
"created_at": stamp,
|
||||||
|
"updated_at": stamp,
|
||||||
|
"deleted_at": None,
|
||||||
|
"deleted_by": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return table.find_one(uid=uid)
|
||||||
|
|
||||||
|
|
||||||
|
def update(uid: str, changes: dict) -> None:
|
||||||
|
_table().update({"uid": uid, "updated_at": _now(), **changes}, ["uid"])
|
||||||
|
|
||||||
|
|
||||||
|
def mark_absent(uid: str, actor_uid: str = "system") -> None:
|
||||||
|
update(uid, {"desired_state": "absent"})
|
||||||
|
|
||||||
|
|
||||||
|
def soft_delete(uid: str, actor_uid: str = "system") -> None:
|
||||||
|
_table().update(
|
||||||
|
{"uid": uid, "deleted_at": _now(), "deleted_by": actor_uid}, ["uid"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def suspend_for_instance(instance_uid: str) -> None:
|
||||||
|
for row in list_for_instance(instance_uid):
|
||||||
|
update(row["uid"], {"status": STATUS_SUSPENDED})
|
||||||
|
|
||||||
|
|
||||||
|
def resume_for_instance(instance_uid: str) -> None:
|
||||||
|
for row in list_for_instance(instance_uid):
|
||||||
|
if row.get("status") == STATUS_SUSPENDED:
|
||||||
|
update(row["uid"], {"status": STATUS_PENDING})
|
||||||
|
|
||||||
|
|
||||||
|
def record_hit(uid: str, byte_count: int) -> None:
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
sql = (
|
||||||
|
"UPDATE tunnels SET request_count = COALESCE(request_count, 0) + 1, "
|
||||||
|
"bytes_out = COALESCE(bytes_out, 0) + :bytes, last_request_at = :seen "
|
||||||
|
"WHERE uid = :uid AND deleted_at IS NULL"
|
||||||
|
)
|
||||||
|
with db:
|
||||||
|
db.executable.execute(
|
||||||
|
text(sql),
|
||||||
|
{"bytes": max(0, byte_count), "seen": _now(), "uid": uid},
|
||||||
|
)
|
||||||
334
devplacepy/services/containers/workspace_service.py
Normal file
334
devplacepy/services/containers/workspace_service.py
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from devplacepy import config
|
||||||
|
from devplacepy.database import get_table
|
||||||
|
from devplacepy.services.base import BaseService, ConfigField
|
||||||
|
from devplacepy.services.containers import store
|
||||||
|
from devplacepy.services.containers.workspace import flags, quota, tunnels
|
||||||
|
|
||||||
|
DISK_SAMPLE_DEFAULT_MINUTES = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(value: str | None) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _minutes_since(value: str | None) -> float | None:
|
||||||
|
parsed = _parse(value)
|
||||||
|
if parsed is None:
|
||||||
|
return None
|
||||||
|
return (_now() - parsed).total_seconds() / 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def _directory_size(path: Path) -> int:
|
||||||
|
total = 0
|
||||||
|
stack = [path]
|
||||||
|
while stack:
|
||||||
|
current = stack.pop()
|
||||||
|
try:
|
||||||
|
with os.scandir(current) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
try:
|
||||||
|
if entry.is_dir(follow_symlinks=False):
|
||||||
|
stack.append(Path(entry.path))
|
||||||
|
elif entry.is_file(follow_symlinks=False):
|
||||||
|
total += entry.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceService(BaseService):
|
||||||
|
title = "Dev Workspaces"
|
||||||
|
description = (
|
||||||
|
"Reconciles browser IDE workspaces: tunnel certificates, disk and egress "
|
||||||
|
"metering, abuse flags, idle and retention lifecycle, and purge sweeps."
|
||||||
|
)
|
||||||
|
default_enabled = False
|
||||||
|
min_interval = 5
|
||||||
|
METRICS_SECONDS = 30
|
||||||
|
|
||||||
|
config_fields = [
|
||||||
|
ConfigField("workspace_enabled", "Enabled", type="bool", default="0",
|
||||||
|
group="General", help="Master switch for the workspace feature."),
|
||||||
|
ConfigField("workspace_editor_version", "Editor version", default="",
|
||||||
|
group="General",
|
||||||
|
help="code-server release. Empty means the image default."),
|
||||||
|
ConfigField("workspace_extensions_gallery", "Extensions gallery", type="text",
|
||||||
|
default="", group="General",
|
||||||
|
help="EXTENSIONS_GALLERY JSON. Empty means the default."),
|
||||||
|
ConfigField("workspace_molohttp_base_url", "molohttp base URL", type="url",
|
||||||
|
default="https://pravda.education/molohttp", group="molohttp"),
|
||||||
|
ConfigField("workspace_molohttp_api_key", "molohttp API key", type="password",
|
||||||
|
default="", group="molohttp", secret=True,
|
||||||
|
help="Preferred credential. Sent as the x-api-key header."),
|
||||||
|
ConfigField("workspace_molohttp_username", "molohttp username", default="",
|
||||||
|
group="molohttp"),
|
||||||
|
ConfigField("workspace_molohttp_password", "molohttp password",
|
||||||
|
type="password", default="", group="molohttp", secret=True),
|
||||||
|
ConfigField("workspace_tunnel_domain", "Tunnel domain",
|
||||||
|
default=config.WORKSPACE_TUNNEL_DOMAIN, group="Tunnels"),
|
||||||
|
ConfigField("workspace_hostname_pattern", "Hostname pattern",
|
||||||
|
default="{name}.{domain}", group="Tunnels"),
|
||||||
|
ConfigField("workspace_port_hostname_pattern", "Port hostname pattern",
|
||||||
|
default="{port}-{name}.{domain}", group="Tunnels"),
|
||||||
|
ConfigField("workspace_cert_mode", "Certificate mode", type="select",
|
||||||
|
default="per_host", options=[{"value": "per_host", "label": "Per host"}, {"value": "wildcard", "label": "Wildcard"}],
|
||||||
|
group="Tunnels"),
|
||||||
|
ConfigField("workspace_acme_email", "ACME email", default="", group="Tunnels"),
|
||||||
|
ConfigField("workspace_max_per_user", "Max workspaces per user", type="int",
|
||||||
|
default="2", minimum=0, group="Quotas"),
|
||||||
|
ConfigField("workspace_max_tunnels", "Max tunnels per workspace", type="int",
|
||||||
|
default="5", minimum=0, group="Quotas"),
|
||||||
|
ConfigField("workspace_disk_quota_mb", "Disk quota (MB)", type="int",
|
||||||
|
default="2048", minimum=0, group="Quotas"),
|
||||||
|
ConfigField("workspace_egress_quota_mb", "Egress quota (MB)", type="int",
|
||||||
|
default="10240", minimum=0, group="Quotas"),
|
||||||
|
ConfigField("workspace_disk_warn_percent", "Disk warn percent", type="int",
|
||||||
|
default="80", minimum=1, maximum=100, group="Quotas"),
|
||||||
|
ConfigField("workspace_idle_warn_minutes", "Idle warn (minutes)", type="int",
|
||||||
|
default="45", minimum=1, group="Lifecycle"),
|
||||||
|
ConfigField("workspace_idle_stop_minutes", "Idle stop (minutes)", type="int",
|
||||||
|
default="60", minimum=1, group="Lifecycle"),
|
||||||
|
ConfigField("workspace_retention_days", "Retention (days)", type="int",
|
||||||
|
default="14", minimum=1, group="Lifecycle"),
|
||||||
|
ConfigField("workspace_purge_after_days", "Purge after (days)", type="int",
|
||||||
|
default="7", minimum=1, group="Lifecycle"),
|
||||||
|
ConfigField("workspace_auto_stop", "Auto stop", type="select", default="auto",
|
||||||
|
options=[{"value": "off", "label": "Off"}, {"value": "notify", "label": "Notify"}, {"value": "auto", "label": "Auto"}], group="Automation"),
|
||||||
|
ConfigField("workspace_auto_delete", "Auto delete", type="select",
|
||||||
|
default="auto", options=[{"value": "off", "label": "Off"}, {"value": "notify", "label": "Notify"}, {"value": "auto", "label": "Auto"}],
|
||||||
|
group="Automation"),
|
||||||
|
ConfigField("workspace_auto_suspend", "Auto suspend", type="select",
|
||||||
|
default="notify", options=[{"value": "off", "label": "Off"}, {"value": "notify", "label": "Notify"}, {"value": "auto", "label": "Auto"}],
|
||||||
|
group="Automation"),
|
||||||
|
ConfigField("workspace_auto_flag", "Auto flag", type="select", default="auto",
|
||||||
|
options=[{"value": "off", "label": "Off"}, {"value": "flag", "label": "Flag"}, {"value": "auto", "label": "Auto"}], group="Automation"),
|
||||||
|
ConfigField("workspace_flag_cpu_percent", "Flag CPU percent", type="int",
|
||||||
|
default="95", minimum=1, maximum=100, group="Abuse"),
|
||||||
|
ConfigField("workspace_flag_cpu_minutes", "Flag CPU minutes", type="int",
|
||||||
|
default="120", minimum=1, group="Abuse"),
|
||||||
|
ConfigField("workspace_flag_egress_mb_per_hour", "Flag egress MB/hour",
|
||||||
|
type="int", default="2048", minimum=1, group="Abuse"),
|
||||||
|
ConfigField("workspace_flag_request_rate", "Flag request rate", type="int",
|
||||||
|
default="6000", minimum=1, group="Abuse"),
|
||||||
|
ConfigField("workspace_disk_sample_minutes", "Disk sample (minutes)",
|
||||||
|
type="int", default=str(DISK_SAMPLE_DEFAULT_MINUTES), minimum=1,
|
||||||
|
group="Advanced"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("workspace", interval_seconds=30)
|
||||||
|
self._last_disk_sample = 0.0
|
||||||
|
|
||||||
|
def _workspaces(self) -> list[dict]:
|
||||||
|
table = get_table("instances")
|
||||||
|
return list(table.find(is_workspace=1, deleted_at=None))
|
||||||
|
|
||||||
|
async def run_once(self) -> None:
|
||||||
|
cfg = self.get_config()
|
||||||
|
if cfg.get("workspace_enabled") not in (True, "1", 1):
|
||||||
|
return
|
||||||
|
rows = self._workspaces()
|
||||||
|
for phase in (
|
||||||
|
self._sample_disk,
|
||||||
|
self._evaluate_flags,
|
||||||
|
self._advance_lifecycle,
|
||||||
|
self._sweep_purge,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
phase(rows, cfg)
|
||||||
|
except Exception as error:
|
||||||
|
self.log(f"{phase.__name__} failed: {error}")
|
||||||
|
|
||||||
|
def _sample_disk(self, rows: list[dict], cfg: dict) -> None:
|
||||||
|
interval = int(cfg.get("workspace_disk_sample_minutes") or
|
||||||
|
DISK_SAMPLE_DEFAULT_MINUTES) * 60
|
||||||
|
if time.monotonic() - self._last_disk_sample < interval:
|
||||||
|
return
|
||||||
|
self._last_disk_sample = time.monotonic()
|
||||||
|
for row in rows:
|
||||||
|
workspace = config.CONTAINER_WORKSPACES_DIR / row.get("project_uid", "")
|
||||||
|
state = config.WORKSPACE_STATE_DIR / row["uid"]
|
||||||
|
total = _directory_size(workspace) + _directory_size(state)
|
||||||
|
store.update_instance(
|
||||||
|
row["uid"],
|
||||||
|
{"disk_bytes": total, "disk_sampled_at": _now().isoformat()},
|
||||||
|
)
|
||||||
|
row["disk_bytes"] = total
|
||||||
|
|
||||||
|
def _evaluate_flags(self, rows: list[dict], cfg: dict) -> None:
|
||||||
|
mode = cfg.get("workspace_auto_flag") or "auto"
|
||||||
|
if mode == "off":
|
||||||
|
return
|
||||||
|
egress_ceiling = int(cfg.get("workspace_flag_egress_mb_per_hour") or 2048)
|
||||||
|
egress_bytes = egress_ceiling * 1024 * 1024
|
||||||
|
requests_ceiling = int(cfg.get("workspace_flag_request_rate") or 6000)
|
||||||
|
for row in rows:
|
||||||
|
limits = quota.resolve(row.get("workspace_owner_uid", ""), row)
|
||||||
|
used = int(row.get("egress_bytes") or 0)
|
||||||
|
if used > egress_bytes:
|
||||||
|
flags.raise_flag(
|
||||||
|
row,
|
||||||
|
flags.KIND_EGRESS,
|
||||||
|
"warn",
|
||||||
|
f"egress {used} bytes exceeds hourly ceiling {egress_bytes}",
|
||||||
|
float(used),
|
||||||
|
float(egress_bytes),
|
||||||
|
)
|
||||||
|
if int(row.get("request_count") or 0) > requests_ceiling:
|
||||||
|
flags.raise_flag(
|
||||||
|
row,
|
||||||
|
flags.KIND_REQUESTS,
|
||||||
|
"warn",
|
||||||
|
"request rate above configured ceiling",
|
||||||
|
float(row.get("request_count") or 0),
|
||||||
|
float(requests_ceiling),
|
||||||
|
)
|
||||||
|
disk_quota = limits.disk_quota_bytes()
|
||||||
|
if disk_quota and int(row.get("disk_bytes") or 0) > disk_quota:
|
||||||
|
flags.raise_flag(
|
||||||
|
row,
|
||||||
|
flags.KIND_DISK,
|
||||||
|
"warn",
|
||||||
|
"disk usage above quota",
|
||||||
|
float(row.get("disk_bytes") or 0),
|
||||||
|
float(disk_quota),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _advance_lifecycle(self, rows: list[dict], cfg: dict) -> None:
|
||||||
|
from devplacepy.utils import create_notification
|
||||||
|
|
||||||
|
stop_mode = cfg.get("workspace_auto_stop") or "auto"
|
||||||
|
delete_mode = cfg.get("workspace_auto_delete") or "auto"
|
||||||
|
for row in rows:
|
||||||
|
if row.get("suspended_at"):
|
||||||
|
continue
|
||||||
|
limits = quota.resolve(row.get("workspace_owner_uid", ""), row)
|
||||||
|
owner = row.get("workspace_owner_uid", "")
|
||||||
|
idle = _minutes_since(row.get("last_active_at"))
|
||||||
|
if idle is None:
|
||||||
|
continue
|
||||||
|
running = row.get("status") == "running"
|
||||||
|
if running and stop_mode != "off":
|
||||||
|
if idle >= limits.idle_stop_minutes:
|
||||||
|
if stop_mode == "auto":
|
||||||
|
store.update_instance(
|
||||||
|
row["uid"], {"desired_state": "stopped"}
|
||||||
|
)
|
||||||
|
if owner:
|
||||||
|
create_notification(
|
||||||
|
owner,
|
||||||
|
"workspace",
|
||||||
|
f"Workspace {row.get('name', '')} stopped after "
|
||||||
|
f"{limits.idle_stop_minutes} minutes idle.",
|
||||||
|
row["uid"],
|
||||||
|
f"/projects/{row.get('project_uid', '')}/workspace",
|
||||||
|
)
|
||||||
|
store.update_instance(row["uid"], {"idle_warned_at": ""})
|
||||||
|
elif idle >= limits.idle_warn_minutes and not row.get("idle_warned_at"):
|
||||||
|
store.update_instance(
|
||||||
|
row["uid"], {"idle_warned_at": _now().isoformat()}
|
||||||
|
)
|
||||||
|
if owner:
|
||||||
|
remaining = int(limits.idle_stop_minutes - idle)
|
||||||
|
create_notification(
|
||||||
|
owner,
|
||||||
|
"workspace",
|
||||||
|
f"Workspace {row.get('name', '')} stops in about "
|
||||||
|
f"{remaining} minutes unless you use it.",
|
||||||
|
row["uid"],
|
||||||
|
f"/projects/{row.get('project_uid', '')}/workspace",
|
||||||
|
)
|
||||||
|
if delete_mode == "off" or running:
|
||||||
|
continue
|
||||||
|
idle_days = idle / (60 * 24)
|
||||||
|
warn_at = max(1, limits.retention_days - 3)
|
||||||
|
if idle_days >= limits.retention_days:
|
||||||
|
if delete_mode == "auto":
|
||||||
|
store.delete_instance(row["uid"], "system")
|
||||||
|
tunnels.suspend_for_instance(row["uid"])
|
||||||
|
if owner:
|
||||||
|
create_notification(
|
||||||
|
owner,
|
||||||
|
"workspace",
|
||||||
|
f"Workspace {row.get('name', '')} was removed after "
|
||||||
|
f"{limits.retention_days} days idle. An administrator can "
|
||||||
|
"restore it from Trash.",
|
||||||
|
row["uid"],
|
||||||
|
"/projects",
|
||||||
|
)
|
||||||
|
elif idle_days >= warn_at and not row.get("delete_warned_at"):
|
||||||
|
store.update_instance(
|
||||||
|
row["uid"], {"delete_warned_at": _now().isoformat()}
|
||||||
|
)
|
||||||
|
if owner:
|
||||||
|
due = _now() + timedelta(days=limits.retention_days - idle_days)
|
||||||
|
create_notification(
|
||||||
|
owner,
|
||||||
|
"workspace",
|
||||||
|
f"Workspace {row.get('name', '')} is scheduled for deletion "
|
||||||
|
f"on {due.strftime('%d/%m/%Y')} unless you use it.",
|
||||||
|
row["uid"],
|
||||||
|
f"/projects/{row.get('project_uid', '')}/workspace",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sweep_purge(self, rows: list[dict], cfg: dict) -> None:
|
||||||
|
limits = quota.resolve()
|
||||||
|
cutoff = _now() - timedelta(days=limits.purge_after_days)
|
||||||
|
table = get_table("instances")
|
||||||
|
for row in table.find(is_workspace=1):
|
||||||
|
deleted = _parse(row.get("deleted_at"))
|
||||||
|
if not deleted or deleted > cutoff:
|
||||||
|
continue
|
||||||
|
for tunnel in tunnels.list_for_instance(row["uid"]):
|
||||||
|
tunnels.soft_delete(tunnel["uid"], "system")
|
||||||
|
|
||||||
|
def collect_metrics(self) -> dict:
|
||||||
|
rows = self._workspaces()
|
||||||
|
running = [r for r in rows if r.get("status") == "running"]
|
||||||
|
suspended = [r for r in rows if r.get("suspended_at")]
|
||||||
|
disk = sum(int(r.get("disk_bytes") or 0) for r in rows)
|
||||||
|
egress = sum(int(r.get("egress_bytes") or 0) for r in rows)
|
||||||
|
open_flags = flags.list_flags()
|
||||||
|
tunnel_rows = list(get_table("tunnels").find(deleted_at=None))
|
||||||
|
by_status: dict[str, int] = {}
|
||||||
|
for row in tunnel_rows:
|
||||||
|
key = row.get("status") or "unknown"
|
||||||
|
by_status[key] = by_status.get(key, 0) + 1
|
||||||
|
return {
|
||||||
|
"stats": [
|
||||||
|
{"label": "Workspaces", "value": len(rows)},
|
||||||
|
{"label": "Running", "value": len(running)},
|
||||||
|
{"label": "Suspended", "value": len(suspended)},
|
||||||
|
{"label": "Disk MB", "value": disk // (1024 * 1024)},
|
||||||
|
{"label": "Egress MB", "value": egress // (1024 * 1024)},
|
||||||
|
{"label": "Open flags", "value": len(open_flags)},
|
||||||
|
{"label": "Tunnels", "value": len(tunnel_rows)},
|
||||||
|
],
|
||||||
|
"table": {
|
||||||
|
"columns": ["Tunnel status", "Count"],
|
||||||
|
"rows": [[key, value] for key, value in sorted(by_status.items())],
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -32,6 +32,11 @@ from .spec import Action, Catalog
|
|||||||
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
|
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
|
||||||
|
|
||||||
CONFIRM_REQUIRED = {
|
CONFIRM_REQUIRED = {
|
||||||
|
"workspace_stop",
|
||||||
|
"workspace_delete",
|
||||||
|
"tunnel_delete",
|
||||||
|
"workspace_flag_resolve",
|
||||||
|
"workspace_suspend",
|
||||||
"project_set_private",
|
"project_set_private",
|
||||||
"project_set_readonly",
|
"project_set_readonly",
|
||||||
"customize_set_css",
|
"customize_set_css",
|
||||||
@ -300,8 +305,10 @@ class Dispatcher:
|
|||||||
self._chunks = ChunkController(settings)
|
self._chunks = ChunkController(settings)
|
||||||
self._rsearch = RsearchController(settings, owner_kind, owner_id)
|
self._rsearch = RsearchController(settings, owner_kind, owner_id)
|
||||||
from ..container import ContainerController
|
from ..container import ContainerController
|
||||||
|
from ..workspace import WorkspaceController
|
||||||
|
|
||||||
self._container = ContainerController(client, owner_id=owner_id)
|
self._container = ContainerController(client, owner_id=owner_id)
|
||||||
|
self._workspace = WorkspaceController(owner_kind, owner_id, admin=is_admin)
|
||||||
from ..customization import CustomizationController
|
from ..customization import CustomizationController
|
||||||
|
|
||||||
self._customization = CustomizationController(owner_kind, owner_id)
|
self._customization = CustomizationController(owner_kind, owner_id)
|
||||||
@ -493,6 +500,9 @@ class Dispatcher:
|
|||||||
if action.handler == "container":
|
if action.handler == "container":
|
||||||
return await self._container.dispatch(action.name, arguments)
|
return await self._container.dispatch(action.name, arguments)
|
||||||
|
|
||||||
|
if action.handler == "workspace":
|
||||||
|
return await self._workspace.dispatch(action.name, arguments)
|
||||||
|
|
||||||
if action.handler == "customization":
|
if action.handler == "customization":
|
||||||
return await self._customization.dispatch(action.name, arguments)
|
return await self._customization.dispatch(action.name, arguments)
|
||||||
|
|
||||||
|
|||||||
@ -44,6 +44,7 @@ class Action:
|
|||||||
"chunks",
|
"chunks",
|
||||||
"rsearch",
|
"rsearch",
|
||||||
"container",
|
"container",
|
||||||
|
"workspace",
|
||||||
"customization",
|
"customization",
|
||||||
"notification",
|
"notification",
|
||||||
"behavior",
|
"behavior",
|
||||||
|
|||||||
212
devplacepy/services/devii/actions/workspace_actions.py
Normal file
212
devplacepy/services/devii/actions/workspace_actions.py
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from .spec import Action, Param
|
||||||
|
|
||||||
|
|
||||||
|
def arg(
|
||||||
|
name: str, description: str, required: bool = False, kind: str = "string"
|
||||||
|
) -> Param:
|
||||||
|
return Param(
|
||||||
|
name=name,
|
||||||
|
location="body",
|
||||||
|
description=description,
|
||||||
|
required=required,
|
||||||
|
type=kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SLUG = arg("project_slug", "Project slug or uid that owns the workspace.", required=True)
|
||||||
|
CONFIRM = arg(
|
||||||
|
"confirm",
|
||||||
|
"Set true only after showing the exact target to the user and getting agreement.",
|
||||||
|
kind="boolean",
|
||||||
|
)
|
||||||
|
|
||||||
|
WORKSPACE_ACTIONS: tuple[Action, ...] = (
|
||||||
|
Action(
|
||||||
|
name="workspace_open",
|
||||||
|
method="LOCAL",
|
||||||
|
path="",
|
||||||
|
handler="workspace",
|
||||||
|
requires_auth=True,
|
||||||
|
summary=(
|
||||||
|
"Open or resume the browser VS Code workspace for a project. "
|
||||||
|
"Creates one if the user has none, otherwise resumes the existing one."
|
||||||
|
),
|
||||||
|
params=(SLUG,),
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="workspace_status",
|
||||||
|
method="LOCAL",
|
||||||
|
path="",
|
||||||
|
handler="workspace",
|
||||||
|
requires_auth=True,
|
||||||
|
read_only=True,
|
||||||
|
summary=(
|
||||||
|
"Read a workspace's state, disk and egress usage against quota, idle "
|
||||||
|
"countdown, tunnels and any open moderation flags."
|
||||||
|
),
|
||||||
|
params=(SLUG,),
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="workspace_list",
|
||||||
|
method="LOCAL",
|
||||||
|
path="",
|
||||||
|
handler="workspace",
|
||||||
|
requires_auth=True,
|
||||||
|
read_only=True,
|
||||||
|
summary="List the caller's workspaces. Administrators see every workspace.",
|
||||||
|
params=(),
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="workspace_stop",
|
||||||
|
method="LOCAL",
|
||||||
|
path="",
|
||||||
|
handler="workspace",
|
||||||
|
requires_auth=True,
|
||||||
|
summary="Stop a running workspace. The files and tunnels are kept.",
|
||||||
|
params=(SLUG, CONFIRM),
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="workspace_delete",
|
||||||
|
method="LOCAL",
|
||||||
|
path="",
|
||||||
|
handler="workspace",
|
||||||
|
requires_auth=True,
|
||||||
|
summary="Delete a workspace. Removable from admin Trash afterwards.",
|
||||||
|
params=(SLUG, CONFIRM),
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="tunnel_list",
|
||||||
|
method="LOCAL",
|
||||||
|
path="",
|
||||||
|
handler="workspace",
|
||||||
|
requires_auth=True,
|
||||||
|
read_only=True,
|
||||||
|
summary="List the public HTTPS tunnels published by a workspace.",
|
||||||
|
params=(SLUG,),
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="tunnel_create",
|
||||||
|
method="LOCAL",
|
||||||
|
path="",
|
||||||
|
handler="workspace",
|
||||||
|
requires_auth=True,
|
||||||
|
summary=(
|
||||||
|
"Publish a container port on a public HTTPS hostname. The resulting URL "
|
||||||
|
"is PUBLIC and unauthenticated - warn the user before creating one."
|
||||||
|
),
|
||||||
|
params=(
|
||||||
|
SLUG,
|
||||||
|
arg("container_port", "Port inside the container.", required=True, kind="integer"),
|
||||||
|
arg("label", "Human label for the tunnel."),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="tunnel_delete",
|
||||||
|
method="LOCAL",
|
||||||
|
path="",
|
||||||
|
handler="workspace",
|
||||||
|
requires_auth=True,
|
||||||
|
summary="Remove a public tunnel. The URL stops serving immediately.",
|
||||||
|
params=(SLUG, arg("tunnel_uid", "Tunnel uid.", required=True), CONFIRM),
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="workspace_quota_get",
|
||||||
|
method="LOCAL",
|
||||||
|
path="",
|
||||||
|
handler="workspace",
|
||||||
|
requires_auth=True,
|
||||||
|
read_only=True,
|
||||||
|
summary=(
|
||||||
|
"Read the effective workspace limits for the caller, or for any user "
|
||||||
|
"when the caller is an administrator."
|
||||||
|
),
|
||||||
|
params=(arg("username", "Administrators only: whose quota to read."),),
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="workspace_quota_set",
|
||||||
|
method="LOCAL",
|
||||||
|
path="",
|
||||||
|
handler="workspace",
|
||||||
|
requires_auth=True,
|
||||||
|
requires_admin=True,
|
||||||
|
summary="Set a per-user workspace quota rule. Zero means inherit the global value.",
|
||||||
|
params=(
|
||||||
|
arg("username", "User to apply the rule to.", required=True),
|
||||||
|
arg("max_workspaces", "Workspace count limit.", kind="integer"),
|
||||||
|
arg("max_tunnels", "Tunnel count limit.", kind="integer"),
|
||||||
|
arg("disk_quota_mb", "Disk quota in MB.", kind="integer"),
|
||||||
|
arg("egress_quota_mb", "Egress quota in MB.", kind="integer"),
|
||||||
|
arg("idle_stop_minutes", "Idle stop window in minutes.", kind="integer"),
|
||||||
|
arg("retention_days", "Retention in days.", kind="integer"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="workspace_flag_list",
|
||||||
|
method="LOCAL",
|
||||||
|
path="",
|
||||||
|
handler="workspace",
|
||||||
|
requires_auth=True,
|
||||||
|
read_only=True,
|
||||||
|
summary=(
|
||||||
|
"List moderation flags. A member sees only flags on their own "
|
||||||
|
"workspaces; an administrator sees every flag."
|
||||||
|
),
|
||||||
|
params=(arg("status", "Filter by status: open, resolved, dismissed."),),
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="workspace_flag_raise",
|
||||||
|
method="LOCAL",
|
||||||
|
path="",
|
||||||
|
handler="workspace",
|
||||||
|
requires_auth=True,
|
||||||
|
requires_admin=True,
|
||||||
|
summary="Raise a moderation flag against a workspace.",
|
||||||
|
params=(
|
||||||
|
SLUG,
|
||||||
|
arg("username", "Owner of the workspace.", required=True),
|
||||||
|
arg("severity", "info, warn or critical."),
|
||||||
|
arg("detail", "Why the flag was raised."),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="workspace_flag_resolve",
|
||||||
|
method="LOCAL",
|
||||||
|
path="",
|
||||||
|
handler="workspace",
|
||||||
|
requires_auth=True,
|
||||||
|
requires_admin=True,
|
||||||
|
summary="Resolve or dismiss a moderation flag.",
|
||||||
|
params=(
|
||||||
|
arg("flag_uid", "Flag uid.", required=True),
|
||||||
|
arg("status", "resolved or dismissed."),
|
||||||
|
CONFIRM,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="workspace_suspend",
|
||||||
|
method="LOCAL",
|
||||||
|
path="",
|
||||||
|
handler="workspace",
|
||||||
|
requires_auth=True,
|
||||||
|
requires_admin=True,
|
||||||
|
summary="Suspend a workspace. It stops serving; no data is destroyed.",
|
||||||
|
params=(
|
||||||
|
SLUG,
|
||||||
|
arg("username", "Owner of the workspace.", required=True),
|
||||||
|
arg("reason", "Reason shown to the owner.", required=True),
|
||||||
|
CONFIRM,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
name="workspace_unsuspend",
|
||||||
|
method="LOCAL",
|
||||||
|
path="",
|
||||||
|
handler="workspace",
|
||||||
|
requires_auth=True,
|
||||||
|
requires_admin=True,
|
||||||
|
summary="Lift a suspension and let the workspace serve again.",
|
||||||
|
params=(SLUG, arg("username", "Owner of the workspace.", required=True)),
|
||||||
|
),
|
||||||
|
)
|
||||||
@ -19,6 +19,7 @@ from .actions.notification_actions import NOTIFICATION_ACTIONS
|
|||||||
from .actions.rsearch_actions import RSEARCH_ACTIONS
|
from .actions.rsearch_actions import RSEARCH_ACTIONS
|
||||||
from .actions.spec import Catalog
|
from .actions.spec import Catalog
|
||||||
from .actions.telegram_actions import TELEGRAM_ACTIONS
|
from .actions.telegram_actions import TELEGRAM_ACTIONS
|
||||||
|
from .actions.workspace_actions import WORKSPACE_ACTIONS
|
||||||
from .interaction.actions import INTERACTION_ACTIONS
|
from .interaction.actions import INTERACTION_ACTIONS
|
||||||
from .virtual_tools.actions import VIRTUAL_TOOL_ACTIONS
|
from .virtual_tools.actions import VIRTUAL_TOOL_ACTIONS
|
||||||
from .agentic.actions import AGENTIC_ACTIONS
|
from .agentic.actions import AGENTIC_ACTIONS
|
||||||
@ -36,6 +37,7 @@ CATALOG = Catalog(
|
|||||||
+ CHUNK_ACTIONS
|
+ CHUNK_ACTIONS
|
||||||
+ RSEARCH_ACTIONS
|
+ RSEARCH_ACTIONS
|
||||||
+ CONTAINER_ACTIONS
|
+ CONTAINER_ACTIONS
|
||||||
|
+ WORKSPACE_ACTIONS
|
||||||
+ CUSTOMIZATION_ACTIONS
|
+ CUSTOMIZATION_ACTIONS
|
||||||
+ BEHAVIOR_ACTIONS
|
+ BEHAVIOR_ACTIONS
|
||||||
+ NOTIFICATION_ACTIONS
|
+ NOTIFICATION_ACTIONS
|
||||||
|
|||||||
5
devplacepy/services/devii/workspace/__init__.py
Normal file
5
devplacepy/services/devii/workspace/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from .controller import WorkspaceController
|
||||||
|
|
||||||
|
__all__ = ["WorkspaceController"]
|
||||||
243
devplacepy/services/devii/workspace/controller.py
Normal file
243
devplacepy/services/devii/workspace/controller.py
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from devplacepy.database import get_table, resolve_by_slug
|
||||||
|
from devplacepy.services.containers import store
|
||||||
|
from devplacepy.services.containers.workspace import (
|
||||||
|
flags,
|
||||||
|
provision,
|
||||||
|
quota,
|
||||||
|
tunnels,
|
||||||
|
)
|
||||||
|
from devplacepy.services.containers.workspace.provision import WorkspaceError
|
||||||
|
from devplacepy.utils import generate_uid
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceController:
|
||||||
|
def __init__(self, owner_kind: str, owner_id: str, admin: bool = False) -> None:
|
||||||
|
self.owner_kind = owner_kind
|
||||||
|
self.owner_id = owner_id
|
||||||
|
self.admin = admin
|
||||||
|
|
||||||
|
def _user(self) -> dict | None:
|
||||||
|
if self.owner_kind != "user" or not self.owner_id:
|
||||||
|
return None
|
||||||
|
return get_table("users").find_one(uid=self.owner_id)
|
||||||
|
|
||||||
|
def _user_by_name(self, username: str) -> dict | None:
|
||||||
|
if not username:
|
||||||
|
return None
|
||||||
|
return get_table("users").find_one(username=username)
|
||||||
|
|
||||||
|
def _project(self, slug: str) -> dict | None:
|
||||||
|
return resolve_by_slug(get_table("projects"), slug)
|
||||||
|
|
||||||
|
def _resolve(self, slug: str, owner_uid: str = "") -> dict:
|
||||||
|
project = self._project(slug)
|
||||||
|
if not project:
|
||||||
|
raise WorkspaceError(f"project not found: {slug}")
|
||||||
|
target = owner_uid or self.owner_id
|
||||||
|
instance = provision.find_for_project(project["uid"], target)
|
||||||
|
if not instance:
|
||||||
|
raise WorkspaceError("no workspace exists for this project")
|
||||||
|
return instance
|
||||||
|
|
||||||
|
async def dispatch(self, name: str, args: dict[str, Any]) -> Any:
|
||||||
|
handler = getattr(self, f"_{name}", None)
|
||||||
|
if handler is None:
|
||||||
|
return {"error": f"unknown workspace action: {name}"}
|
||||||
|
try:
|
||||||
|
result = handler(args)
|
||||||
|
if hasattr(result, "__await__"):
|
||||||
|
return await result
|
||||||
|
return result
|
||||||
|
except WorkspaceError as error:
|
||||||
|
return {"error": str(error)}
|
||||||
|
|
||||||
|
async def _workspace_open(self, args: dict) -> Any:
|
||||||
|
user = self._user()
|
||||||
|
if not user:
|
||||||
|
raise WorkspaceError("sign in to use workspaces")
|
||||||
|
project = self._project(args.get("project_slug", ""))
|
||||||
|
if not project:
|
||||||
|
raise WorkspaceError("project not found")
|
||||||
|
instance = await provision.ensure(project, user)
|
||||||
|
instance = provision.resume(instance)
|
||||||
|
provision.write_manifest(instance)
|
||||||
|
view = provision.view(instance)
|
||||||
|
view["editor_url"] = (
|
||||||
|
f"/projects/{project.get('slug') or project['uid']}/workspace"
|
||||||
|
)
|
||||||
|
return view
|
||||||
|
|
||||||
|
def _workspace_status(self, args: dict) -> Any:
|
||||||
|
return provision.view(self._resolve(args.get("project_slug", "")))
|
||||||
|
|
||||||
|
def _workspace_list(self, args: dict) -> Any:
|
||||||
|
table = get_table("instances")
|
||||||
|
filters: dict[str, Any] = {"is_workspace": 1, "deleted_at": None}
|
||||||
|
if not self.admin:
|
||||||
|
filters["workspace_owner_uid"] = self.owner_id
|
||||||
|
return {
|
||||||
|
"workspaces": [provision.view(row) for row in table.find(**filters)]
|
||||||
|
}
|
||||||
|
|
||||||
|
def _workspace_stop(self, args: dict) -> Any:
|
||||||
|
instance = self._resolve(args.get("project_slug", ""))
|
||||||
|
provision.stop(instance)
|
||||||
|
return {"ok": True, "status": "stopping", "uid": instance["uid"]}
|
||||||
|
|
||||||
|
def _workspace_delete(self, args: dict) -> Any:
|
||||||
|
instance = self._resolve(args.get("project_slug", ""))
|
||||||
|
for row in tunnels.list_for_instance(instance["uid"]):
|
||||||
|
tunnels.soft_delete(row["uid"], self.owner_id)
|
||||||
|
store.delete_instance(instance["uid"], self.owner_id)
|
||||||
|
return {"ok": True, "deleted": instance["uid"]}
|
||||||
|
|
||||||
|
def _tunnel_list(self, args: dict) -> Any:
|
||||||
|
instance = self._resolve(args.get("project_slug", ""))
|
||||||
|
return {"tunnels": tunnels.list_for_instance(instance["uid"])}
|
||||||
|
|
||||||
|
def _tunnel_create(self, args: dict) -> Any:
|
||||||
|
instance = self._resolve(args.get("project_slug", ""))
|
||||||
|
port = int(args.get("container_port") or 0)
|
||||||
|
if port <= 0:
|
||||||
|
raise WorkspaceError("container_port is required")
|
||||||
|
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
|
||||||
|
if limits.max_tunnels and tunnels.count_for_instance(
|
||||||
|
instance["uid"]
|
||||||
|
) >= limits.max_tunnels:
|
||||||
|
raise WorkspaceError(f"tunnel limit reached ({limits.max_tunnels})")
|
||||||
|
row = tunnels.create(
|
||||||
|
instance, args.get("label", ""), port, self.owner_id
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise WorkspaceError("could not create tunnel")
|
||||||
|
provision.write_manifest(instance)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"tunnel": row,
|
||||||
|
"url": f"https://{row['hostname']}",
|
||||||
|
"public": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _tunnel_delete(self, args: dict) -> Any:
|
||||||
|
instance = self._resolve(args.get("project_slug", ""))
|
||||||
|
uid = args.get("tunnel_uid", "")
|
||||||
|
row = tunnels.get(uid)
|
||||||
|
if not row or row.get("instance_uid") != instance["uid"]:
|
||||||
|
raise WorkspaceError("tunnel not found on this workspace")
|
||||||
|
tunnels.soft_delete(uid, self.owner_id)
|
||||||
|
provision.write_manifest(instance)
|
||||||
|
return {"ok": True, "deleted": uid}
|
||||||
|
|
||||||
|
def _workspace_quota_get(self, args: dict) -> Any:
|
||||||
|
target = self.owner_id
|
||||||
|
username = args.get("username", "")
|
||||||
|
if username:
|
||||||
|
if not self.admin:
|
||||||
|
raise WorkspaceError("only administrators may read another user's quota")
|
||||||
|
other = self._user_by_name(username)
|
||||||
|
if not other:
|
||||||
|
raise WorkspaceError(f"user not found: {username}")
|
||||||
|
target = other["uid"]
|
||||||
|
limits = quota.resolve(target)
|
||||||
|
return {
|
||||||
|
"max_workspaces": limits.max_workspaces,
|
||||||
|
"max_tunnels": limits.max_tunnels,
|
||||||
|
"disk_quota_mb": limits.disk_quota_mb,
|
||||||
|
"egress_quota_mb": limits.egress_quota_mb,
|
||||||
|
"idle_stop_minutes": limits.idle_stop_minutes,
|
||||||
|
"retention_days": limits.retention_days,
|
||||||
|
"used_workspaces": provision.count_for_owner(target),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _workspace_quota_set(self, args: dict) -> Any:
|
||||||
|
if not self.admin:
|
||||||
|
raise WorkspaceError("administrators only")
|
||||||
|
other = self._user_by_name(args.get("username", ""))
|
||||||
|
if not other:
|
||||||
|
raise WorkspaceError("user not found")
|
||||||
|
table = get_table(quota.RULES_TABLE)
|
||||||
|
existing = table.find_one(
|
||||||
|
owner_kind="user", owner_id=other["uid"], deleted_at=None
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
key: int(args.get(key) or 0)
|
||||||
|
for key in quota.RULE_COLUMNS
|
||||||
|
if args.get(key) is not None
|
||||||
|
}
|
||||||
|
if existing:
|
||||||
|
table.update({"uid": existing["uid"], **payload}, ["uid"])
|
||||||
|
uid = existing["uid"]
|
||||||
|
else:
|
||||||
|
uid = generate_uid()
|
||||||
|
table.insert(
|
||||||
|
{
|
||||||
|
"uid": uid,
|
||||||
|
"owner_kind": "user",
|
||||||
|
"owner_id": other["uid"],
|
||||||
|
"label": args.get("label", "") or other.get("username", ""),
|
||||||
|
"created_at": "",
|
||||||
|
"updated_at": "",
|
||||||
|
"deleted_at": None,
|
||||||
|
"deleted_by": None,
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"ok": True, "rule_uid": uid, "applied": payload}
|
||||||
|
|
||||||
|
def _workspace_flag_list(self, args: dict) -> Any:
|
||||||
|
status = args.get("status", "open")
|
||||||
|
if self.admin:
|
||||||
|
return {"flags": flags.list_flags(status=status)}
|
||||||
|
return {"flags": flags.list_flags(user_uid=self.owner_id, status=status)}
|
||||||
|
|
||||||
|
def _workspace_flag_raise(self, args: dict) -> Any:
|
||||||
|
if not self.admin:
|
||||||
|
raise WorkspaceError("administrators only")
|
||||||
|
owner = self._user_by_name(args.get("username", ""))
|
||||||
|
if not owner:
|
||||||
|
raise WorkspaceError("user not found")
|
||||||
|
instance = self._resolve(args.get("project_slug", ""), owner["uid"])
|
||||||
|
row = flags.raise_flag(
|
||||||
|
instance,
|
||||||
|
flags.KIND_MANUAL,
|
||||||
|
args.get("severity", "warn"),
|
||||||
|
args.get("detail", ""),
|
||||||
|
)
|
||||||
|
return {"ok": True, "flag": row}
|
||||||
|
|
||||||
|
def _workspace_flag_resolve(self, args: dict) -> Any:
|
||||||
|
if not self.admin:
|
||||||
|
raise WorkspaceError("administrators only")
|
||||||
|
status = args.get("status", "resolved")
|
||||||
|
if not flags.set_status(args.get("flag_uid", ""), status, self.owner_id):
|
||||||
|
raise WorkspaceError("flag not found or invalid status")
|
||||||
|
return {"ok": True, "status": status}
|
||||||
|
|
||||||
|
def _workspace_suspend(self, args: dict) -> Any:
|
||||||
|
if not self.admin:
|
||||||
|
raise WorkspaceError("administrators only")
|
||||||
|
owner = self._user_by_name(args.get("username", ""))
|
||||||
|
if not owner:
|
||||||
|
raise WorkspaceError("user not found")
|
||||||
|
instance = self._resolve(args.get("project_slug", ""), owner["uid"])
|
||||||
|
reason = args.get("reason", "")
|
||||||
|
if not reason:
|
||||||
|
raise WorkspaceError("a reason is required and is shown to the owner")
|
||||||
|
provision.suspend(instance, self.owner_id, reason)
|
||||||
|
return {"ok": True, "suspended": instance["uid"], "reason": reason}
|
||||||
|
|
||||||
|
def _workspace_unsuspend(self, args: dict) -> Any:
|
||||||
|
if not self.admin:
|
||||||
|
raise WorkspaceError("administrators only")
|
||||||
|
owner = self._user_by_name(args.get("username", ""))
|
||||||
|
if not owner:
|
||||||
|
raise WorkspaceError("user not found")
|
||||||
|
instance = self._resolve(args.get("project_slug", ""), owner["uid"])
|
||||||
|
provision.unsuspend(instance)
|
||||||
|
return {"ok": True, "resumed": instance["uid"]}
|
||||||
@ -5,7 +5,7 @@ from datetime import datetime, timezone
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from devplacepy.config import SECONDS_PER_DAY
|
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.utils import generate_uid
|
||||||
from devplacepy.services.devrant.ids import as_int, now_unix
|
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():
|
if as_int(token.get("expire_time")) < now_unix():
|
||||||
return None
|
return None
|
||||||
user = get_table("users").find_one(uid=token.get("user_uid"))
|
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 None
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@ -73,7 +73,7 @@ def resolve_user_by_key(key: str) -> Optional[dict]:
|
|||||||
if expire and expire < now_unix():
|
if expire and expire < now_unix():
|
||||||
return None
|
return None
|
||||||
user = get_table("users").find_one(uid=token.get("user_uid"))
|
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 None
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|||||||
118
devplacepy/static/css/workspace.css
Normal file
118
devplacepy/static/css/workspace.css
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
/* retoor <retoor@molodetz.nl> */
|
||||||
|
|
||||||
|
.workspace-page {
|
||||||
|
max-width: var(--content-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-muted {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-state {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-meters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin: var(--space-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-meter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-tunnel-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 var(--space-sm) 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-tunnel {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-badge {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0 var(--space-xs);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-badge-active {
|
||||||
|
background: var(--success);
|
||||||
|
color: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-badge-failed {
|
||||||
|
background: var(--danger);
|
||||||
|
color: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-badge-provisioning,
|
||||||
|
.workspace-badge-pending {
|
||||||
|
background: var(--warning);
|
||||||
|
color: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-tunnel-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-suspended {
|
||||||
|
border-left: 3px solid var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-flag-critical {
|
||||||
|
border-left: 3px solid var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-flag-warn {
|
||||||
|
border-left: 3px solid var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-flag-info {
|
||||||
|
border-left: 3px solid var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.workspace-meters {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
devplacepy/static/js/WorkspaceManager.js
Normal file
63
devplacepy/static/js/WorkspaceManager.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
// retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import { Http } from "./Http.js";
|
||||||
|
import { Poller } from "./Poller.js";
|
||||||
|
|
||||||
|
export class WorkspaceManager {
|
||||||
|
constructor(root) {
|
||||||
|
this.root = root;
|
||||||
|
this.slug = root.dataset.slug;
|
||||||
|
this.poller = null;
|
||||||
|
this.bind();
|
||||||
|
this.subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
static mount() {
|
||||||
|
const root = document.querySelector("[data-workspace-root]");
|
||||||
|
return root ? new WorkspaceManager(root) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bind() {
|
||||||
|
this.root.addEventListener("submit", (event) => {
|
||||||
|
const form = event.target.closest("form");
|
||||||
|
if (!form || form.dataset.confirm) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this.send(form);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(form) {
|
||||||
|
try {
|
||||||
|
await Http.sendForm(form);
|
||||||
|
await this.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
window.app?.toast?.show(error.message || "Action failed", { type: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe() {
|
||||||
|
const uid = this.root.dataset.workspaceUid;
|
||||||
|
if (uid && window.app?.pubsub) {
|
||||||
|
window.app.pubsub.subscribe(`workspace.${uid}.detail`, () => this.render());
|
||||||
|
}
|
||||||
|
this.poller = new Poller(() => this.refresh(), 20000);
|
||||||
|
this.poller.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
try {
|
||||||
|
const data = await Http.getJson(`/projects/${this.slug}/workspace`);
|
||||||
|
this.render(data);
|
||||||
|
} catch (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(data) {
|
||||||
|
if (!data || !data.workspace) return;
|
||||||
|
const state = this.root.querySelector("[data-workspace-status]");
|
||||||
|
if (state) state.textContent = data.workspace.status || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkspaceManager;
|
||||||
@ -29,6 +29,9 @@
|
|||||||
<a href="/admin/containers" class="sidebar-link {% if admin_section == 'containers' %}active{% endif %}">
|
<a href="/admin/containers" class="sidebar-link {% if admin_section == 'containers' %}active{% endif %}">
|
||||||
<span class="sidebar-icon">📦</span> Containers
|
<span class="sidebar-icon">📦</span> Containers
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/workspaces" class="sidebar-link {% if admin_section == 'workspaces' %}active{% endif %}">
|
||||||
|
<span class="sidebar-icon">💻</span> Workspaces
|
||||||
|
</a>
|
||||||
<a href="/admin/devii-tasks" class="sidebar-link {% if admin_section == 'devii-tasks' %}active{% endif %}">
|
<a href="/admin/devii-tasks" class="sidebar-link {% if admin_section == 'devii-tasks' %}active{% endif %}">
|
||||||
<span class="sidebar-icon">⏰</span> Devii tasks
|
<span class="sidebar-icon">⏰</span> Devii tasks
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
114
devplacepy/templates/admin_workspaces.html
Normal file
114
devplacepy/templates/admin_workspaces.html
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
{% extends "admin_base.html" %}
|
||||||
|
{% block extra_head %}
|
||||||
|
{{ super() }}
|
||||||
|
<link rel="stylesheet" href="{{ static_url('/static/css/workspace.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
{% block admin_content %}
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<h2>Workspaces</h2>
|
||||||
|
<span class="admin-count">{{ workspaces|length }} workspaces</span>
|
||||||
|
<span class="admin-count">{{ flags|length }} open flags</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table" id="ws-admin-list" data-endpoint="/admin/workspaces/data">
|
||||||
|
<caption class="sr-only">Dev workspaces</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Disk</th>
|
||||||
|
<th>Egress</th>
|
||||||
|
<th>Tunnels</th>
|
||||||
|
<th>Flags</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ws in workspaces %}
|
||||||
|
<tr data-workspace-uid="{{ ws.uid }}">
|
||||||
|
<td>{{ ws.name }}</td>
|
||||||
|
<td>{{ ws.owner_username }}</td>
|
||||||
|
<td>{{ ws.project_title }}</td>
|
||||||
|
<td>
|
||||||
|
{% if ws.suspended %}
|
||||||
|
<span class="workspace-badge workspace-badge-failed">suspended</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="workspace-badge">{{ ws.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ ws.disk_percent }}% of {{ ws.disk_quota_mb }} MB</td>
|
||||||
|
<td>{{ ws.egress_percent }}% of {{ ws.egress_quota_mb }} MB</td>
|
||||||
|
<td>{{ ws.tunnels|length }}</td>
|
||||||
|
<td>{{ ws.flags|length }}</td>
|
||||||
|
<td class="admin-actions">
|
||||||
|
{% if ws.status == "running" %}
|
||||||
|
<form method="post" action="/admin/workspaces/{{ ws.uid }}/stop">
|
||||||
|
<button type="submit" class="admin-btn">Stop</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="/admin/workspaces/{{ ws.uid }}/start">
|
||||||
|
<button type="submit" class="admin-btn">Start</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if ws.suspended %}
|
||||||
|
<form method="post" action="/admin/workspaces/{{ ws.uid }}/unsuspend">
|
||||||
|
<button type="submit" class="admin-btn">Unsuspend</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="/admin/workspaces/{{ ws.uid }}/suspend">
|
||||||
|
<input type="text" name="reason" placeholder="Reason" maxlength="500" required>
|
||||||
|
<button type="submit" class="admin-btn">Suspend</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/admin/workspaces/{{ ws.uid }}/flag">
|
||||||
|
<input type="text" name="detail" placeholder="Flag detail" maxlength="500">
|
||||||
|
<button type="submit" class="admin-btn">Flag</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/workspaces/{{ ws.uid }}/delete">
|
||||||
|
<button type="submit" data-confirm="Delete this workspace?" data-confirm-danger class="admin-btn admin-btn-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="9">No workspaces yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<h2>Open flags</h2>
|
||||||
|
</div>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<caption class="sr-only">Open workspace flags</caption>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Kind</th><th>Severity</th><th>Detail</th><th>Value</th><th>Threshold</th><th>Actions</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for flag in flags %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ flag.kind }}</td>
|
||||||
|
<td>{{ flag.severity }}</td>
|
||||||
|
<td>{{ flag.detail }}</td>
|
||||||
|
<td>{{ flag.metric_value }}</td>
|
||||||
|
<td>{{ flag.threshold }}</td>
|
||||||
|
<td class="admin-actions">
|
||||||
|
<form method="post" action="/admin/workspaces/flags/{{ flag.uid }}/resolve?status=resolved">
|
||||||
|
<button type="submit" class="admin-btn">Resolve</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/workspaces/flags/{{ flag.uid }}/resolve?status=dismissed">
|
||||||
|
<button type="submit" class="admin-btn">Dismiss</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6">No open flags.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
124
devplacepy/templates/workspace.html
Normal file
124
devplacepy/templates/workspace.html
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<link rel="stylesheet" href="{{ static_url('css/containers.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ static_url('css/workspace.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="workspace-page" data-workspace-root
|
||||||
|
data-slug="{{ project.slug or project.uid }}"
|
||||||
|
data-has-workspace="{{ 1 if has_workspace else 0 }}">
|
||||||
|
|
||||||
|
<h1 class="workspace-title">Workspace: {{ project.title }}</h1>
|
||||||
|
|
||||||
|
{% if not has_workspace %}
|
||||||
|
<div class="card workspace-empty">
|
||||||
|
<p>No workspace yet for this project. Opening one starts a container with your
|
||||||
|
project files, a browser editor, and a terminal.</p>
|
||||||
|
<p class="workspace-muted">You are using {{ workspace_count }} of {{ max_workspaces }} workspaces.</p>
|
||||||
|
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace">
|
||||||
|
<button type="submit" class="btn btn-primary">Open workspace</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{% if workspace.suspended %}
|
||||||
|
<div class="card workspace-suspended">
|
||||||
|
<strong>This workspace is suspended.</strong>
|
||||||
|
<p>{{ workspace.flag_reason or "Contact an administrator." }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for flag in workspace.flags %}
|
||||||
|
<div class="card workspace-flag workspace-flag-{{ flag.severity }}">
|
||||||
|
<strong>{{ flag.kind }}</strong>
|
||||||
|
<span>{{ flag.detail }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="card workspace-summary">
|
||||||
|
<div class="workspace-state" data-workspace-status>{{ workspace.status }}</div>
|
||||||
|
<div class="workspace-meters">
|
||||||
|
<div class="workspace-meter">
|
||||||
|
<span>Disk</span>
|
||||||
|
<progress max="100" value="{{ workspace.disk_percent }}"></progress>
|
||||||
|
<span>{{ workspace.disk_percent }}% of {{ workspace.disk_quota_mb }} MB</span>
|
||||||
|
</div>
|
||||||
|
<div class="workspace-meter">
|
||||||
|
<span>Egress</span>
|
||||||
|
<progress max="100" value="{{ workspace.egress_percent }}"></progress>
|
||||||
|
<span>{{ workspace.egress_percent }}% of {{ workspace.egress_quota_mb }} MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="workspace-muted">
|
||||||
|
Stops after {{ workspace.idle_stop_minutes }} minutes idle. Removed after
|
||||||
|
{{ workspace.retention_days }} days idle.
|
||||||
|
{% if workspace.last_active_at %}
|
||||||
|
Last active {{ dt_ago(workspace.last_active_at) }}.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="workspace-actions">
|
||||||
|
{% if workspace.status == "running" %}
|
||||||
|
<a class="btn btn-primary" href="{{ editor_url }}">Open editor</a>
|
||||||
|
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace/stop">
|
||||||
|
<button type="submit" class="btn">Stop</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace">
|
||||||
|
<button type="submit" class="btn btn-primary">Start</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace/delete">
|
||||||
|
<button type="submit" data-confirm="Delete this workspace?" data-confirm-danger class="btn btn-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card workspace-tunnels">
|
||||||
|
<h2>Public tunnels</h2>
|
||||||
|
<p class="workspace-muted">
|
||||||
|
A tunnel publishes a port from inside your container on a public HTTPS address.
|
||||||
|
Anyone with the link can reach it. You may have up to {{ workspace.max_tunnels }}.
|
||||||
|
</p>
|
||||||
|
<ul class="workspace-tunnel-list" data-tunnel-list>
|
||||||
|
{% for tunnel in workspace.tunnels %}
|
||||||
|
<li class="workspace-tunnel">
|
||||||
|
<a href="https://{{ tunnel.hostname }}" rel="noopener" target="_blank">{{ tunnel.hostname }}</a>
|
||||||
|
<span class="workspace-badge">port {{ tunnel.container_port }}</span>
|
||||||
|
<span class="workspace-badge workspace-badge-{{ tunnel.status }}">{{ tunnel.status }}</span>
|
||||||
|
{% if tunnel.last_error %}<span class="workspace-error">{{ tunnel.last_error }}</span>{% endif %}
|
||||||
|
<form method="post"
|
||||||
|
action="/projects/{{ project.slug or project.uid }}/workspace/tunnels/{{ tunnel.uid }}/delete">
|
||||||
|
<button type="submit" data-confirm="Remove this tunnel?" data-confirm-danger class="btn btn-small">Remove</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="workspace-muted">No tunnels yet.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<form class="workspace-tunnel-form" method="post"
|
||||||
|
action="/projects/{{ project.slug or project.uid }}/workspace/tunnels">
|
||||||
|
<input type="text" name="label" placeholder="Label (optional)" maxlength="64">
|
||||||
|
<input type="number" name="container_port" placeholder="Port" min="1" max="65535" required>
|
||||||
|
<button type="submit" class="btn">Add tunnel</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card workspace-help">
|
||||||
|
<h2>Inside the container</h2>
|
||||||
|
<ul>
|
||||||
|
<li><code>sudo</code> and <code>apt install</code> work with no extra setup.</li>
|
||||||
|
<li>Python, Rust, Nim and Swift toolchains are preinstalled.</li>
|
||||||
|
<li>Ports below 1024 cannot bind. Use a high port and a tunnel.</li>
|
||||||
|
<li>Your public URLs are also in <code>/app/.devplace/tunnels.json</code>.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script type="module" src="{{ static_url('js/WorkspaceManager.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
@ -5,7 +5,7 @@ import binascii
|
|||||||
import hashlib
|
import hashlib
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from fastapi import Request
|
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.authcache import _user_cache, _sync_auth_cache
|
||||||
from devplacepy.utils.passwords import verify_password
|
from devplacepy.utils.passwords import verify_password
|
||||||
|
|
||||||
@ -32,7 +32,7 @@ def _user_from_session(request: Request):
|
|||||||
return None
|
return None
|
||||||
users = get_table("users")
|
users = get_table("users")
|
||||||
user = users.find_one(uid=session["user_uid"])
|
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"])
|
sessions.delete(id=session["id"])
|
||||||
_user_cache.pop(token)
|
_user_cache.pop(token)
|
||||||
return None
|
return None
|
||||||
@ -50,7 +50,7 @@ def _user_from_api_key(key: str):
|
|||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
user = get_table("users").find_one(api_key=key)
|
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
|
return None
|
||||||
_user_cache.set(cache_key, user)
|
_user_cache.set(cache_key, user)
|
||||||
return user
|
return user
|
||||||
@ -76,7 +76,7 @@ def _user_from_basic(header: str):
|
|||||||
user = users.find_one(username=identifier) or users.find_one(
|
user = users.find_one(username=identifier) or users.find_one(
|
||||||
email=identifier.lower()
|
email=identifier.lower()
|
||||||
)
|
)
|
||||||
if not user or not user.get("is_active", True):
|
if not user or not is_account_active(user):
|
||||||
return None
|
return None
|
||||||
if not verify_password(password, user.get("password_hash", "")):
|
if not verify_password(password, user.get("password_hash", "")):
|
||||||
return None
|
return None
|
||||||
|
|||||||
24
events.md
24
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.status` | `services/containers/service.py` |
|
||||||
| `container.instance.sync` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
|
| `container.instance.sync` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
|
||||||
| `container.reconcile.action` | `services/containers/service.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.create` | `routers/projects/containers/schedules.py`, `services/devii/actions/dispatcher.py` |
|
||||||
| `container.schedule.delete` | `routers/projects/containers/schedules.py` |
|
| `container.schedule.delete` | `routers/projects/containers/schedules.py` |
|
||||||
|
|
||||||
|
|||||||
@ -14,9 +14,42 @@ ENV PYTHONUNBUFFERED=1 \
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
git curl wget vim ack ca-certificates build-essential libpq-dev \
|
git curl wget vim ack ca-certificates build-essential libpq-dev \
|
||||||
tmux apache2-utils procps htop iftop iotop netcat-openbsd zip unzip \
|
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/*
|
&& 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
|
FROM base AS deps
|
||||||
RUN pip install \
|
RUN pip install \
|
||||||
pip setuptools wheel packaging \
|
pip setuptools wheel packaging \
|
||||||
@ -68,6 +101,31 @@ RUN set -eu; \
|
|||||||
done
|
done
|
||||||
|
|
||||||
USER pravda
|
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
|
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"]
|
CMD ["sleep", "infinity"]
|
||||||
|
|||||||
@ -181,3 +181,45 @@ def test_rate_limit_block_recorded(monkeypatch):
|
|||||||
event_key="security.rate_limit.block", result="denied"
|
event_key="security.rate_limit.block", result="denied"
|
||||||
)
|
)
|
||||||
assert event is not None
|
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
|
||||||
|
|||||||
230
tests/api/projects/workspace.py
Normal file
230
tests/api/projects/workspace.py
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from devplacepy.content import can_manage_workspace, can_open_workspace
|
||||||
|
from devplacepy.database import get_table, init_db, set_setting
|
||||||
|
from devplacepy.services.containers import activity, store
|
||||||
|
from devplacepy.services.containers.workspace import (
|
||||||
|
flags,
|
||||||
|
naming,
|
||||||
|
provision,
|
||||||
|
quota,
|
||||||
|
tunnels,
|
||||||
|
)
|
||||||
|
from devplacepy.services.containers.workspace.provision import WorkspaceError
|
||||||
|
from tests.conftest import run_async
|
||||||
|
|
||||||
|
OWNER = "user-owner"
|
||||||
|
OTHER = "user-other"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _workspace_db():
|
||||||
|
init_db()
|
||||||
|
set_setting("workspace_enabled", "1")
|
||||||
|
yield
|
||||||
|
for table in ("instances", "tunnels", "workspace_flags", "workspace_quota_rules"):
|
||||||
|
get_table(table).delete()
|
||||||
|
|
||||||
|
|
||||||
|
def _project(uid: str = "proj-ws") -> dict:
|
||||||
|
return {"uid": uid, "slug": "demo", "title": "Demo", "user_uid": OWNER}
|
||||||
|
|
||||||
|
|
||||||
|
def _instance(**overrides) -> dict:
|
||||||
|
row = {
|
||||||
|
"project_uid": "proj-ws",
|
||||||
|
"name": "ws-demo",
|
||||||
|
"status": "running",
|
||||||
|
"desired_state": "running",
|
||||||
|
"is_workspace": 1,
|
||||||
|
"workspace_owner_uid": OWNER,
|
||||||
|
"tunnel_name": naming.generate(),
|
||||||
|
"ports_json": '[{"host": 20500, "container": 8080, "proto": "tcp"}]',
|
||||||
|
}
|
||||||
|
row.update(overrides)
|
||||||
|
return store.create_instance(row)
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_workspace_requires_enabled_setting():
|
||||||
|
set_setting("workspace_enabled", "0")
|
||||||
|
assert can_open_workspace(_project(), {"uid": OWNER, "role": "Member"}) is False
|
||||||
|
set_setting("workspace_enabled", "1")
|
||||||
|
assert can_open_workspace(_project(), {"uid": OWNER, "role": "Member"}) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_guest_can_never_open_workspace():
|
||||||
|
assert can_open_workspace(_project(), None) is False
|
||||||
|
assert can_open_workspace(_project(), {}) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_owner_member_cannot_open_workspace():
|
||||||
|
assert can_open_workspace(_project(), {"uid": OTHER, "role": "Member"}) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_open_any_project_workspace():
|
||||||
|
assert can_open_workspace(_project(), {"uid": OTHER, "role": "Admin"}) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_manages_own_workspace_without_admin():
|
||||||
|
instance = _instance()
|
||||||
|
assert can_manage_workspace(instance, _project(), {"uid": OWNER, "role": "Member"})
|
||||||
|
assert not can_manage_workspace(
|
||||||
|
instance, _project(), {"uid": OTHER, "role": "Member"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_or_resume_is_idempotent():
|
||||||
|
project = _project()
|
||||||
|
user = {"uid": OWNER, "username": "owner"}
|
||||||
|
first = run_async(provision.ensure(project, user))
|
||||||
|
second = run_async(provision.ensure(project, user))
|
||||||
|
assert first["uid"] == second["uid"]
|
||||||
|
assert provision.count_for_owner(OWNER) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_quota_blocks_beyond_limit():
|
||||||
|
set_setting("workspace_max_per_user", "1")
|
||||||
|
user = {"uid": OWNER, "username": "owner"}
|
||||||
|
run_async(provision.ensure(_project("p-a"), user))
|
||||||
|
with pytest.raises(WorkspaceError):
|
||||||
|
run_async(provision.ensure(_project("p-b"), user))
|
||||||
|
set_setting("workspace_max_per_user", "2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_tunnel_revives_rather_than_duplicates():
|
||||||
|
instance = _instance()
|
||||||
|
first = tunnels.create(instance, "web", 8080, OWNER)
|
||||||
|
tunnels.soft_delete(first["uid"], OWNER)
|
||||||
|
assert tunnels.count_for_instance(instance["uid"]) == 0
|
||||||
|
revived = tunnels.create(instance, "web", 8080, OWNER)
|
||||||
|
assert revived["uid"] == first["uid"]
|
||||||
|
assert tunnels.count_for_instance(instance["uid"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_tunnel_hostname_is_a_single_dns_label():
|
||||||
|
instance = _instance()
|
||||||
|
row = tunnels.create(instance, "web", 3000, OWNER)
|
||||||
|
host = row["hostname"]
|
||||||
|
suffix = "." + naming.domain()
|
||||||
|
assert host.endswith(suffix)
|
||||||
|
label = host[: -len(suffix)]
|
||||||
|
assert "." not in label
|
||||||
|
assert naming.is_valid_label(label)
|
||||||
|
|
||||||
|
|
||||||
|
def test_suspend_stops_tunnels_without_deleting_them():
|
||||||
|
instance = _instance()
|
||||||
|
tunnels.create(instance, "web", 8080, OWNER)
|
||||||
|
provision.suspend(instance, "admin-uid", "abuse")
|
||||||
|
rows = tunnels.list_for_instance(instance["uid"])
|
||||||
|
assert rows and rows[0]["status"] == "suspended"
|
||||||
|
refreshed = store.get_instance(instance["uid"])
|
||||||
|
assert refreshed["suspended_at"]
|
||||||
|
provision.unsuspend(refreshed)
|
||||||
|
assert tunnels.list_for_instance(instance["uid"])[0]["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suspended_workspace_cannot_resume():
|
||||||
|
instance = _instance()
|
||||||
|
provision.suspend(instance, "admin-uid", "abuse")
|
||||||
|
with pytest.raises(WorkspaceError):
|
||||||
|
provision.resume(store.get_instance(instance["uid"]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_flags_are_idempotent_while_open():
|
||||||
|
instance = _instance()
|
||||||
|
first = flags.raise_flag(instance, flags.KIND_EGRESS, "warn", "a", 1.0, 0.5)
|
||||||
|
second = flags.raise_flag(instance, flags.KIND_EGRESS, "warn", "b", 2.0, 0.5)
|
||||||
|
assert first["uid"] == second["uid"]
|
||||||
|
assert len(flags.list_flags(instance_uid=instance["uid"])) == 1
|
||||||
|
flags.clear_flag(instance["uid"], flags.KIND_EGRESS, "admin")
|
||||||
|
assert flags.list_flags(instance_uid=instance["uid"]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_accumulates_egress_and_requests():
|
||||||
|
instance = _instance()
|
||||||
|
activity.forget(instance["uid"])
|
||||||
|
for _ in range(3):
|
||||||
|
activity.touch(instance["uid"], egress_bytes=100)
|
||||||
|
activity.flush(instance["uid"])
|
||||||
|
row = store.get_instance(instance["uid"])
|
||||||
|
assert row["egress_bytes"] == 300
|
||||||
|
assert row["request_count"] == 3
|
||||||
|
assert row["last_active_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_quota_rule_overrides_only_its_owner():
|
||||||
|
get_table("workspace_quota_rules").insert(
|
||||||
|
{
|
||||||
|
"uid": "rule-x",
|
||||||
|
"owner_kind": "user",
|
||||||
|
"owner_id": OWNER,
|
||||||
|
"label": "power",
|
||||||
|
"max_workspaces": 7,
|
||||||
|
"deleted_at": None,
|
||||||
|
"deleted_by": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert quota.resolve(OWNER).max_workspaces == 7
|
||||||
|
assert quota.resolve(OTHER).max_workspaces == quota.resolve().max_workspaces
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_env_contract_is_complete():
|
||||||
|
from devplacepy.services.containers import api
|
||||||
|
|
||||||
|
instance = _instance()
|
||||||
|
env = api.workspace_env(instance, "https://example.test")
|
||||||
|
for key in (
|
||||||
|
"DEVPLACE_WORKSPACE",
|
||||||
|
"DEVPLACE_WORKSPACE_UID",
|
||||||
|
"DEVPLACE_TUNNEL_NAME",
|
||||||
|
"DEVPLACE_TUNNEL_DOMAIN",
|
||||||
|
"DEVPLACE_TUNNEL_MANIFEST",
|
||||||
|
"VSCODE_PROXY_URI",
|
||||||
|
"DEVPLACE_EDITOR_PORT",
|
||||||
|
"DEVPLACE_QUOTA_DISK_MB",
|
||||||
|
"DEVPLACE_RETENTION_DAYS",
|
||||||
|
):
|
||||||
|
assert key in env, key
|
||||||
|
assert all(isinstance(value, str) for value in env.values())
|
||||||
|
assert "{{port}}" in env["VSCODE_PROXY_URI"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_workspace_instance_gets_no_workspace_env():
|
||||||
|
from devplacepy.services.containers import api
|
||||||
|
|
||||||
|
env = api.workspace_env({"is_workspace": 0}, "https://example.test")
|
||||||
|
assert env == {"DEVPLACE_WORKSPACE": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def test_devii_workspace_tools_are_role_gated():
|
||||||
|
from devplacepy.services.devii.registry import CATALOG
|
||||||
|
|
||||||
|
names = {a.name for a in CATALOG.actions if a.handler == "workspace"}
|
||||||
|
admin_only = {
|
||||||
|
a.name for a in CATALOG.actions if a.handler == "workspace" and a.requires_admin
|
||||||
|
}
|
||||||
|
guest = {s["function"]["name"] for s in CATALOG.tool_schemas_for(False, False)}
|
||||||
|
member = {s["function"]["name"] for s in CATALOG.tool_schemas_for(True, False)}
|
||||||
|
admin = {s["function"]["name"] for s in CATALOG.tool_schemas_for(True, True)}
|
||||||
|
assert not (names & guest)
|
||||||
|
assert not ((names & member) & admin_only)
|
||||||
|
assert names <= admin
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_confirm_gated_tool_declares_a_confirm_param():
|
||||||
|
from devplacepy.services.devii.actions.dispatcher import CONFIRM_REQUIRED
|
||||||
|
from devplacepy.services.devii.registry import CATALOG
|
||||||
|
|
||||||
|
for action in CATALOG.actions:
|
||||||
|
if action.handler != "workspace" or action.name not in CONFIRM_REQUIRED:
|
||||||
|
continue
|
||||||
|
assert any(p.name == "confirm" for p in action.params), action.name
|
||||||
|
|
||||||
|
|
||||||
|
def test_tunnel_host_detection_never_matches_the_site():
|
||||||
|
assert naming.is_tunnel_host("abc." + naming.domain())
|
||||||
|
assert not naming.is_tunnel_host("pravda.education")
|
||||||
|
assert not naming.is_tunnel_host("")
|
||||||
221
tests/e2e/projects/workspace.py
Normal file
221
tests/e2e/projects/workspace.py
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import re
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
from playwright.sync_api import expect
|
||||||
|
|
||||||
|
from devplacepy.database import get_table, set_setting
|
||||||
|
from devplacepy.services.containers import store
|
||||||
|
from devplacepy.services.containers.workspace import flags, naming
|
||||||
|
from devplacepy.utils import make_combined_slug
|
||||||
|
from tests.conftest import BASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _workspaces_on():
|
||||||
|
previous = None
|
||||||
|
row = get_table("site_settings").find_one(key="workspace_enabled")
|
||||||
|
if row:
|
||||||
|
previous = row.get("value")
|
||||||
|
set_setting("workspace_enabled", "1")
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
set_setting("workspace_enabled", previous if previous is not None else "0")
|
||||||
|
instances = get_table("instances")
|
||||||
|
created = [r["uid"] for r in instances.find(is_workspace=1)]
|
||||||
|
for uid in created:
|
||||||
|
get_table("tunnels").delete(instance_uid=uid)
|
||||||
|
get_table("workspace_flags").delete(instance_uid=uid)
|
||||||
|
instances.delete(uid=uid)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_for(user: dict) -> dict:
|
||||||
|
return get_table("users").find_one(username=user["username"])
|
||||||
|
|
||||||
|
|
||||||
|
def _project_for(owner_uid: str, title: str = "WS Project") -> dict:
|
||||||
|
uid = str(uuid4())
|
||||||
|
slug = make_combined_slug(title, uid)
|
||||||
|
row = {
|
||||||
|
"uid": uid,
|
||||||
|
"user_uid": owner_uid,
|
||||||
|
"title": title,
|
||||||
|
"description": "workspace host project",
|
||||||
|
"slug": slug,
|
||||||
|
"stars": 0,
|
||||||
|
"created_at": "2026-01-01T00:00:00+00:00",
|
||||||
|
"deleted_at": None,
|
||||||
|
"deleted_by": None,
|
||||||
|
}
|
||||||
|
get_table("projects").insert(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace_for(project: dict, owner_uid: str, **overrides) -> dict:
|
||||||
|
payload = {
|
||||||
|
"project_uid": project["uid"],
|
||||||
|
"name": "ws-e2e",
|
||||||
|
"status": "running",
|
||||||
|
"desired_state": "running",
|
||||||
|
"is_workspace": 1,
|
||||||
|
"workspace_owner_uid": owner_uid,
|
||||||
|
"tunnel_name": naming.generate(),
|
||||||
|
"ports_json": '[{"host": 20777, "container": 8080, "proto": "tcp"}]',
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return store.create_instance(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_page_offers_creation_to_owner(alice):
|
||||||
|
page, user = alice
|
||||||
|
project = _project_for(_row_for(user)["uid"])
|
||||||
|
page.goto(
|
||||||
|
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||||
|
wait_until="domcontentloaded",
|
||||||
|
)
|
||||||
|
page.locator(".workspace-page").wait_for(state="visible")
|
||||||
|
expect(page.locator("button:has-text('Open workspace')")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_page_shows_state_quota_and_tunnel_form(alice):
|
||||||
|
page, user = alice
|
||||||
|
project = _project_for(_row_for(user)["uid"], "WS Detail")
|
||||||
|
_workspace_for(project, _row_for(user)["uid"])
|
||||||
|
page.goto(
|
||||||
|
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||||
|
wait_until="domcontentloaded",
|
||||||
|
)
|
||||||
|
page.locator(".workspace-summary").wait_for(state="visible")
|
||||||
|
expect(page.locator("[data-workspace-status]")).to_contain_text("running")
|
||||||
|
expect(page.locator(".workspace-meter").first).to_be_visible()
|
||||||
|
expect(page.locator(".workspace-tunnel-form")).to_be_visible()
|
||||||
|
expect(page.locator(".workspace-help")).to_contain_text("sudo")
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_can_add_and_remove_a_tunnel(alice):
|
||||||
|
page, user = alice
|
||||||
|
project = _project_for(_row_for(user)["uid"], "WS Tunnel")
|
||||||
|
_workspace_for(project, _row_for(user)["uid"])
|
||||||
|
url = f"{BASE_URL}/projects/{project['slug']}/workspace"
|
||||||
|
page.goto(url, wait_until="domcontentloaded")
|
||||||
|
page.locator(".workspace-tunnel-form input[name='container_port']").fill("8080")
|
||||||
|
page.locator(".workspace-tunnel-form button:has-text('Add tunnel')").click()
|
||||||
|
page.wait_for_url(url, wait_until="domcontentloaded")
|
||||||
|
tunnel = page.locator(".workspace-tunnel").first
|
||||||
|
tunnel.wait_for(state="visible")
|
||||||
|
expect(tunnel).to_contain_text(naming.domain())
|
||||||
|
|
||||||
|
page.locator(".workspace-tunnel button:has-text('Remove')").first.click()
|
||||||
|
page.locator(".dialog-confirm").wait_for(state="visible")
|
||||||
|
page.locator(".dialog-confirm").click()
|
||||||
|
page.wait_for_url(url, wait_until="domcontentloaded")
|
||||||
|
expect(page.locator(".workspace-tunnel-list")).to_contain_text("No tunnels yet")
|
||||||
|
|
||||||
|
|
||||||
|
def test_suspended_workspace_shows_reason_to_owner(alice):
|
||||||
|
page, user = alice
|
||||||
|
project = _project_for(_row_for(user)["uid"], "WS Suspended")
|
||||||
|
instance = _workspace_for(project, _row_for(user)["uid"])
|
||||||
|
store.update_instance(
|
||||||
|
instance["uid"],
|
||||||
|
{"suspended_at": "2026-01-01T00:00:00+00:00", "flag_reason": "sustained cpu"},
|
||||||
|
)
|
||||||
|
page.goto(
|
||||||
|
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||||
|
wait_until="domcontentloaded",
|
||||||
|
)
|
||||||
|
banner = page.locator(".workspace-suspended")
|
||||||
|
banner.wait_for(state="visible")
|
||||||
|
expect(banner).to_contain_text("sustained cpu")
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_flag_is_visible_to_the_owner(alice):
|
||||||
|
page, user = alice
|
||||||
|
project = _project_for(_row_for(user)["uid"], "WS Flagged")
|
||||||
|
instance = _workspace_for(project, _row_for(user)["uid"])
|
||||||
|
flags.raise_flag(
|
||||||
|
store.get_instance(instance["uid"]),
|
||||||
|
flags.KIND_EGRESS,
|
||||||
|
"warn",
|
||||||
|
"egress above the hourly ceiling",
|
||||||
|
2048.0,
|
||||||
|
1024.0,
|
||||||
|
)
|
||||||
|
page.goto(
|
||||||
|
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||||
|
wait_until="domcontentloaded",
|
||||||
|
)
|
||||||
|
flag = page.locator(".workspace-flag").first
|
||||||
|
flag.wait_for(state="visible")
|
||||||
|
expect(flag).to_contain_text("egress above the hourly ceiling")
|
||||||
|
|
||||||
|
|
||||||
|
def test_guest_cannot_reach_the_workspace_page(page):
|
||||||
|
project = _project_for(str(uuid4()), "WS Guest")
|
||||||
|
response = requests.get(
|
||||||
|
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||||
|
allow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code in (303, 401, 404)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_owner_member_is_refused(bob):
|
||||||
|
page, user = bob
|
||||||
|
project = _project_for(str(uuid4()), "WS Foreign")
|
||||||
|
page.goto(
|
||||||
|
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||||
|
wait_until="domcontentloaded",
|
||||||
|
)
|
||||||
|
assert not page.locator(".workspace-summary").count()
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_console_lists_and_suspends_a_workspace(alice):
|
||||||
|
page, user = alice
|
||||||
|
project = _project_for(_row_for(user)["uid"], "WS Admin")
|
||||||
|
instance = _workspace_for(project, _row_for(user)["uid"])
|
||||||
|
page.goto(f"{BASE_URL}/admin/workspaces", wait_until="domcontentloaded")
|
||||||
|
row = page.locator(f"tr[data-workspace-uid='{instance['uid']}']")
|
||||||
|
row.wait_for(state="visible")
|
||||||
|
expect(row).to_contain_text("ws-e2e")
|
||||||
|
|
||||||
|
row.locator("input[name='reason']").fill("policy breach")
|
||||||
|
row.locator("button:has-text('Suspend')").click()
|
||||||
|
page.wait_for_url(f"{BASE_URL}/admin/workspaces", wait_until="domcontentloaded")
|
||||||
|
|
||||||
|
refreshed = store.get_instance(instance["uid"])
|
||||||
|
assert refreshed["suspended_at"]
|
||||||
|
row = page.locator(f"tr[data-workspace-uid='{instance['uid']}']")
|
||||||
|
expect(row).to_contain_text("suspended")
|
||||||
|
|
||||||
|
row.locator("button:has-text('Unsuspend')").click()
|
||||||
|
page.wait_for_url(f"{BASE_URL}/admin/workspaces", wait_until="domcontentloaded")
|
||||||
|
assert not store.get_instance(instance["uid"])["suspended_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_console_raises_and_resolves_a_flag(alice):
|
||||||
|
page, user = alice
|
||||||
|
project = _project_for(_row_for(user)["uid"], "WS Flag Admin")
|
||||||
|
instance = _workspace_for(project, _row_for(user)["uid"])
|
||||||
|
page.goto(f"{BASE_URL}/admin/workspaces", wait_until="domcontentloaded")
|
||||||
|
row = page.locator(f"tr[data-workspace-uid='{instance['uid']}']")
|
||||||
|
row.wait_for(state="visible")
|
||||||
|
row.locator("input[name='detail']").fill("manual review")
|
||||||
|
row.locator("button:has-text('Flag')").click()
|
||||||
|
page.wait_for_url(f"{BASE_URL}/admin/workspaces", wait_until="domcontentloaded")
|
||||||
|
|
||||||
|
assert flags.list_flags(instance_uid=instance["uid"])
|
||||||
|
page.locator("button:has-text('Resolve')").first.click()
|
||||||
|
page.wait_for_url(f"{BASE_URL}/admin/workspaces", wait_until="domcontentloaded")
|
||||||
|
assert not flags.list_flags(instance_uid=instance["uid"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspaces_sidebar_link_is_present_for_admin(alice):
|
||||||
|
page, user = alice
|
||||||
|
page.goto(f"{BASE_URL}/admin/workspaces", wait_until="domcontentloaded")
|
||||||
|
link = page.locator(".sidebar-link:has-text('Workspaces')")
|
||||||
|
link.wait_for(state="visible")
|
||||||
|
expect(link).to_have_class(re.compile("active"))
|
||||||
@ -67,3 +67,29 @@ def test_primary_admin_skips_deactivated_and_deleted_founders(local_db):
|
|||||||
for uid, _, _, _ in seeded:
|
for uid, _, _, _ in seeded:
|
||||||
users.delete(uid=uid)
|
users.delete(uid=uid)
|
||||||
invalidate_admins_cache()
|
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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user