# retoor <retoor@molodetz.nl>
import json
import logging
from typing import Any
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.services.containers import api, store
from devplacepy.services.containers.api import ContainerError
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.devii.errors import ToolInputError
from devplacepy.services.devii.tasks.schedule import Schedule, from_iso
logger = logging.getLogger("devii.container")
class ContainerController:
def __init__(self, client: Any = None) -> None:
self._client = client
def _ingress_url(self, instance: dict):
slug = instance.get("ingress_slug")
if not slug:
return None
from devplacepy.seo import public_base_url
base = public_base_url()
return f"{base}/p/{slug}" if base else f"/p/{slug}"
def _actor_user(self) -> dict:
username = getattr(self._client, "username", None)
if username:
user = get_table("users").find_one(username=username)
if user:
return user
return {"uid": "admin", "username": username or "admin"}
def _project(self, arguments: dict) -> dict:
from devplacepy.content import can_view_project
slug = str(arguments.get("project_slug", "")).strip()
project = resolve_by_slug(get_table("projects"), slug) if slug else None
if not project or not can_view_project(project, self._actor_user()):
raise ToolInputError(f"project not found: {slug}")
return project
def _instance(self, project: dict, ref: str) -> dict:
inst = store.get_instance(ref)
if inst is None or inst["project_uid"] != project["uid"]:
for candidate in store.list_instances(project["uid"]):
if candidate["name"] == ref:
return candidate
raise ToolInputError(f"instance not found: {ref}")
return inst
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
handler = (
getattr(self, "_" + name[len("container_") :], None)
if name.startswith("container_")
else None
)
if handler is None:
raise ToolInputError(f"unknown container tool: {name}")
try:
return await handler(arguments)
except ContainerError as exc:
return json.dumps({"error": "container_error", "message": str(exc)})
async def _list_instances(self, arguments) -> str:
project = self._project(arguments)
instances = store.list_instances(project["uid"])
for inst in instances:
inst["ingress_url"] = self._ingress_url(inst)
return json.dumps({"instances": instances}, default=str)
async def _create_instance(self, arguments) -> str:
project = self._project(arguments)
autostart = str(arguments.get("autostart", "true")).lower() not in (
"false",
"0",
"no",
"off",
)
start_on_boot = str(arguments.get("start_on_boot", "false")).lower() in (
"true",
"1",
"yes",
"on",
)
inst = await api.create_instance(
project,
name=str(arguments.get("name", "")),
boot_command=str(arguments.get("boot_command", "")),
boot_language=str(arguments.get("boot_language", "none")),
boot_script=str(arguments.get("boot_script", "")),
run_as_uid=str(arguments.get("run_as_uid", "")),
start_on_boot=start_on_boot,
env=arguments.get("env", ""),
cpu_limit=str(arguments.get("cpu_limit", "")),
mem_limit=str(arguments.get("mem_limit", "")),
ports=arguments.get("ports", ""),
restart_policy=str(arguments.get("restart_policy", "never")),
autostart=autostart,
ingress_slug=str(arguments.get("ingress_slug", "")),
ingress_port=arguments.get("ingress_port"),
actor=("user", self._actor_user()["uid"]),
)
return json.dumps(
{"instance": inst, "ingress_url": self._ingress_url(inst)}, default=str
)
async def _instance_action(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
action = str(arguments.get("action", "")).lower()
actor = ("user", self._actor_user()["uid"])
if action == "delete":
api.mark_for_removal(inst, actor=actor)
elif action == "sync":
counts = await api.sync_workspace(inst, self._actor_user())
return json.dumps({"status": "synced", **counts})
elif action == "restart":
api.request_restart(inst, actor=actor)
elif action in ("start", "resume"):
api.set_desired_state(inst, store.DESIRED_RUNNING, actor=actor)
elif action == "stop":
api.set_desired_state(inst, store.DESIRED_STOPPED, actor=actor)
elif action == "pause":
api.set_desired_state(inst, store.DESIRED_PAUSED, actor=actor)
else:
raise ToolInputError(f"unknown action: {action}")
return json.dumps({"status": "ok", "action": action, "instance": inst["uid"]})
async def _configure_instance(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
actor = ("user", self._actor_user()["uid"])
kwargs: dict = {}
for key in (
"run_as_uid",
"boot_language",
"boot_script",
"boot_command",
"restart_policy",
"cpu_limit",
"mem_limit",
):
if key in arguments and arguments.get(key) is not None:
kwargs[key] = str(arguments.get(key))
if "start_on_boot" in arguments and arguments.get("start_on_boot") is not None:
kwargs["start_on_boot"] = str(arguments.get("start_on_boot")).lower() in (
"true",
"1",
"yes",
"on",
)
updated = api.update_instance_config(inst, actor=actor, **kwargs)
return json.dumps({"status": "configured", "instance": updated}, default=str)
async def _logs(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
if not inst.get("container_id"):
return json.dumps({"logs": "", "status": inst["status"]})
lines: list = []
async def collect(line: str) -> None:
lines.append(line)
tail = int(arguments.get("tail", 200) or 200)
await get_backend().logs(
inst["container_id"],
follow=False,
tail=max(1, min(tail, 2000)),
on_log=collect,
)
return json.dumps({"logs": "\n".join(lines)})
async def _exec(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
if not inst.get("container_id"):
raise ToolInputError("instance is not running")
command = str(arguments.get("command", "")).strip()
if not command:
raise ToolInputError("command is required")
result = await get_backend().exec(
inst["container_id"], ["/bin/sh", "-c", command]
)
actor = self._actor_user()
store.record_event(
inst,
"exec",
"user",
actor["uid"],
{"command": command, "exit_code": result.exit_code},
)
return json.dumps({"exit_code": result.exit_code, "output": result.output})
async def _stats(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
return json.dumps(
{
"instance": inst["name"],
"status": inst["status"],
"ingress_url": self._ingress_url(inst),
"runtime": api.instance_runtime(inst),
"stats": api.instance_stats(inst["uid"]),
},
default=str,
)
async def _schedule(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
run_at = arguments.get("run_at")
try:
schedule = Schedule(
kind=str(arguments.get("kind", "")),
cron=arguments.get("cron") or None,
run_at=from_iso(run_at) if run_at else None,
every_seconds=arguments.get("every_seconds"),
)
except ValueError as exc:
raise ToolInputError(str(exc))
sched = api.add_schedule(inst, str(arguments.get("action", "")), schedule)
return json.dumps({"schedule": sched}, default=str)