Working INGRESS and tests working and Pravda name change.
Some checks failed
DevPlace CI / test (push) Failing after 7m46s

This commit is contained in:
retoor 2026-06-10 09:11:56 +02:00
parent 3a6085fb55
commit e1906f6ce8
9 changed files with 129 additions and 52 deletions

View File

@ -148,8 +148,8 @@ Admin-only. Supervises container instances, all running one shared prebuilt imag
- **Interactive shell = floating xterm.js window.** An instance's shell is NOT inline; it is a floating `<container-terminal>` (`static/js/components/ContainerTerminal.js`) over the existing exec WS (`/projects/{slug}/containers/instances/{uid}/exec/ws`, `pty.openpty()` + `docker exec -it`, admin + `service_manager.owns_lock()` gated). xterm + fit addon are vendored under `static/vendor/xterm/` and lazy-loaded. It extends the generic **`FloatingWindow`** base (`static/js/components/FloatingWindow.js` + `static/css/floating-window.css`) which owns the Devii-style chrome (drag, native `resize:both`, maximize/fullscreen, geometry persistence). **`app.windows`** (`WindowManager`, `components/WindowManager.js`) assigns z-index on `pointerdown` so the last-clicked window is on top. **The Devii terminal (`devii/devii-terminal.js`) now also `extends FloatingWindow`** and reuses the base drag/geometry/preset/resize/persist plus `_window`/`_registerWindow` (z-order + context-menu) machinery; it overrides only the parts that genuinely differ - `_setState` (Devii adds a `closed` state + launcher FAB), `_defaultGeometry` (bottom-right 760x600), `_changeFont` (per-user `--devii-font-size` CSS var), `_contextItems`, and `_persistGeometry` (delegates to its richer `{state,geometry,focused}` blob under `devii-terminal-state`). Devii keeps native `resize:both` (no `.fw-resize` grip) and **`devii.css` is unchanged**: the only base change required was a `get _dragClass()` getter (default `fw-dragging`) that Devii overrides to `devii-dragging`, so the base drag handlers toggle Devii's own class and its existing CSS drives everything. Open it two ways: the instance-page **Open terminal** button (`app.containerTerminals.open(slug, uid, name)`, `ContainerTerminalManager`) or Devii "attach &lt;container&gt;" -> the admin-only `open_terminal` **client** action (`client_actions.py`, `requires_admin=True`) -> `DeviiClient._openTerminal`. **Resize protocol:** the exec WS treats a brace-leading JSON frame `{"type":"resize","cols","rows"}` as a control message - it `TIOCSWINSZ`-es the host pty (`fcntl.ioctl`) **and** `proc.send_signal(SIGWINCH)` to the `docker exec -it` client so the resize forwards into the container tty (the SIGWINCH is required: with `start_new_session=True` the host slave is not the client's controlling terminal, so the kernel does not auto-deliver it; the pair shares its winsize, so the client reads the new size off its slave stdio and calls Docker's exec-resize API). Everything else is raw stdin, so keystrokes are untouched. Client-side the **fit addon** drives it and the window has a dedicated `.fw-resize` grip (xterm covers the native CSS resize corner, so the base `FloatingWindow` adds a manual bottom-right grip instead of relying on `resize:both`). **Persistence (tmux):** with `?session=<name>` (validated `^[A-Za-z0-9_-]{1,64}$`) the WS runs `tmux new-session -A -s <name>` (falls back to bash if tmux missing), so the shell/server survives WS death - `proc.kill` on disconnect kills the tmux *client*, the server daemon + session live on (proven: a detached `devplace` session keeps its process running across exec clients). The frontend keeps exactly ONE persistent terminal (the last opened, `ContainerTerminalManager`): it attaches to session `devplace`, **auto-reconnects** with capped backoff on unexpected WS close (not on code 1008), and is remembered per user in `localStorage` (`ct-persistent:<scope>`); `app.containerTerminals.restore()` (one call in `Application.js` after `window.app`) reopens it on the next page load. Opening another terminal calls `setPersistent(false)` on the previous one (its tmux session lingers in the container, but the frontend stops auto-reconnecting/remembering it); explicit window-close (`_onClose`) detaches + forgets (session still survives, reopen re-attaches). **Context menu:** every window wires `app.contextMenu.attach(this.win, () => this._contextItems())` (right-click + long-press). The base `FloatingWindow._contextItems` is Minimize/Normalize/Close; `ContainerTerminal` overrides it with Sync files / Restart / Terminate (`dp-dialog` confirm, then `delete` + close) / Minimize / Normalize / Project / Files / Close - the lifecycle items POST to the existing `instances/{uid}/{sync,restart,delete}` routes and Project/Files `window.location.assign` to `/projects/{slug}` and `/projects/{slug}/files`. Devii's `_contextItems` override returns Minimize/Normalize/Close only (no container/project actions, since it is bound to neither). **Minimize/Normalize** are `_presetGeometry(w,h)` size presets (smallest-usable / comfortable), available as both titlebar buttons (`data-win="minimize|normalize"`) and menu items. xterm renders ANSI natively (no `cleanTerm` stripping for the interactive shell; the one-shot exec box still strips, since `docker exec` without `-t` is non-TTY).
- **No wildcard verb route (load-bearing).** `routers/containers.py` registers every instance verb as a **literal** route - `start`/`stop`/`pause`/`resume`/`restart` (stacked `@router.post` decorators on one `instance_action` handler that reads the verb from `request.url.path`), plus `delete`/`exec`/`sync`/`schedules`. There is deliberately NO `POST /instances/{uid}/{action}` catch-all: a wildcard segment matches its literal siblings too (Starlette matches first-declared), so a catch-all silently swallowed `exec`/`sync`/`delete`/`schedules` as `{"error": "unknown action: exec"}` whenever it sat above them. Add any new instance verb as a literal route (and to the `instance_action` decorator stack if it just flips desired state) - never reintroduce a `{action}` wildcard.
- **Host ports are auto-allocated and globally unique.** `parse_ports` accepts a bare container port (`8899`, host side `0` = auto) or a pinned `host:container`. `api.assign_host_ports` (called in `create_instance`) resolves every `host=0` to the lowest free port in `[HOST_PORT_MIN=20001, 65535]` that is neither in `used_host_ports()` (the union of every instance's published host ports from `ports_json`) nor currently bound on the host (`_host_port_free` test-binds `0.0.0.0:port`). A pinned host port already published by another instance is rejected up front. This guarantees no two instances ever collide on a host port, which was the silent `docker run` "port is already allocated" failure. The resolved host port is what gets stored in `ports_json` and what the ingress proxy reads.
- **Ingress (`/p/<slug>`)** = `routers/proxy.py` (registered at `/p`). An instance opts in with `ingress_slug` + `ingress_port` (must be one of its mapped container ports; slug unique, validated in `api.validate_ingress`). `_resolve(slug)` -> `store.find_instance_by_ingress` -> the published host port; the route reverse-proxies HTTP (httpx) and WebSocket (`websockets` client) to `http://{config.CONTAINER_PROXY_HOST}:{host_port}/{path}`, stripping the `/p/<slug>` prefix. **Public** (no auth) but SSRF-safe (host/port come from the instance row, never user input). nginx needs a `/p/` location with WS upgrade + long timeouts (added to `nginx/nginx.conf.template`).
- **Production wiring** (the manager drives the host docker daemon): opt-in overlay `docker-compose.containers.yml` (socket mount, `INSTALL_DOCKER_CLI=true` build arg, `group_add` docker gid). `make docker-build`/`make docker-up` always apply this overlay and self-derive its inputs (`DOCKER_GID` from `stat -c '%g' /var/run/docker.sock`, `DEVPLACE_DATA_DIR=$(CURDIR)/var`), so it works with no `sudo`/`/srv`/`.env` edits; a plain `docker compose up -d` drops the overlay and re-breaks it. **DooD bind-mount gotcha**: `docker run -v <path>:/app` resolves `<path>` on the HOST, so `DEVPLACE_DATA_DIR` must sit at an identical host+container path (make uses `$(CURDIR)/var`; manual compose defaults to `/srv/devplace-data`); build contexts ship via the docker API tarball so the container temp dir is fine. Ingress reaches host ports via `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` (containerized) or `127.0.0.1` (bare-metal). See README "Container Manager wiring".
- **Ingress (`/p/<slug>`)** = `routers/proxy.py` (registered at `/p`). An instance opts in with `ingress_slug` + `ingress_port` (must be one of its mapped container ports; slug unique, validated in `api.validate_ingress`). `_resolve(slug)` -> `store.find_instance_by_ingress` -> `api.proxy_target(instance)` returning `(gateway, host_port)` (the instance's recorded docker bridge gateway + published host port); the route reverse-proxies HTTP (httpx) and WebSocket (`websockets` client) to `http://{host}:{port}/{path}`, stripping the `/p/<slug>` prefix. The reconciler records `container_ip`/`container_gateway` from `docker inspect` each running tick. Gateway+published-port is used instead of loopback (broken where docker's `nat OUTPUT` excludes `127.0.0.0/8` from DNAT) and instead of the container's own bridge IP (droppable by docker's `DOCKER ! -i docker0 -o docker0 -j DROP` isolation rule); `DEVPLACE_CONTAINER_PROXY_HOST` overrides the host (still with the published port). **Public** (no auth) but SSRF-safe (host/port come from the instance row, never user input). nginx needs a `/p/` location with WS upgrade + long timeouts (added to `nginx/nginx.conf.template`).
- **Production wiring** (the manager drives the host docker daemon): opt-in overlay `docker-compose.containers.yml` (socket mount, `INSTALL_DOCKER_CLI=true` build arg, `group_add` docker gid). `make docker-build`/`make docker-up` always apply this overlay and self-derive its inputs (`DOCKER_GID` from `stat -c '%g' /var/run/docker.sock`, `DEVPLACE_DATA_DIR=$(CURDIR)/var`), so it works with no `sudo`/`/srv`/`.env` edits; a plain `docker compose up -d` drops the overlay and re-breaks it. **DooD bind-mount gotcha**: `docker run -v <path>:/app` resolves `<path>` on the HOST, so `DEVPLACE_DATA_DIR` must sit at an identical host+container path (make uses `$(CURDIR)/var`; manual compose defaults to `/srv/devplace-data`); build contexts ship via the docker API tarball so the container temp dir is fine. Ingress reaches host ports via each instance's recorded docker bridge gateway (`api.proxy_target` -> `gateway:host_port`); set `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` only when a containerized app cannot route to that gateway. See README "Container Manager wiring".
- **Testing without Docker:** `FakeBackend` (its `image_exists` returns True) + `runtime.set_backend`, monkeypatch `config.CONTAINER_WORKSPACES_DIR` to tmp. See `tests/test_containers.py` (argv, instance creation on `config.CONTAINER_IMAGE`, image-not-built guard, reconcile matrices, schedule firing, ingress validation + live HTTP proxy, HTTP admin gate).
## CDN Libraries

