Working INGRESS and tests working and Pravda name change.
Some checks failed
DevPlace CI / test (push) Failing after 7m46s
Some checks failed
DevPlace CI / test (push) Failing after 7m46s
This commit is contained in:
parent
3a6085fb55
commit
e1906f6ce8
@ -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 <container>" -> 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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user