feat: implement working ingress configuration and rename Pravda references in tests

Updated ingress setup to ensure proper routing and functionality. Renamed all occurrences of "Pravda" to the new designated name across test files and configuration, aligning with the latest naming convention.
This commit is contained in:
2026-06-10 07:11:56 +00:00
parent b8bc49b863
commit 743b1afdee
9 changed files with 129 additions and 52 deletions
+7 -2
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"
+10 -20
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()
+56 -8
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,
@@ -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
+32 -16
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 = []
+2
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,
@@ -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