File diff suppressed because one or more lines are too long

View File

@ -31,8 +31,13 @@ DATA_DIR = Path(environ.get("DEVPLACE_DATA_DIR", str(BASE_DIR / "var")))
CONTAINER_WORKSPACES_DIR = DATA_DIR / "container_workspaces"
# Every container instance runs this one prebuilt image (built once via `make ppy`).
CONTAINER_IMAGE = environ.get("DEVPLACE_CONTAINER_IMAGE", "ppy:latest")
# Host the /p/<slug> ingress proxy dials to reach a published container port.
CONTAINER_PROXY_HOST = environ.get("DEVPLACE_CONTAINER_PROXY_HOST", "127.0.0.1")
# Override host the /p/<slug> ingress proxy dials, with the published host port,
# instead of the container's own bridge IP. Set this only for topologies where
# the app cannot route to the container network directly (containerized app via
# docker socket: use host.docker.internal). Left empty, the proxy connects
# straight to the container IP and container port, which avoids the host
# port-publishing layer (docker-proxy / iptables DNAT / loopback) entirely.
CONTAINER_PROXY_HOST = environ.get("DEVPLACE_CONTAINER_PROXY_HOST", "").strip()
VAPID_PRIVATE_KEY_FILE = BASE_DIR / "notification-private.pem"
VAPID_PRIVATE_KEY_PKCS8_FILE = BASE_DIR / "notification-private.pkcs8.pem"

