|
# 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 (
|
|
certs,
|
|
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}")
|
|
try:
|
|
await self._issue_tunnel_certificates()
|
|
except Exception as error:
|
|
self.log(f"_issue_tunnel_certificates failed: {error}")
|
|
|
|
async def _issue_tunnel_certificates(self) -> None:
|
|
if not certs.configured():
|
|
return
|
|
for row in tunnels.awaiting_certificate():
|
|
await certs.certify(row, self.log)
|
|
|
|
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())],
|
|
},
|
|
}
|