|
# 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",
|
|
},
|
|
)
|
|
instance = store.get_instance(instance["uid"])
|
|
api.ensure_editor_password(instance)
|
|
publish_editor_tunnel(instance, owner_uid)
|
|
return store.get_instance(instance["uid"])
|
|
|
|
|
|
def publish_editor_tunnel(instance: dict, owner_uid: str) -> dict | None:
|
|
port = int(instance.get("editor_port") or api.EDITOR_DEFAULT_PORT)
|
|
if not port or not instance.get("tunnel_name"):
|
|
return None
|
|
return tunnels.create(instance, "Editor", port, owner_uid)
|
|
|
|
|
|
def is_editor_tunnel(instance: dict, tunnel: dict) -> bool:
|
|
editor_port = int(instance.get("editor_port") or api.EDITOR_DEFAULT_PORT)
|
|
return int(tunnel.get("container_port") or 0) == editor_port
|
|
|
|
|
|
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"]),
|
|
}
|