View File

@ -1,7 +1,6 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import logging
import httpx
@ -9,8 +8,7 @@ import websockets
from fastapi import APIRouter, Request, WebSocket
from starlette.responses import Response
from devplacepy import config
from devplacepy.services.containers import store
from devplacepy.services.containers import api, store
from devplacepy.utils import not_found
logger = logging.getLogger(__name__)
@ -71,31 +69,23 @@ def _rewrite_location(value: str, prefix: str) -> str:
def _resolve(slug: str):
instance = store.find_instance_by_ingress(slug)
if not instance or instance.get("status") != store.ST_RUNNING:
return None, None
ports = json.loads(instance.get("ports_json") or "[]")
if not ports:
return instance, None
ingress_port = int(instance.get("ingress_port") or 0)
if ingress_port:
for port in ports:
if int(port["container"]) == ingress_port:
return instance, int(port["host"])
return instance, None
return instance, int(ports[0]["host"])
return None, None, None
host, port = api.proxy_target(instance)
return instance, host, port
@router.api_route("/{slug}", methods=METHODS)
@router.api_route("/{slug}/{path:path}", methods=METHODS)
async def proxy_http(request: Request, slug: str, path: str = ""):
instance, port = _resolve(slug)
instance, host, port = _resolve(slug)
if instance is None:
raise not_found("No running container is published at this address")
if port is None:
if not host or not port:
return Response(
"the published container has no reachable port", status_code=502
)
prefix = f"/p/{slug}"
url = f"http://{config.CONTAINER_PROXY_HOST}:{port}/{path}"
url = f"http://{host}:{port}/{path}"
headers = _forward_headers(request, prefix)
body = await request.body()
try:
@ -134,11 +124,11 @@ async def proxy_http(request: Request, slug: str, path: str = ""):
@router.websocket("/{slug}")
@router.websocket("/{slug}/{path:path}")
async def proxy_ws(websocket: WebSocket, slug: str, path: str = ""):
instance, port = _resolve(slug)
if instance is None or port is None:
instance, host, port = _resolve(slug)
if instance is None or not host or not port:
await websocket.close(code=1011)
return
upstream_url = f"ws://{config.CONTAINER_PROXY_HOST}:{port}/{path}"
upstream_url = f"ws://{host}:{port}/{path}"
if websocket.url.query:
upstream_url += f"?{websocket.url.query}"
await websocket.accept()

