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).
|
- **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.
|
- **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.
|
- **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`).
|
- **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 `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` (containerized) or `127.0.0.1` (bare-metal). See README "Container Manager wiring".
|
- **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).
|
- **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
|
## 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"
|
CONTAINER_WORKSPACES_DIR = DATA_DIR / "container_workspaces"
|
||||||
# Every container instance runs this one prebuilt image (built once via `make ppy`).
|
# Every container instance runs this one prebuilt image (built once via `make ppy`).
|
||||||
CONTAINER_IMAGE = environ.get("DEVPLACE_CONTAINER_IMAGE", "ppy:latest")
|
CONTAINER_IMAGE = environ.get("DEVPLACE_CONTAINER_IMAGE", "ppy:latest")
|
||||||
# Host the /p/<slug> ingress proxy dials to reach a published container port.
|
# Override host the /p/<slug> ingress proxy dials, with the published host port,
|
||||||
CONTAINER_PROXY_HOST = environ.get("DEVPLACE_CONTAINER_PROXY_HOST", "127.0.0.1")
|
# 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_FILE = BASE_DIR / "notification-private.pem"
|
||||||
VAPID_PRIVATE_KEY_PKCS8_FILE = BASE_DIR / "notification-private.pkcs8.pem"
|
VAPID_PRIVATE_KEY_PKCS8_FILE = BASE_DIR / "notification-private.pkcs8.pem"
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@ -9,8 +8,7 @@ import websockets
|
|||||||
from fastapi import APIRouter, Request, WebSocket
|
from fastapi import APIRouter, Request, WebSocket
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
|
|
||||||
from devplacepy import config
|
from devplacepy.services.containers import api, store
|
||||||
from devplacepy.services.containers import store
|
|
||||||
from devplacepy.utils import not_found
|
from devplacepy.utils import not_found
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -71,31 +69,23 @@ def _rewrite_location(value: str, prefix: str) -> str:
|
|||||||
def _resolve(slug: str):
|
def _resolve(slug: str):
|
||||||
instance = store.find_instance_by_ingress(slug)
|
instance = store.find_instance_by_ingress(slug)
|
||||||
if not instance or instance.get("status") != store.ST_RUNNING:
|
if not instance or instance.get("status") != store.ST_RUNNING:
|
||||||
return None, None
|
return None, None, None
|
||||||
ports = json.loads(instance.get("ports_json") or "[]")
|
host, port = api.proxy_target(instance)
|
||||||
if not ports:
|
return instance, host, port
|
||||||
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"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.api_route("/{slug}", methods=METHODS)
|
@router.api_route("/{slug}", methods=METHODS)
|
||||||
@router.api_route("/{slug}/{path:path}", methods=METHODS)
|
@router.api_route("/{slug}/{path:path}", methods=METHODS)
|
||||||
async def proxy_http(request: Request, slug: str, path: str = ""):
|
async def proxy_http(request: Request, slug: str, path: str = ""):
|
||||||
instance, port = _resolve(slug)
|
instance, host, port = _resolve(slug)
|
||||||
if instance is None:
|
if instance is None:
|
||||||
raise not_found("No running container is published at this address")
|
raise not_found("No running container is published at this address")
|
||||||
if port is None:
|
if not host or not port:
|
||||||
return Response(
|
return Response(
|
||||||
"the published container has no reachable port", status_code=502
|
"the published container has no reachable port", status_code=502
|
||||||
)
|
)
|
||||||
prefix = f"/p/{slug}"
|
prefix = f"/p/{slug}"
|
||||||
url = f"http://{config.CONTAINER_PROXY_HOST}:{port}/{path}"
|
url = f"http://{host}:{port}/{path}"
|
||||||
headers = _forward_headers(request, prefix)
|
headers = _forward_headers(request, prefix)
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
try:
|
try:
|
||||||
@ -134,11 +124,11 @@ async def proxy_http(request: Request, slug: str, path: str = ""):
|
|||||||
@router.websocket("/{slug}")
|
@router.websocket("/{slug}")
|
||||||
@router.websocket("/{slug}/{path:path}")
|
@router.websocket("/{slug}/{path:path}")
|
||||||
async def proxy_ws(websocket: WebSocket, slug: str, path: str = ""):
|
async def proxy_ws(websocket: WebSocket, slug: str, path: str = ""):
|
||||||
instance, port = _resolve(slug)
|
instance, host, port = _resolve(slug)
|
||||||
if instance is None or port is None:
|
if instance is None or not host or not port:
|
||||||
await websocket.close(code=1011)
|
await websocket.close(code=1011)
|
||||||
return
|
return
|
||||||
upstream_url = f"ws://{config.CONTAINER_PROXY_HOST}:{port}/{path}"
|
upstream_url = f"ws://{host}:{port}/{path}"
|
||||||
if websocket.url.query:
|
if websocket.url.query:
|
||||||
upstream_url += f"?{websocket.url.query}"
|
upstream_url += f"?{websocket.url.query}"
|
||||||
await websocket.accept()
|
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__}"
|
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)
|
ingress_port = int(instance.get("ingress_port") or 0)
|
||||||
if ingress_port:
|
if ingress_port:
|
||||||
for mapping in port_maps:
|
for mapping in port_maps:
|
||||||
if int(mapping.get("container") or 0) == ingress_port:
|
if int(mapping.get("container") or 0) == ingress_port:
|
||||||
return int(mapping.get("host") or 0)
|
return ingress_port
|
||||||
return 0
|
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:
|
def instance_runtime(instance: dict) -> dict:
|
||||||
boot = (instance.get("boot_command") or "").strip()
|
boot = (instance.get("boot_command") or "").strip()
|
||||||
port_maps = json.loads(instance.get("ports_json") or "[]")
|
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 = []
|
ports = []
|
||||||
for mapping in port_maps:
|
for mapping in port_maps:
|
||||||
host_port = int(mapping.get("host") or 0)
|
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),
|
"container": int(mapping.get("container") or 0),
|
||||||
"host": host_port,
|
"host": host_port,
|
||||||
"proto": mapping.get("proto", "tcp"),
|
"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 = (
|
ingress_serving = (
|
||||||
_http_probe(host, ingress_host_port)
|
_http_probe(target_host, target_port)
|
||||||
if ingress_host_port
|
if target_host and target_port
|
||||||
else "no ingress port mapped"
|
else "no ingress port mapped"
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"command": boot or "image CMD (no boot_command set)",
|
"command": boot or "image CMD (no boot_command set)",
|
||||||
"ports": ports,
|
"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_port": int(instance.get("ingress_port") or 0),
|
||||||
"ingress_serving": ingress_serving,
|
"ingress_serving": ingress_serving,
|
||||||
"status_ok_for_ingress": instance.get("status") == store.ST_RUNNING,
|
"status_ok_for_ingress": instance.get("status") == store.ST_RUNNING,
|
||||||
|
|||||||
@ -44,6 +44,14 @@ class FakeBackend(Backend):
|
|||||||
"exit_code": None,
|
"exit_code": None,
|
||||||
"image": spec.image,
|
"image": spec.image,
|
||||||
"spec": spec,
|
"spec": spec,
|
||||||
|
"NetworkSettings": {
|
||||||
|
"Networks": {
|
||||||
|
"bridge": {
|
||||||
|
"IPAddress": f"10.88.0.{self._counter}",
|
||||||
|
"Gateway": "10.88.0.1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return cid
|
return cid
|
||||||
|
|
||||||
|
|||||||
@ -94,15 +94,18 @@ class ContainerService(BaseService):
|
|||||||
if ps is None:
|
if ps is None:
|
||||||
await self._launch(backend, inst)
|
await self._launch(backend, inst)
|
||||||
elif ps.state == "running":
|
elif ps.state == "running":
|
||||||
|
changes = {}
|
||||||
if status != store.ST_RUNNING:
|
if status != store.ST_RUNNING:
|
||||||
store.update_instance(
|
changes["status"] = store.ST_RUNNING
|
||||||
uid,
|
changes["container_id"] = ps.container_id
|
||||||
{
|
changes["started_at"] = inst.get("started_at") or store.now()
|
||||||
"status": store.ST_RUNNING,
|
ip, gateway = await self._capture_net(backend, ps.container_id)
|
||||||
"container_id": ps.container_id,
|
if ip and ip != (inst.get("container_ip") or ""):
|
||||||
"started_at": inst.get("started_at") or store.now(),
|
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":
|
elif ps.state == "paused":
|
||||||
await backend.unpause(ps.container_id)
|
await backend.unpause(ps.container_id)
|
||||||
store.update_instance(uid, {"status": store.ST_RUNNING})
|
store.update_instance(uid, {"status": store.ST_RUNNING})
|
||||||
@ -136,17 +139,30 @@ class ContainerService(BaseService):
|
|||||||
)
|
)
|
||||||
self.log(f"launch {inst['name']} failed: {exc}")
|
self.log(f"launch {inst['name']} failed: {exc}")
|
||||||
return
|
return
|
||||||
store.update_instance(
|
changes = {
|
||||||
inst["uid"],
|
"container_id": cid,
|
||||||
{
|
"status": store.ST_RUNNING,
|
||||||
"container_id": cid,
|
"started_at": store.now(),
|
||||||
"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", "")
|
store.record_event(inst, "start", "reconciler", "")
|
||||||
self.log(f"launched instance {inst['name']}")
|
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:
|
async def _exit_logs(self, backend, container_id: str) -> str:
|
||||||
lines: list = []
|
lines: list = []
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,8 @@ def create_instance(row: dict) -> dict:
|
|||||||
"uid": uid,
|
"uid": uid,
|
||||||
"slug": make_combined_slug(row.get("name", "instance"), uid),
|
"slug": make_combined_slug(row.get("name", "instance"), uid),
|
||||||
"container_id": "",
|
"container_id": "",
|
||||||
|
"container_ip": "",
|
||||||
|
"container_gateway": "",
|
||||||
"status": ST_CREATED,
|
"status": ST_CREATED,
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
"restart_count": 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
|
`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
|
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
|
`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
|
the host; and the ingress proxy reaches a published port through the container's own docker bridge
|
||||||
`DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` (a bare-metal `make prod` deploy uses the default
|
gateway plus the published host port. The reconciler records each instance's `container_ip` and
|
||||||
`127.0.0.1`). Remember to run `make ppy` on the production host too. Full steps are in the README
|
`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".
|
"Container Manager wiring".
|
||||||
|
|
||||||
## Observability, CLI, and Devii
|
## Observability, CLI, and Devii
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user