feat: add container manager CLI commands and docker-compose overlay for admin container lifecycle
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import config, project_files
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.locks import NUMBERING_LOCK
|
||||
from devplacepy.services.containers.templates import DEFAULT_DOCKERFILE
|
||||
from devplacepy.services.containers.backend.base import Mount, PortMapping, RunSpec
|
||||
from devplacepy.services.devii.tasks.schedule import Schedule, next_run, now_utc, to_iso, from_iso
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
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
|
||||
|
||||
|
||||
class ContainerError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_image_name(name: str) -> str:
|
||||
name = (name or "").strip().lower()
|
||||
if not IMAGE_NAME_RE.match(name):
|
||||
raise ContainerError("name must be lowercase letters, digits, '.', '_' or '-' (max 63 chars)")
|
||||
return name
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def parse_ports(value) -> list:
|
||||
ports = []
|
||||
if not value:
|
||||
return ports
|
||||
items = value if isinstance(value, list) else str(value).replace(",", "\n").splitlines()
|
||||
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():
|
||||
raise ContainerError(f"port '{item}' must be numeric host:container or a bare container port")
|
||||
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
|
||||
raise ContainerError(f"no free host port available in range {HOST_PORT_MIN}-{HOST_PORT_MAX}")
|
||||
|
||||
|
||||
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:
|
||||
raise ContainerError(f"host port {host} is already published by another instance")
|
||||
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
|
||||
|
||||
|
||||
# ---------------- dockerfiles + builds ----------------
|
||||
|
||||
async def enqueue_build(dockerfile: dict, version: dict, *, also_latest: bool = True, owner=("system", "system")) -> dict:
|
||||
async with NUMBERING_LOCK:
|
||||
head = store.get_dockerfile(dockerfile["uid"])
|
||||
build_number = int(head.get("latest_build_number") or 0) + 1
|
||||
store.update_dockerfile(head["uid"], {"latest_build_number": build_number})
|
||||
image_tag = f"{head['name']}:{build_number}"
|
||||
build = store.create_build(head, version, build_number, image_tag, also_latest)
|
||||
job_uid = queue.enqueue("container_build", {"build_uid": build["uid"]}, owner[0], owner[1], f"build {image_tag}")
|
||||
store.update_build(build["uid"], {"job_uid": job_uid})
|
||||
return store.get_build(build["uid"])
|
||||
|
||||
|
||||
async def create_dockerfile(project: dict, user: dict, *, name: str, description: str = "",
|
||||
tags: str = "", content: str = None) -> dict:
|
||||
name = validate_image_name(name)
|
||||
for existing in store.list_dockerfiles(project["uid"]):
|
||||
if existing["name"] == name:
|
||||
raise ContainerError(f"a Dockerfile named '{name}' already exists in this project")
|
||||
content = content if content is not None and content.strip() else DEFAULT_DOCKERFILE
|
||||
dockerfile = store.create_dockerfile(project["uid"], user, name, description, tags)
|
||||
version = store.create_version(dockerfile, content, user)
|
||||
build = await enqueue_build(dockerfile, version, owner=("user", user["uid"]))
|
||||
return {"dockerfile": store.get_dockerfile(dockerfile["uid"]), "version": version, "build": build}
|
||||
|
||||
|
||||
async def save_version(dockerfile: dict, user: dict, content: str, *, force: bool = False) -> dict:
|
||||
if not force and store.content_hash(content) == dockerfile.get("content_hash"):
|
||||
return {"version": None, "build": None, "changed": False}
|
||||
version = store.create_version(dockerfile, content, user)
|
||||
build = await enqueue_build(store.get_dockerfile(dockerfile["uid"]), version, owner=("user", user["uid"]))
|
||||
return {"version": version, "build": build, "changed": True}
|
||||
|
||||
|
||||
# ---------------- instances ----------------
|
||||
|
||||
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):
|
||||
raise ContainerError("ingress slug must be lowercase letters, digits, or '-' (max 63 chars)")
|
||||
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:
|
||||
raise ContainerError(f"ingress_port {ingress_port} must be one of the container ports you mapped")
|
||||
if not ingress_port and len(container_ports) != 1:
|
||||
raise ContainerError("set ingress_port to choose which mapped container port to publish")
|
||||
return slug, ingress_port
|
||||
|
||||
|
||||
async def create_instance(project: dict, dockerfile: dict, build: dict, *, name: str,
|
||||
boot_command: str = "", 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:
|
||||
if build.get("status") != store.BUILD_SUCCESS:
|
||||
raise ContainerError("the selected build has not completed successfully")
|
||||
if restart_policy not in store.RESTART_POLICIES:
|
||||
raise ContainerError(f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}")
|
||||
_validate_limits(cpu_limit, mem_limit)
|
||||
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"]
|
||||
await asyncio.to_thread(project_files.export_to_dir, project["uid"], "", str(workspace))
|
||||
|
||||
row = {
|
||||
"project_uid": project["uid"], "dockerfile_uid": dockerfile["uid"], "build_uid": build["uid"],
|
||||
"name": name, "boot_command": boot_command or "",
|
||||
"env_json": json.dumps(env_map),
|
||||
"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 []),
|
||||
"restart_policy": restart_policy,
|
||||
"ingress_slug": ingress_slug, "ingress_port": ingress_port,
|
||||
"desired_state": store.DESIRED_RUNNING if autostart else store.DESIRED_STOPPED,
|
||||
"status": store.ST_CREATED,
|
||||
"workspace_dir": str(workspace),
|
||||
}
|
||||
instance = store.create_instance(row)
|
||||
store.record_event(instance, "created", actor[0], actor[1], {"build": build["uid"]})
|
||||
return instance
|
||||
|
||||
|
||||
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):
|
||||
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:
|
||||
store.update_instance(instance["uid"], {"desired_state": store.DESIRED_RUNNING, "status": store.ST_RESTARTING})
|
||||
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:
|
||||
store.update_instance(instance["uid"], {"desired_state": store.DESIRED_STOPPED, "status": store.ST_REMOVING})
|
||||
store.record_event(instance, "remove", actor[0], actor[1])
|
||||
|
||||
|
||||
def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
|
||||
env = json.loads(instance.get("env_json") or "{}")
|
||||
ports = [PortMapping(p["host"], p["container"], p.get("proto", "tcp"))
|
||||
for p in json.loads(instance.get("ports_json") or "[]")]
|
||||
mounts = [Mount(instance["workspace_dir"], "/app", "rw")]
|
||||
for extra in json.loads(instance.get("volumes_json") or "[]"):
|
||||
if isinstance(extra, dict) and extra.get("host") and extra.get("container"):
|
||||
mounts.append(Mount(extra["host"], extra["container"], extra.get("mode", "rw")))
|
||||
command = []
|
||||
boot = (instance.get("boot_command") or "").strip()
|
||||
if boot:
|
||||
command = ["/bin/sh", "-c", boot]
|
||||
return RunSpec(
|
||||
image=image_tag, name=instance["slug"],
|
||||
labels={INSTANCE_LABEL: instance["uid"], PROJECT_LABEL: instance["project_uid"]},
|
||||
env=env, cpu_limit=instance.get("cpu_limit", ""), mem_limit=instance.get("mem_limit", ""),
|
||||
ports=ports, mounts=mounts, restart_policy=instance.get("restart_policy", "never"), command=command,
|
||||
)
|
||||
|
||||
|
||||
async def sync_workspace(instance: dict, user: dict) -> int:
|
||||
workspace = instance.get("workspace_dir")
|
||||
if not workspace:
|
||||
raise ContainerError("instance has no workspace")
|
||||
count = await asyncio.to_thread(project_files.import_from_dir, instance["project_uid"], workspace, user)
|
||||
store.record_event(instance, "sync", "user", user["uid"], {"imported": count})
|
||||
return count
|
||||
|
||||
|
||||
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 ----------------
|
||||
|
||||
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 dockerfile_stats(dockerfile_uid: str) -> dict:
|
||||
builds = store.list_builds(dockerfile_uid, limit=1000)
|
||||
done = [b for b in builds if b["status"] in (store.BUILD_SUCCESS, store.BUILD_FAILED)]
|
||||
success = [b for b in done if b["status"] == store.BUILD_SUCCESS]
|
||||
durations = [int(b.get("duration_ms") or 0) for b in success if b.get("duration_ms")]
|
||||
return {
|
||||
"total_builds": len(builds),
|
||||
"success": len(success),
|
||||
"failed": len(done) - len(success),
|
||||
"success_rate": round(len(success) / len(done) * 100, 1) if done else 0.0,
|
||||
"avg_build_ms": int(sum(durations) / len(durations)) if durations else 0,
|
||||
"p95_build_ms": int(_percentile(durations, 95)),
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
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__}"
|
||||
|
||||
|
||||
def _ingress_host_port(instance: dict, port_maps: list) -> int:
|
||||
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:
|
||||
return int(mapping.get("host") or 0)
|
||||
return 0
|
||||
return int(port_maps[0].get("host") or 0) if port_maps else 0
|
||||
|
||||
|
||||
def instance_runtime(instance: dict) -> dict:
|
||||
boot = (instance.get("boot_command") or "").strip()
|
||||
port_maps = json.loads(instance.get("ports_json") or "[]")
|
||||
host = config.CONTAINER_PROXY_HOST
|
||||
ports = []
|
||||
for mapping in port_maps:
|
||||
host_port = int(mapping.get("host") or 0)
|
||||
ports.append({
|
||||
"container": int(mapping.get("container") or 0),
|
||||
"host": host_port,
|
||||
"proto": mapping.get("proto", "tcp"),
|
||||
"reachable": _port_reachable(host, host_port) if host_port else False,
|
||||
})
|
||||
ingress_host_port = _ingress_host_port(instance, port_maps)
|
||||
ingress_serving = _http_probe(host, ingress_host_port) if ingress_host_port else "no ingress port mapped"
|
||||
return {
|
||||
"command": boot or "image CMD (no boot_command set)",
|
||||
"ports": ports,
|
||||
"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],
|
||||
}
|
||||
Reference in New Issue
Block a user