View File

@ -371,20 +371,62 @@ def _http_probe(host: str, port: int, timeout: float = 1.0) -> str:
return f"unreachable: {type(exc).__name__}"
def _ingress_host_port(instance: dict, port_maps: list) -> int:
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:
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 ingress_port
return 0
return int(port_maps[0].get("host") or 0) if port_maps else 0
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
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
host_port = _host_port_for(port_maps, container_port)
if config.CONTAINER_PROXY_HOST:
return (config.CONTAINER_PROXY_HOST, host_port) if host_port else (None, None)
if not host_port:
return None, None
gateway = (instance.get("container_gateway") or "").strip()
return (gateway or "127.0.0.1", host_port)
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
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"
ports = []
for mapping in port_maps:
host_port = int(mapping.get("host") or 0)
@ -393,18 +435,24 @@ def instance_runtime(instance: dict) -> dict:
"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,
"reachable": _port_reachable(probe_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
_http_probe(target_host, target_port)
if target_host and target_port
else "no ingress port mapped"
)
return {
"command": boot or "image CMD (no boot_command set)",
"ports": ports,
"container_ip": container_ip,
"container_gateway": container_gateway,
"ingress_target": (
f"{target_host}:{target_port}" if target_host and target_port else ""
),
"ingress_port": int(instance.get("ingress_port") or 0),
"ingress_serving": ingress_serving,
"status_ok_for_ingress": instance.get("status") == store.ST_RUNNING,

View File

@ -44,6 +44,14 @@ class FakeBackend(Backend):
"exit_code": None,
"image": spec.image,
"spec": spec,
"NetworkSettings": {
"Networks": {
"bridge": {
"IPAddress": f"10.88.0.{self._counter}",
"Gateway": "10.88.0.1",
}
}
},
}
return cid

