2026-06-09 06:41:27 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import json
|
|
|
|
|
import re
|
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
|
|
|
import secrets
|
2026-06-09 06:41:27 +02:00
|
|
|
import socket
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-06-14 09:48:10 +02:00
|
|
|
from devplacepy import config, project_files, stealth
|
2026-06-09 06:41:27 +02:00
|
|
|
from devplacepy.services.containers import store
|
2026-06-11 00:17:25 +02:00
|
|
|
from devplacepy.services.containers.backend.base import (
|
|
|
|
|
WORKSPACE_MOUNT,
|
2026-08-07 10:53:08 +02:00
|
|
|
WORKSPACE_STATE_MOUNT,
|
2026-06-11 00:17:25 +02:00
|
|
|
Mount,
|
|
|
|
|
PortMapping,
|
|
|
|
|
RunSpec,
|
|
|
|
|
)
|
2026-06-09 16:06:02 +02:00
|
|
|
from devplacepy.services.containers.runtime import get_backend
|
2026-06-09 18:48:08 +02:00
|
|
|
from devplacepy.services.devii.tasks.schedule import (
|
|
|
|
|
Schedule,
|
|
|
|
|
now_utc,
|
|
|
|
|
to_iso,
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
|
|
|
|
|
IMAGE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,62}$")
|
|
|
|
|
INGRESS_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
|
|
|
|
MEM_RE = re.compile(r"^\d+(\.\d+)?[bkmgBKMG]?$")
|
|
|
|
|
CPU_RE = re.compile(r"^\d+(\.\d+)?$")
|
|
|
|
|
ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
|
|
|
INSTANCE_LABEL = "devplace.instance"
|
|
|
|
|
PROJECT_LABEL = "devplace.project"
|
|
|
|
|
HOST_PORT_MIN = 20001
|
|
|
|
|
HOST_PORT_MAX = 65535
|
2026-06-14 16:46:36 +02:00
|
|
|
BOOT_LANGUAGES = ("none", "python", "bash")
|
|
|
|
|
BOOT_SCRIPT_FILES = {"python": ".devplace_boot.py", "bash": ".devplace_boot.sh"}
|
|
|
|
|
BOOT_SCRIPT_RUNNERS = {"python": "python", "bash": "bash"}
|
|
|
|
|
MAX_BOOT_SCRIPT_CHARS = 100_000
|
2026-06-09 06:41:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class ContainerError(ValueError):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_limits(cpu_limit: str, mem_limit: str) -> None:
|
|
|
|
|
if cpu_limit and not CPU_RE.match(str(cpu_limit)):
|
|
|
|
|
raise ContainerError("cpu limit must be a number, e.g. 1 or 1.5")
|
|
|
|
|
if mem_limit and not MEM_RE.match(str(mem_limit)):
|
|
|
|
|
raise ContainerError("memory limit must look like 512m, 1g, or a byte count")
|
|
|
|
|
|
|
|
|
|
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
def _actor_uid(actor) -> str:
|
|
|
|
|
return actor[1] if actor and actor[0] == "user" else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_run_as(run_as_uid, actor_uid: str = "") -> str:
|
2026-06-14 16:46:36 +02:00
|
|
|
uid = str(run_as_uid or "").strip()
|
|
|
|
|
if not uid:
|
|
|
|
|
return ""
|
|
|
|
|
from devplacepy import database
|
|
|
|
|
|
|
|
|
|
user = database.get_users_by_uids([uid]).get(uid)
|
|
|
|
|
if not user:
|
|
|
|
|
raise ContainerError(f"run-as user not found: {uid}")
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
if actor_uid and actor_uid != uid:
|
|
|
|
|
if not database.consent_granted("user", uid, "container_credentials"):
|
|
|
|
|
raise ContainerError(
|
|
|
|
|
f"{user['username']} has not consented to their DevPlace credentials "
|
|
|
|
|
f"being shared with software run by someone else; they grant it under "
|
|
|
|
|
f"Privacy on their profile"
|
|
|
|
|
)
|
2026-06-14 16:46:36 +02:00
|
|
|
return uid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_boot(boot_language, boot_script) -> tuple:
|
|
|
|
|
language = str(boot_language or "none").strip().lower() or "none"
|
|
|
|
|
if language not in BOOT_LANGUAGES:
|
|
|
|
|
raise ContainerError(
|
|
|
|
|
f"boot language must be one of {', '.join(BOOT_LANGUAGES)}"
|
|
|
|
|
)
|
|
|
|
|
script = str(boot_script or "")
|
|
|
|
|
if language == "none":
|
|
|
|
|
script = ""
|
|
|
|
|
if len(script) > MAX_BOOT_SCRIPT_CHARS:
|
|
|
|
|
raise ContainerError(
|
|
|
|
|
f"boot script exceeds the {MAX_BOOT_SCRIPT_CHARS}-character limit"
|
|
|
|
|
)
|
|
|
|
|
if language != "none" and not script.strip():
|
|
|
|
|
raise ContainerError("boot script is required when a boot language is set")
|
|
|
|
|
return language, script
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 06:41:27 +02:00
|
|
|
def parse_ports(value) -> list:
|
|
|
|
|
ports = []
|
|
|
|
|
if not value:
|
|
|
|
|
return ports
|
2026-06-09 18:48:08 +02:00
|
|
|
items = (
|
|
|
|
|
value if isinstance(value, list) else str(value).replace(",", "\n").splitlines()
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
for item in items:
|
|
|
|
|
item = str(item).strip()
|
|
|
|
|
if not item:
|
|
|
|
|
continue
|
|
|
|
|
proto = "tcp"
|
|
|
|
|
if "/" in item:
|
|
|
|
|
item, proto = item.split("/", 1)
|
|
|
|
|
if ":" in item:
|
|
|
|
|
host, container = item.split(":", 1)
|
|
|
|
|
else:
|
|
|
|
|
host, container = "0", item
|
|
|
|
|
if not host.isdigit() or not container.isdigit():
|
2026-06-09 18:48:08 +02:00
|
|
|
raise ContainerError(
|
|
|
|
|
f"port '{item}' must be numeric host:container or a bare container port"
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
ports.append(PortMapping(int(host), int(container), proto.strip() or "tcp"))
|
|
|
|
|
return ports
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def used_host_ports() -> set:
|
|
|
|
|
ports = set()
|
|
|
|
|
for instance in store.all_instances():
|
|
|
|
|
for mapping in json.loads(instance.get("ports_json") or "[]"):
|
|
|
|
|
host = int(mapping.get("host") or 0)
|
|
|
|
|
if host:
|
|
|
|
|
ports.add(host)
|
|
|
|
|
return ports
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _host_port_free(port: int) -> bool:
|
|
|
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
|
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
|
|
|
try:
|
|
|
|
|
sock.bind(("0.0.0.0", port))
|
|
|
|
|
return True
|
|
|
|
|
except OSError:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def allocate_host_port(reserved: set) -> int:
|
|
|
|
|
for port in range(HOST_PORT_MIN, HOST_PORT_MAX + 1):
|
|
|
|
|
if port in reserved:
|
|
|
|
|
continue
|
|
|
|
|
if _host_port_free(port):
|
|
|
|
|
return port
|
2026-06-09 18:48:08 +02:00
|
|
|
raise ContainerError(
|
|
|
|
|
f"no free host port available in range {HOST_PORT_MIN}-{HOST_PORT_MAX}"
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def assign_host_ports(port_list: list) -> list:
|
|
|
|
|
reserved = used_host_ports()
|
|
|
|
|
assigned = []
|
|
|
|
|
for mapping in port_list:
|
|
|
|
|
host = mapping.host
|
|
|
|
|
if not host:
|
|
|
|
|
host = allocate_host_port(reserved)
|
|
|
|
|
elif host in reserved:
|
2026-06-09 18:48:08 +02:00
|
|
|
raise ContainerError(
|
|
|
|
|
f"host port {host} is already published by another instance"
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
reserved.add(host)
|
|
|
|
|
assigned.append(PortMapping(host, mapping.container, mapping.proto))
|
|
|
|
|
return assigned
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_env(value) -> dict:
|
|
|
|
|
env = {}
|
|
|
|
|
if not value:
|
|
|
|
|
return env
|
|
|
|
|
if isinstance(value, dict):
|
|
|
|
|
items = value.items()
|
|
|
|
|
else:
|
|
|
|
|
items = (line.split("=", 1) for line in str(value).splitlines() if "=" in line)
|
|
|
|
|
for key, val in items:
|
|
|
|
|
key = str(key).strip()
|
|
|
|
|
if not ENV_KEY_RE.match(key):
|
|
|
|
|
raise ContainerError(f"invalid environment variable name: {key}")
|
|
|
|
|
env[key] = str(val)
|
|
|
|
|
return env
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- instances ----------------
|
|
|
|
|
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-06-09 06:41:27 +02:00
|
|
|
def validate_ingress(slug: str, port, port_list) -> tuple:
|
|
|
|
|
slug = (slug or "").strip().lower()
|
|
|
|
|
if not slug:
|
|
|
|
|
return "", 0
|
|
|
|
|
if not INGRESS_SLUG_RE.match(slug):
|
2026-06-09 18:48:08 +02:00
|
|
|
raise ContainerError(
|
|
|
|
|
"ingress slug must be lowercase letters, digits, or '-' (max 63 chars)"
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
for other in store.all_instances():
|
|
|
|
|
if other.get("ingress_slug") == slug:
|
|
|
|
|
raise ContainerError(f"ingress slug '{slug}' is already in use")
|
|
|
|
|
ingress_port = int(port) if port else 0
|
|
|
|
|
container_ports = {p.container for p in port_list}
|
|
|
|
|
if ingress_port and ingress_port not in container_ports:
|
2026-06-09 18:48:08 +02:00
|
|
|
raise ContainerError(
|
|
|
|
|
f"ingress_port {ingress_port} must be one of the container ports you mapped"
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
if not ingress_port and len(container_ports) != 1:
|
2026-06-09 18:48:08 +02:00
|
|
|
raise ContainerError(
|
|
|
|
|
"set ingress_port to choose which mapped container port to publish"
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
return slug, ingress_port
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 18:48:08 +02:00
|
|
|
async def create_instance(
|
|
|
|
|
project: dict,
|
|
|
|
|
*,
|
|
|
|
|
name: str,
|
|
|
|
|
boot_command: str = "",
|
2026-06-14 16:46:36 +02:00
|
|
|
boot_language: str = "none",
|
|
|
|
|
boot_script: str = "",
|
|
|
|
|
run_as_uid: str = "",
|
|
|
|
|
start_on_boot: bool = False,
|
2026-06-09 18:48:08 +02:00
|
|
|
env="",
|
|
|
|
|
cpu_limit: str = "",
|
|
|
|
|
mem_limit: str = "",
|
|
|
|
|
ports="",
|
|
|
|
|
volumes="",
|
|
|
|
|
restart_policy: str = "never",
|
|
|
|
|
autostart: bool = True,
|
|
|
|
|
ingress_slug: str = "",
|
|
|
|
|
ingress_port=None,
|
|
|
|
|
actor=("system", "system"),
|
|
|
|
|
) -> dict:
|
2026-06-09 16:06:02 +02:00
|
|
|
if not await get_backend().image_exists(config.CONTAINER_IMAGE):
|
2026-06-09 18:48:08 +02:00
|
|
|
raise ContainerError(
|
|
|
|
|
f"the '{config.CONTAINER_IMAGE}' image is not built - run 'make ppy'"
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
if restart_policy not in store.RESTART_POLICIES:
|
2026-06-09 18:48:08 +02:00
|
|
|
raise ContainerError(
|
|
|
|
|
f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}"
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
_validate_limits(cpu_limit, mem_limit)
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
run_as_uid = validate_run_as(run_as_uid, _actor_uid(actor))
|
2026-06-14 16:46:36 +02:00
|
|
|
boot_language, boot_script = validate_boot(boot_language, boot_script)
|
2026-06-09 06:41:27 +02:00
|
|
|
port_list = assign_host_ports(parse_ports(ports))
|
|
|
|
|
env_map = parse_env(env)
|
|
|
|
|
name = (name or "").strip()
|
|
|
|
|
if not name:
|
|
|
|
|
raise ContainerError("instance name is required")
|
|
|
|
|
ingress_slug, ingress_port = validate_ingress(ingress_slug, ingress_port, port_list)
|
|
|
|
|
|
|
|
|
|
workspace = Path(config.CONTAINER_WORKSPACES_DIR) / project["uid"]
|
2026-06-09 18:48:08 +02:00
|
|
|
await asyncio.to_thread(
|
|
|
|
|
project_files.export_to_dir, project["uid"], "", str(workspace)
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
|
|
|
|
|
row = {
|
2026-06-09 16:06:02 +02:00
|
|
|
"project_uid": project["uid"],
|
|
|
|
|
"created_by": actor[1] if actor and actor[0] == "user" else "",
|
|
|
|
|
"owner_uid": project.get("user_uid", ""),
|
2026-06-14 16:46:36 +02:00
|
|
|
"run_as_uid": run_as_uid,
|
2026-06-09 18:48:08 +02:00
|
|
|
"name": name,
|
|
|
|
|
"boot_command": boot_command or "",
|
2026-06-14 16:46:36 +02:00
|
|
|
"boot_language": boot_language,
|
|
|
|
|
"boot_script": boot_script,
|
|
|
|
|
"start_on_boot": 1 if start_on_boot else 0,
|
2026-06-09 06:41:27 +02:00
|
|
|
"env_json": json.dumps(env_map),
|
2026-06-09 18:48:08 +02:00
|
|
|
"cpu_limit": str(cpu_limit or ""),
|
|
|
|
|
"mem_limit": str(mem_limit or ""),
|
|
|
|
|
"ports_json": json.dumps(
|
|
|
|
|
[
|
|
|
|
|
{"host": p.host, "container": p.container, "proto": p.proto}
|
|
|
|
|
for p in port_list
|
|
|
|
|
]
|
|
|
|
|
),
|
|
|
|
|
"volumes_json": volumes
|
|
|
|
|
if isinstance(volumes, str)
|
|
|
|
|
else json.dumps(volumes or []),
|
2026-06-09 06:41:27 +02:00
|
|
|
"restart_policy": restart_policy,
|
2026-06-09 18:48:08 +02:00
|
|
|
"ingress_slug": ingress_slug,
|
|
|
|
|
"ingress_port": ingress_port,
|
2026-06-09 06:41:27 +02:00
|
|
|
"desired_state": store.DESIRED_RUNNING if autostart else store.DESIRED_STOPPED,
|
|
|
|
|
"status": store.ST_CREATED,
|
|
|
|
|
"workspace_dir": str(workspace),
|
|
|
|
|
}
|
|
|
|
|
instance = store.create_instance(row)
|
2026-06-09 18:48:08 +02:00
|
|
|
store.record_event(
|
|
|
|
|
instance, "created", actor[0], actor[1], {"image": config.CONTAINER_IMAGE}
|
|
|
|
|
)
|
2026-06-16 05:32:19 +02:00
|
|
|
if actor and actor[0] == "user":
|
|
|
|
|
from devplacepy.utils import track_action
|
|
|
|
|
|
|
|
|
|
track_action(actor[1], "container")
|
2026-06-09 06:41:27 +02:00
|
|
|
return instance
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 18:48:08 +02:00
|
|
|
def set_desired_state(
|
|
|
|
|
instance: dict, desired: str, *, actor=("system", "system")
|
|
|
|
|
) -> dict:
|
|
|
|
|
if desired not in (
|
|
|
|
|
store.DESIRED_RUNNING,
|
|
|
|
|
store.DESIRED_STOPPED,
|
|
|
|
|
store.DESIRED_PAUSED,
|
|
|
|
|
):
|
2026-06-09 06:41:27 +02:00
|
|
|
raise ContainerError("desired state must be running, stopped, or paused")
|
|
|
|
|
store.update_instance(instance["uid"], {"desired_state": desired})
|
|
|
|
|
store.record_event(instance, f"desire_{desired}", actor[0], actor[1])
|
|
|
|
|
return store.get_instance(instance["uid"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def request_restart(instance: dict, *, actor=("system", "system")) -> dict:
|
2026-06-09 18:48:08 +02:00
|
|
|
store.update_instance(
|
|
|
|
|
instance["uid"],
|
|
|
|
|
{"desired_state": store.DESIRED_RUNNING, "status": store.ST_RESTARTING},
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
store.record_event(instance, "restart", actor[0], actor[1])
|
|
|
|
|
return store.get_instance(instance["uid"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mark_for_removal(instance: dict, *, actor=("system", "system")) -> None:
|
2026-06-09 18:48:08 +02:00
|
|
|
store.update_instance(
|
|
|
|
|
instance["uid"],
|
|
|
|
|
{"desired_state": store.DESIRED_STOPPED, "status": store.ST_REMOVING},
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
store.record_event(instance, "remove", actor[0], actor[1])
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 16:46:36 +02:00
|
|
|
def update_instance_config(
|
|
|
|
|
instance: dict,
|
|
|
|
|
*,
|
|
|
|
|
run_as_uid=None,
|
|
|
|
|
boot_language=None,
|
|
|
|
|
boot_script=None,
|
|
|
|
|
boot_command=None,
|
|
|
|
|
restart_policy=None,
|
|
|
|
|
start_on_boot=None,
|
|
|
|
|
cpu_limit=None,
|
|
|
|
|
mem_limit=None,
|
|
|
|
|
actor=("system", "system"),
|
|
|
|
|
) -> dict:
|
|
|
|
|
changes: dict = {}
|
|
|
|
|
if run_as_uid is not None:
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
changes["run_as_uid"] = validate_run_as(run_as_uid, _actor_uid(actor))
|
2026-06-14 16:46:36 +02:00
|
|
|
if boot_language is not None or boot_script is not None:
|
|
|
|
|
language = (
|
|
|
|
|
boot_language
|
|
|
|
|
if boot_language is not None
|
|
|
|
|
else instance.get("boot_language", "none")
|
|
|
|
|
)
|
|
|
|
|
script = (
|
|
|
|
|
boot_script if boot_script is not None else instance.get("boot_script", "")
|
|
|
|
|
)
|
|
|
|
|
language, script = validate_boot(language, script)
|
|
|
|
|
changes["boot_language"] = language
|
|
|
|
|
changes["boot_script"] = script
|
|
|
|
|
if boot_command is not None:
|
|
|
|
|
changes["boot_command"] = str(boot_command or "")[:500]
|
|
|
|
|
if restart_policy is not None:
|
|
|
|
|
if restart_policy not in store.RESTART_POLICIES:
|
|
|
|
|
raise ContainerError(
|
|
|
|
|
f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}"
|
|
|
|
|
)
|
|
|
|
|
changes["restart_policy"] = restart_policy
|
|
|
|
|
if start_on_boot is not None:
|
|
|
|
|
changes["start_on_boot"] = 1 if start_on_boot else 0
|
|
|
|
|
if cpu_limit is not None or mem_limit is not None:
|
|
|
|
|
cpu = cpu_limit if cpu_limit is not None else instance.get("cpu_limit", "")
|
|
|
|
|
mem = mem_limit if mem_limit is not None else instance.get("mem_limit", "")
|
|
|
|
|
_validate_limits(cpu, mem)
|
|
|
|
|
changes["cpu_limit"] = str(cpu or "")
|
|
|
|
|
changes["mem_limit"] = str(mem or "")
|
|
|
|
|
if not changes:
|
|
|
|
|
return store.get_instance(instance["uid"])
|
|
|
|
|
store.update_instance(instance["uid"], changes)
|
|
|
|
|
store.record_event(
|
|
|
|
|
instance, "configure", actor[0], actor[1], {"fields": sorted(changes)}
|
|
|
|
|
)
|
|
|
|
|
return store.get_instance(instance["uid"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_start_on_boot(
|
|
|
|
|
instance: dict, enabled: bool, *, actor=("system", "system")
|
|
|
|
|
) -> dict:
|
|
|
|
|
store.update_instance(instance["uid"], {"start_on_boot": 1 if enabled else 0})
|
|
|
|
|
store.record_event(
|
|
|
|
|
instance, "start_on_boot", actor[0], actor[1], {"enabled": bool(enabled)}
|
|
|
|
|
)
|
|
|
|
|
return store.get_instance(instance["uid"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def materialize_boot_script(instance: dict) -> None:
|
|
|
|
|
language = (instance.get("boot_language") or "none").strip().lower()
|
|
|
|
|
workspace = instance.get("workspace_dir")
|
|
|
|
|
if not workspace:
|
|
|
|
|
return
|
|
|
|
|
for filename in BOOT_SCRIPT_FILES.values():
|
|
|
|
|
stale = Path(workspace) / filename
|
|
|
|
|
if stale.is_file():
|
|
|
|
|
try:
|
|
|
|
|
stale.unlink()
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
if language not in BOOT_SCRIPT_FILES:
|
|
|
|
|
return
|
|
|
|
|
script = instance.get("boot_script") or ""
|
|
|
|
|
if not script.strip():
|
|
|
|
|
return
|
|
|
|
|
target = Path(workspace) / BOOT_SCRIPT_FILES[language]
|
|
|
|
|
try:
|
|
|
|
|
Path(workspace).mkdir(parents=True, exist_ok=True)
|
|
|
|
|
target.write_text(script, encoding="utf-8")
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 16:06:02 +02:00
|
|
|
def pravda_env(instance: dict) -> dict:
|
|
|
|
|
from devplacepy import database, seo
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-06-09 16:06:02 +02:00
|
|
|
base_url = seo.public_base_url()
|
|
|
|
|
api_key = ""
|
2026-06-14 16:46:36 +02:00
|
|
|
user_uid = instance.get("owner_uid") or ""
|
|
|
|
|
for uid in (
|
|
|
|
|
instance.get("run_as_uid"),
|
|
|
|
|
instance.get("created_by"),
|
|
|
|
|
instance.get("owner_uid"),
|
|
|
|
|
):
|
2026-06-09 16:06:02 +02:00
|
|
|
if not uid:
|
|
|
|
|
continue
|
|
|
|
|
user = database.get_users_by_uids([uid]).get(uid)
|
|
|
|
|
if user and user.get("api_key"):
|
|
|
|
|
api_key = user["api_key"]
|
|
|
|
|
break
|
|
|
|
|
slug = instance.get("ingress_slug") or ""
|
|
|
|
|
ingress_url = (f"{base_url}/p/{slug}" if base_url else f"/p/{slug}") if slug else ""
|
2026-08-07 10:53:08 +02:00
|
|
|
env = {
|
2026-07-06 05:57:47 +02:00
|
|
|
"DEVPLACE_BASE_URL": base_url,
|
|
|
|
|
"DEVPLACE_OPENAI_URL": f"{base_url}/openai/v1" if base_url else "",
|
|
|
|
|
"DEVPLACE_API_KEY": api_key,
|
|
|
|
|
"DEVPLACE_USER_UID": instance.get("run_as_uid") or user_uid,
|
|
|
|
|
"DEVPLACE_CONTAINER_NAME": instance.get("name") or "",
|
|
|
|
|
"DEVPLACE_CONTAINER_UID": instance.get("uid") or "",
|
|
|
|
|
"DEVPLACE_INGRESS_URL": ingress_url,
|
2026-06-09 16:06:02 +02:00
|
|
|
}
|
2026-08-07 10:53:08 +02:00
|
|
|
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
|
2026-08-10 00:23:20 +02:00
|
|
|
from devplacepy.services.containers.workspace import editor, naming, quota
|
2026-08-07 10:53:08 +02:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-08-10 00:23:20 +02:00
|
|
|
profile = editor.resolve(owner_uid, instance)
|
2026-08-07 10:53:08 +02:00
|
|
|
env = {
|
|
|
|
|
"DEVPLACE_WORKSPACE": "1",
|
|
|
|
|
"DEVPLACE_WORKSPACE_UID": instance.get("uid") or "",
|
2026-08-10 00:23:20 +02:00
|
|
|
"DEVPLACE_CONTAINER_BOOT": instance.get("boot_marker") or "",
|
|
|
|
|
**editor.env_for(profile),
|
2026-08-07 10:53:08 +02:00
|
|
|
"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),
|
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
|
|
|
"PASSWORD": instance.get("editor_password") or "",
|
2026-08-07 10:53:08 +02:00
|
|
|
"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
|
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
|
|
|
EDITOR_PASSWORD_LENGTH = 8
|
|
|
|
|
EDITOR_PASSWORD_CONSONANTS = "bdfgkmnprstvz"
|
|
|
|
|
EDITOR_PASSWORD_VOWELS = "aeiou"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_editor_password() -> str:
|
|
|
|
|
pairs = EDITOR_PASSWORD_LENGTH // 2
|
|
|
|
|
return "".join(
|
|
|
|
|
secrets.choice(EDITOR_PASSWORD_CONSONANTS)
|
|
|
|
|
+ secrets.choice(EDITOR_PASSWORD_VOWELS)
|
|
|
|
|
for _ in range(pairs)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ensure_editor_password(instance: dict) -> str:
|
|
|
|
|
password = (instance.get("editor_password") or "").strip()
|
|
|
|
|
if password:
|
|
|
|
|
return password
|
|
|
|
|
password = generate_editor_password()
|
|
|
|
|
store.update_instance(instance["uid"], {"editor_password": password})
|
|
|
|
|
instance["editor_password"] = password
|
|
|
|
|
return password
|
2026-08-07 10:53:08 +02:00
|
|
|
|
|
|
|
|
|
2026-08-10 00:23:20 +02:00
|
|
|
def stamp_boot_marker(instance: dict) -> str:
|
|
|
|
|
from devplacepy.utils import generate_uid
|
|
|
|
|
|
|
|
|
|
marker = generate_uid()
|
|
|
|
|
store.update_instance(instance["uid"], {"boot_marker": marker})
|
|
|
|
|
instance["boot_marker"] = marker
|
|
|
|
|
return marker
|
2026-06-09 16:06:02 +02:00
|
|
|
|
|
|
|
|
|
2026-06-09 06:41:27 +02:00
|
|
|
def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
|
2026-08-10 00:23:20 +02:00
|
|
|
from devplacepy.services.containers.workspace import editor
|
|
|
|
|
|
|
|
|
|
profile = None
|
|
|
|
|
cpu_limit = instance.get("cpu_limit", "")
|
|
|
|
|
mem_limit = instance.get("mem_limit", "")
|
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
|
|
|
if instance.get("is_workspace"):
|
|
|
|
|
ensure_editor_password(instance)
|
2026-08-10 00:23:20 +02:00
|
|
|
stamp_boot_marker(instance)
|
|
|
|
|
profile = editor.resolve(instance.get("workspace_owner_uid", ""), instance)
|
|
|
|
|
editor.seed_state(instance, profile)
|
|
|
|
|
cpu_limit = profile.cpu_limit() or cpu_limit
|
|
|
|
|
mem_limit = profile.mem_limit() or mem_limit
|
2026-06-09 16:06:02 +02:00
|
|
|
env = {**json.loads(instance.get("env_json") or "{}"), **pravda_env(instance)}
|
2026-06-09 18:48:08 +02:00
|
|
|
ports = [
|
|
|
|
|
PortMapping(p["host"], p["container"], p.get("proto", "tcp"))
|
|
|
|
|
for p in json.loads(instance.get("ports_json") or "[]")
|
|
|
|
|
]
|
2026-06-11 00:17:25 +02:00
|
|
|
mounts = [Mount(instance["workspace_dir"], WORKSPACE_MOUNT, "rw")]
|
2026-08-07 10:53:08 +02:00
|
|
|
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"))
|
2026-06-09 06:41:27 +02:00
|
|
|
for extra in json.loads(instance.get("volumes_json") or "[]"):
|
|
|
|
|
if isinstance(extra, dict) and extra.get("host") and extra.get("container"):
|
2026-06-09 18:48:08 +02:00
|
|
|
mounts.append(
|
|
|
|
|
Mount(extra["host"], extra["container"], extra.get("mode", "rw"))
|
|
|
|
|
)
|
2026-06-14 16:46:36 +02:00
|
|
|
language = (instance.get("boot_language") or "none").strip().lower()
|
2026-06-09 06:41:27 +02:00
|
|
|
boot = (instance.get("boot_command") or "").strip()
|
2026-08-10 00:23:20 +02:00
|
|
|
if profile and int(instance.get("editor_port") or 0):
|
Route container proxies through the leg that is actually reachable
The workspace editor hung for 60s and then 504'd. Three independent faults were
stacked behind that one symptom.
Reachability: editor_target delegated to proxy_target, which returns
CONTAINER_PROXY_HOST plus the published host port and never falls back to the
container. From inside the app container that address crosses docker0 into the
host INPUT chain, whose policy is DROP with an allow-list that does not include
the published port range, so the packet was dropped and the request hung rather
than being refused. Measured from the app container: container_ip:8443 answers
302, gateway:20006 is dropped. One shared reachable_target now prefers the
direct container leg and falls back to the published port, and editor_target
uses tunnel_target as services/containers/CLAUDE.md already required. The same
defect affected /p/{slug} ingress and every tunnel, since all three resolved
through proxy_target.
The recorded measurement that motivated the old order (container_ip times out,
gateway connects) no longer holds: make docker-attach puts the app on the
instances' bridge network, which is what makes the direct leg work.
Duplicate response headers: the forwarding core relayed the upstream Date and
Server alongside the ones the serving layer generates, so every proxied
response carried two of each. Both are singleton headers and duplicating them
is malformed HTTP.
Serialization: WorkspaceViewOut declared flag_reason and three sibling strings
as str, so a NULL column made the workspace page 500 for JSON clients.
Documents the two public hostnames and the devplace.net SSH tunnel, so a future
session does not conclude the site is down after pointing curl --resolve at an
address the hostname does not resolve to, and adds the layered procedure for
diagnosing a production failure.
Verified on production with Playwright over both hostnames: the code-server
login renders and the workbench loads. Suite: 3345 passed.
2026-08-11 20:03:15 +02:00
|
|
|
command = editor.wrap_with_env_export(editor.argv(instance, profile))
|
2026-08-07 10:53:08 +02:00
|
|
|
elif language in BOOT_SCRIPT_FILES and (instance.get("boot_script") or "").strip():
|
2026-06-14 16:46:36 +02:00
|
|
|
script_path = f"{WORKSPACE_MOUNT}/{BOOT_SCRIPT_FILES[language]}"
|
|
|
|
|
command = [BOOT_SCRIPT_RUNNERS[language], script_path]
|
|
|
|
|
elif boot:
|
|
|
|
|
command = ["/bin/sh", "-c", boot]
|
|
|
|
|
else:
|
|
|
|
|
command = ["sleep", "infinity"]
|
2026-06-09 06:41:27 +02:00
|
|
|
return RunSpec(
|
2026-06-09 18:48:08 +02:00
|
|
|
image=image_tag,
|
|
|
|
|
name=instance["slug"],
|
|
|
|
|
labels={
|
|
|
|
|
INSTANCE_LABEL: instance["uid"],
|
|
|
|
|
PROJECT_LABEL: instance["project_uid"],
|
|
|
|
|
},
|
|
|
|
|
env=env,
|
2026-08-10 00:23:20 +02:00
|
|
|
cpu_limit=cpu_limit,
|
|
|
|
|
mem_limit=mem_limit,
|
2026-06-09 18:48:08 +02:00
|
|
|
ports=ports,
|
|
|
|
|
mounts=mounts,
|
|
|
|
|
restart_policy=instance.get("restart_policy", "never"),
|
|
|
|
|
command=command,
|
2026-06-09 06:41:27 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 16:46:36 +02:00
|
|
|
async def sync_workspace(instance: dict, user: dict) -> dict:
|
2026-06-09 06:41:27 +02:00
|
|
|
workspace = instance.get("workspace_dir")
|
|
|
|
|
if not workspace:
|
|
|
|
|
raise ContainerError("instance has no workspace")
|
2026-06-14 16:46:36 +02:00
|
|
|
counts = await asyncio.to_thread(
|
|
|
|
|
project_files.sync_dir_bidirectional, instance["project_uid"], workspace, user
|
|
|
|
|
)
|
|
|
|
|
store.record_event(
|
|
|
|
|
instance,
|
|
|
|
|
"sync",
|
|
|
|
|
"user",
|
|
|
|
|
user["uid"],
|
|
|
|
|
{"exported": counts["exported"], "imported": counts["imported"]},
|
|
|
|
|
)
|
|
|
|
|
return counts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sync_bidirectional_sync(instance: dict, user: dict) -> dict:
|
|
|
|
|
workspace = instance.get("workspace_dir")
|
|
|
|
|
if not workspace:
|
|
|
|
|
return {"exported": 0, "imported": 0}
|
|
|
|
|
counts = project_files.sync_dir_bidirectional(
|
|
|
|
|
instance["project_uid"], workspace, user
|
2026-06-09 18:48:08 +02:00
|
|
|
)
|
2026-06-14 16:46:36 +02:00
|
|
|
if counts["exported"] or counts["imported"]:
|
|
|
|
|
store.record_event(
|
|
|
|
|
instance,
|
|
|
|
|
"sync",
|
|
|
|
|
"service",
|
|
|
|
|
"system",
|
|
|
|
|
{"exported": counts["exported"], "imported": counts["imported"]},
|
|
|
|
|
)
|
|
|
|
|
return counts
|
2026-06-09 06:41:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def add_schedule(instance: dict, action: str, schedule: Schedule) -> dict:
|
|
|
|
|
if action not in ("start", "stop"):
|
|
|
|
|
raise ContainerError("schedule action must be start or stop")
|
|
|
|
|
first = schedule.first_run(now_utc())
|
|
|
|
|
return store.create_schedule(instance, action, schedule.columns(), to_iso(first))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- aggregation ----------------
|
|
|
|
|
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-06-09 06:41:27 +02:00
|
|
|
def _percentile(values: list, pct: float) -> float:
|
|
|
|
|
if not values:
|
|
|
|
|
return 0.0
|
|
|
|
|
ordered = sorted(values)
|
|
|
|
|
index = min(len(ordered) - 1, int(round((pct / 100.0) * (len(ordered) - 1))))
|
|
|
|
|
return float(ordered[index])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def instance_stats(instance_uid: str) -> dict:
|
|
|
|
|
metrics = store.recent_metrics(instance_uid, limit=720)
|
|
|
|
|
cpu = [m.get("cpu_pct", 0) for m in metrics]
|
|
|
|
|
mem = [m.get("mem_bytes", 0) for m in metrics]
|
|
|
|
|
return {
|
|
|
|
|
"samples": len(metrics),
|
|
|
|
|
"cpu_avg": round(sum(cpu) / len(cpu), 2) if cpu else 0.0,
|
|
|
|
|
"cpu_p95": round(_percentile(cpu, 95), 2),
|
|
|
|
|
"mem_max": max(mem) if mem else 0,
|
|
|
|
|
"mem_avg": int(sum(mem) / len(mem)) if mem else 0,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool:
|
|
|
|
|
try:
|
|
|
|
|
with socket.create_connection((host, port), timeout=timeout):
|
|
|
|
|
return True
|
|
|
|
|
except OSError:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _http_probe(host: str, port: int, timeout: float = 1.0) -> str:
|
|
|
|
|
try:
|
2026-06-14 09:48:10 +02:00
|
|
|
with stealth.stealth_sync_client(timeout=timeout) as client:
|
2026-06-09 06:41:27 +02:00
|
|
|
response = client.get(f"http://{host}:{port}/")
|
|
|
|
|
return f"HTTP {response.status_code}"
|
|
|
|
|
except Exception as exc: # noqa: BLE001 - diagnostic, any failure is informative
|
|
|
|
|
return f"unreachable: {type(exc).__name__}"
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 09:11:56 +02:00
|
|
|
def _net_entry(data: dict) -> dict:
|
|
|
|
|
net = (data or {}).get("NetworkSettings") or {}
|
|
|
|
|
if net.get("IPAddress") or net.get("Gateway"):
|
|
|
|
|
return net
|
|
|
|
|
for entry in (net.get("Networks") or {}).values():
|
|
|
|
|
if entry and (entry.get("IPAddress") or entry.get("Gateway")):
|
|
|
|
|
return entry
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def container_ip_from_inspect(data: dict) -> str:
|
|
|
|
|
return (_net_entry(data).get("IPAddress") or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def container_gateway_from_inspect(data: dict) -> str:
|
|
|
|
|
return (_net_entry(data).get("Gateway") or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ingress_container_port(instance: dict, port_maps: list) -> int:
|
2026-06-09 06:41:27 +02:00
|
|
|
ingress_port = int(instance.get("ingress_port") or 0)
|
|
|
|
|
if ingress_port:
|
|
|
|
|
for mapping in port_maps:
|
|
|
|
|
if int(mapping.get("container") or 0) == ingress_port:
|
2026-06-10 09:11:56 +02:00
|
|
|
return ingress_port
|
2026-06-09 06:41:27 +02:00
|
|
|
return 0
|
2026-06-10 09:11:56 +02:00
|
|
|
return int(port_maps[0].get("container") or 0) if port_maps else 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _host_port_for(port_maps: list, container_port: int) -> int:
|
|
|
|
|
for mapping in port_maps:
|
|
|
|
|
if int(mapping.get("container") or 0) == container_port:
|
|
|
|
|
return int(mapping.get("host") or 0)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
Route container proxies through the leg that is actually reachable
The workspace editor hung for 60s and then 504'd. Three independent faults were
stacked behind that one symptom.
Reachability: editor_target delegated to proxy_target, which returns
CONTAINER_PROXY_HOST plus the published host port and never falls back to the
container. From inside the app container that address crosses docker0 into the
host INPUT chain, whose policy is DROP with an allow-list that does not include
the published port range, so the packet was dropped and the request hung rather
than being refused. Measured from the app container: container_ip:8443 answers
302, gateway:20006 is dropped. One shared reachable_target now prefers the
direct container leg and falls back to the published port, and editor_target
uses tunnel_target as services/containers/CLAUDE.md already required. The same
defect affected /p/{slug} ingress and every tunnel, since all three resolved
through proxy_target.
The recorded measurement that motivated the old order (container_ip times out,
gateway connects) no longer holds: make docker-attach puts the app on the
instances' bridge network, which is what makes the direct leg work.
Duplicate response headers: the forwarding core relayed the upstream Date and
Server alongside the ones the serving layer generates, so every proxied
response carried two of each. Both are singleton headers and duplicating them
is malformed HTTP.
Serialization: WorkspaceViewOut declared flag_reason and three sibling strings
as str, so a NULL column made the workspace page 500 for JSON clients.
Documents the two public hostnames and the devplace.net SSH tunnel, so a future
session does not conclude the site is down after pointing curl --resolve at an
address the hostname does not resolve to, and adds the layered procedure for
diagnosing a production failure.
Verified on production with Playwright over both hostnames: the code-server
login renders and the workbench loads. Suite: 3345 passed.
2026-08-11 20:03:15 +02:00
|
|
|
def reachable_target(instance: dict, container_port: int, port_maps: list) -> tuple:
|
|
|
|
|
container_ip = (instance.get("container_ip") or "").strip()
|
|
|
|
|
if container_ip and container_port > 0:
|
|
|
|
|
return container_ip, container_port
|
|
|
|
|
host_port = _host_port_for(port_maps, container_port)
|
|
|
|
|
if not host_port:
|
|
|
|
|
return None, None
|
|
|
|
|
gateway = (instance.get("container_gateway") or "").strip()
|
|
|
|
|
return config.CONTAINER_PROXY_HOST or gateway or "127.0.0.1", host_port
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 09:11:56 +02:00
|
|
|
def proxy_target(instance: dict) -> tuple:
|
|
|
|
|
port_maps = json.loads(instance.get("ports_json") or "[]")
|
|
|
|
|
container_port = _ingress_container_port(instance, port_maps)
|
|
|
|
|
if not container_port:
|
|
|
|
|
return None, None
|
Route container proxies through the leg that is actually reachable
The workspace editor hung for 60s and then 504'd. Three independent faults were
stacked behind that one symptom.
Reachability: editor_target delegated to proxy_target, which returns
CONTAINER_PROXY_HOST plus the published host port and never falls back to the
container. From inside the app container that address crosses docker0 into the
host INPUT chain, whose policy is DROP with an allow-list that does not include
the published port range, so the packet was dropped and the request hung rather
than being refused. Measured from the app container: container_ip:8443 answers
302, gateway:20006 is dropped. One shared reachable_target now prefers the
direct container leg and falls back to the published port, and editor_target
uses tunnel_target as services/containers/CLAUDE.md already required. The same
defect affected /p/{slug} ingress and every tunnel, since all three resolved
through proxy_target.
The recorded measurement that motivated the old order (container_ip times out,
gateway connects) no longer holds: make docker-attach puts the app on the
instances' bridge network, which is what makes the direct leg work.
Duplicate response headers: the forwarding core relayed the upstream Date and
Server alongside the ones the serving layer generates, so every proxied
response carried two of each. Both are singleton headers and duplicating them
is malformed HTTP.
Serialization: WorkspaceViewOut declared flag_reason and three sibling strings
as str, so a NULL column made the workspace page 500 for JSON clients.
Documents the two public hostnames and the devplace.net SSH tunnel, so a future
session does not conclude the site is down after pointing curl --resolve at an
address the hostname does not resolve to, and adds the layered procedure for
diagnosing a production failure.
Verified on production with Playwright over both hostnames: the code-server
login renders and the workbench loads. Suite: 3345 passed.
2026-08-11 20:03:15 +02:00
|
|
|
return reachable_target(instance, container_port, port_maps)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def tunnel_target(instance: dict, container_port: int) -> tuple:
|
|
|
|
|
if container_port <= 0:
|
2026-06-10 09:11:56 +02:00
|
|
|
return None, None
|
Route container proxies through the leg that is actually reachable
The workspace editor hung for 60s and then 504'd. Three independent faults were
stacked behind that one symptom.
Reachability: editor_target delegated to proxy_target, which returns
CONTAINER_PROXY_HOST plus the published host port and never falls back to the
container. From inside the app container that address crosses docker0 into the
host INPUT chain, whose policy is DROP with an allow-list that does not include
the published port range, so the packet was dropped and the request hung rather
than being refused. Measured from the app container: container_ip:8443 answers
302, gateway:20006 is dropped. One shared reachable_target now prefers the
direct container leg and falls back to the published port, and editor_target
uses tunnel_target as services/containers/CLAUDE.md already required. The same
defect affected /p/{slug} ingress and every tunnel, since all three resolved
through proxy_target.
The recorded measurement that motivated the old order (container_ip times out,
gateway connects) no longer holds: make docker-attach puts the app on the
instances' bridge network, which is what makes the direct leg work.
Duplicate response headers: the forwarding core relayed the upstream Date and
Server alongside the ones the serving layer generates, so every proxied
response carried two of each. Both are singleton headers and duplicating them
is malformed HTTP.
Serialization: WorkspaceViewOut declared flag_reason and three sibling strings
as str, so a NULL column made the workspace page 500 for JSON clients.
Documents the two public hostnames and the devplace.net SSH tunnel, so a future
session does not conclude the site is down after pointing curl --resolve at an
address the hostname does not resolve to, and adds the layered procedure for
diagnosing a production failure.
Verified on production with Playwright over both hostnames: the code-server
login renders and the workbench loads. Suite: 3345 passed.
2026-08-11 20:03:15 +02:00
|
|
|
port_maps = json.loads(instance.get("ports_json") or "[]")
|
|
|
|
|
return reachable_target(instance, container_port, port_maps)
|
2026-06-09 06:41:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def instance_runtime(instance: dict) -> dict:
|
|
|
|
|
boot = (instance.get("boot_command") or "").strip()
|
|
|
|
|
port_maps = json.loads(instance.get("ports_json") or "[]")
|
2026-06-10 09:11:56 +02:00
|
|
|
container_ip = (instance.get("container_ip") or "").strip()
|
|
|
|
|
container_gateway = (instance.get("container_gateway") or "").strip()
|
|
|
|
|
target_host, target_port = proxy_target(instance)
|
|
|
|
|
probe_host = config.CONTAINER_PROXY_HOST or container_gateway or "127.0.0.1"
|
2026-06-09 06:41:27 +02:00
|
|
|
ports = []
|
|
|
|
|
for mapping in port_maps:
|
|
|
|
|
host_port = int(mapping.get("host") or 0)
|
2026-06-09 18:48:08 +02:00
|
|
|
ports.append(
|
|
|
|
|
{
|
|
|
|
|
"container": int(mapping.get("container") or 0),
|
|
|
|
|
"host": host_port,
|
|
|
|
|
"proto": mapping.get("proto", "tcp"),
|
2026-06-10 09:11:56 +02:00
|
|
|
"reachable": _port_reachable(probe_host, host_port)
|
|
|
|
|
if host_port
|
|
|
|
|
else False,
|
2026-06-09 18:48:08 +02:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
ingress_serving = (
|
2026-06-10 09:11:56 +02:00
|
|
|
_http_probe(target_host, target_port)
|
|
|
|
|
if target_host and target_port
|
2026-06-09 18:48:08 +02:00
|
|
|
else "no ingress port mapped"
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
return {
|
|
|
|
|
"command": boot or "image CMD (no boot_command set)",
|
|
|
|
|
"ports": ports,
|
2026-06-10 09:11:56 +02:00
|
|
|
"container_ip": container_ip,
|
|
|
|
|
"container_gateway": container_gateway,
|
|
|
|
|
"ingress_target": (
|
|
|
|
|
f"{target_host}:{target_port}" if target_host and target_port else ""
|
|
|
|
|
),
|
2026-06-09 06:41:27 +02:00
|
|
|
"ingress_port": int(instance.get("ingress_port") or 0),
|
|
|
|
|
"ingress_serving": ingress_serving,
|
|
|
|
|
"status_ok_for_ingress": instance.get("status") == store.ST_RUNNING,
|
|
|
|
|
"restart_count": int(instance.get("restart_count") or 0),
|
|
|
|
|
"exit_code": instance.get("exit_code"),
|
|
|
|
|
"container_id": (instance.get("container_id") or "")[:12],
|
|
|
|
|
}
|