# 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"]),
}