Files
devplacepy/devplacepy/services/containers/workspace/tunnels.py
T
retoor 192df12b1d Make dev workspaces serve a working browser IDE end to end
The workspace feature shipped its routes, agent tools and docs, but the
editor was never reachable: the project page had no entry point, the ppy
image had no code-server binary, no certificate was ever requested for a
tunnel, and both nginx and the proxy dropped what the editor needs.

- Add a VS Code button to the project action row and a Workspace item to
  the overflow menu, gated by can_open_workspace plus a running instance
  (viewer_can_workspace and workspace_editor_url on ProjectDetailOut).
- Install a pinned code-server in ppy.Dockerfile before USER pravda and
  assert it in the build smoke test, so an image that cannot run the
  editor no longer builds green.
- Run the editor with --auth password and a per workspace 8 character
  pronounceable secret, minted once at the ensure_editor_password choke
  point and injected as PASSWORD. Keep it off WorkspaceViewOut, which the
  admin listing shares.
- Publish the editor tunnel when a workspace is created and issue its
  certificate from a new WorkspaceService phase against the molohttp admin
  API, then notify the owner with the live URL and the password. Only
  pending rows are retried, so a broken host cannot burn the ACME failure
  rate limit. Renewal stays molohttp's job.
- Forward the original Host on proxied requests and the client cookie on
  proxied websockets, so code-server scopes its session cookie to the
  public hostname and authenticates the workbench socket.
- Recreate a container stuck in the created state instead of retrying
  docker start forever against an image it can no longer run.
- Return a JSON string from WorkspaceController.dispatch; raw dicts landed
  in a tool message and aborted the turn at the model endpoint.
- Let the nginx catch-all carry websocket upgrades, keeping upstream
  keepalive, so tunnelled apps and the editor both connect.
2026-08-07 13:46:40 +02:00

166 lines
4.6 KiB
Python

# 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 awaiting_certificate() -> list[dict]:
return list(
_table().find(
status=STATUS_PENDING,
desired_state="present",
deleted_at=None,
order_by=["created_at"],
)
)
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},
)