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:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user