View File

@ -94,15 +94,18 @@ class ContainerService(BaseService):
if ps is None:
await self._launch(backend, inst)
elif ps.state == "running":
changes = {}
if status != store.ST_RUNNING:
store.update_instance(
uid,
{
"status": store.ST_RUNNING,
"container_id": ps.container_id,
"started_at": inst.get("started_at") or store.now(),
},
)
changes["status"] = store.ST_RUNNING
changes["container_id"] = ps.container_id
changes["started_at"] = inst.get("started_at") or store.now()
ip, gateway = await self._capture_net(backend, ps.container_id)
if ip and ip != (inst.get("container_ip") or ""):
changes["container_ip"] = ip
if gateway and gateway != (inst.get("container_gateway") or ""):
changes["container_gateway"] = gateway
if changes:
store.update_instance(uid, changes)
elif ps.state == "paused":
await backend.unpause(ps.container_id)
store.update_instance(uid, {"status": store.ST_RUNNING})
@ -136,17 +139,30 @@ class ContainerService(BaseService):
)
self.log(f"launch {inst['name']} failed: {exc}")
return
store.update_instance(
inst["uid"],
{
"container_id": cid,
"status": store.ST_RUNNING,
"started_at": store.now(),
},
)
changes = {
"container_id": cid,
"status": store.ST_RUNNING,
"started_at": store.now(),
}
ip, gateway = await self._capture_net(backend, cid)
if ip:
changes["container_ip"] = ip
if gateway:
changes["container_gateway"] = gateway
store.update_instance(inst["uid"], changes)
store.record_event(inst, "start", "reconciler", "")
self.log(f"launched instance {inst['name']}")
async def _capture_net(self, backend, cid: str) -> tuple:
try:
data = await backend.inspect(cid)
except Exception:
return "", ""
return (
api.container_ip_from_inspect(data),
api.container_gateway_from_inspect(data),
)
async def _exit_logs(self, backend, container_id: str) -> str:
lines: list = []

View File

@ -41,6 +41,8 @@ def create_instance(row: dict) -> dict:
"uid": uid,
"slug": make_combined_slug(row.get("name", "instance"), uid),
"container_id": "",
"container_ip": "",
"container_gateway": "",
"status": ST_CREATED,
"exit_code": 0,
"restart_count": 0,

View File

@ -82,9 +82,17 @@ the docker socket (root on the host - trusted admins only), installs the docker
`stat -c '%g' /var/run/docker.sock`). Two docker-in-docker details matter: the data dir must be
bind-mounted at the **same absolute path** on host and in the app container (make sets
`DEVPLACE_DATA_DIR` to the project's own `./var`) because `docker run -v` resolves the source against
the host; and the ingress proxy reaches published ports on the host via
`DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` (a bare-metal `make prod` deploy uses the default
`127.0.0.1`). Remember to run `make ppy` on the production host too. Full steps are in the README
the host; and the ingress proxy reaches a published port through the container's own docker bridge
gateway plus the published host port. The reconciler records each instance's `container_ip` and
`container_gateway` from `docker inspect`, and the `/p/<slug>` proxy dials `gateway:host_port` by
default. This avoids the loopback path, which fails on hosts where docker's `nat OUTPUT` rule excludes
`127.0.0.0/8` from DNAT and no `docker-proxy` binds loopback for a `0.0.0.0`-published port. Dialing
the container's own bridge IP is also avoided because docker's bridge-isolation rule
(`DOCKER ! -i docker0 -o docker0 -j DROP`) can drop direct bridge-IP traffic from the host. Set
`DEVPLACE_CONTAINER_PROXY_HOST` only to override this (for example a containerized app via the docker
socket uses `host.docker.internal`); when set, the proxy dials that host with the published port
instead of the gateway. Before the reconciler has recorded a gateway, the proxy falls back to
`127.0.0.1`. Remember to run `make ppy` on the production host too. Full steps are in the README
"Container Manager wiring".
## Observability, CLI, and Devii