Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions DOCKERFILE
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ ENV FUNCNODES_WORKER_MANAGER_PORT=9380
ENV FUNCNODES_HOST=0.0.0.0
ENV FUNCNODES_WS_WORKER_STARTPORT=9382

# Docker startup mode. Empty/default starts the normal UI + Workermanager setup.
# Set FUNCNODES_DOCKER_MODE=single-worker to start one persistent worker and a
# frontend connected directly to it, without a Workermanager.
ENV FUNCNODES_DOCKER_MODE=

# Defaults for the Docker-only single-worker mode. The fixed UUID keeps the
# same worker config across container restarts when FUNCNODES_CONFIG_DIR is
# mounted as a volume.
ENV FUNCNODES_SINGLE_WORKER_UUID=00000000000000000000000000000001
ENV FUNCNODES_SINGLE_WORKER_NAME=docker-single-worker
ENV FUNCNODES_SINGLE_WORKER_HOST=0.0.0.0
ENV FUNCNODES_SINGLE_WORKER_PORT=9382

# Optional browser-facing worker host for reverse proxy deployments. Leave empty
# for normal local Docker use; the frontend server can infer the request host
# for local/container bind hosts such as 0.0.0.0.
ENV FUNCNODES_SINGLE_WORKER_PUBLIC_HOST=

# Optional space-separated package list, for example:
# FUNCNODES_UPDATE_PACKAGES="funcnodes-react-flow funcnodes-worker"
# Leave empty for reproducible startup without network-dependent upgrades.
Expand Down
6 changes: 6 additions & 0 deletions docs/content/examples/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,11 @@ services:
FUNCNODES_HOST: "0.0.0.0"
FUNCNODES_WS_WORKER_STARTPORT: "9382"
FUNCNODES_UPDATE_PACKAGES: ""
# Uncomment these values to run one worker and the frontend without a
# Workermanager. In that mode, runserver attaches to the worker by UUID,
# port 9380 is not needed, and 9382 is the single worker websocket port.
# FUNCNODES_DOCKER_MODE: "single-worker"
# FUNCNODES_SINGLE_WORKER_HOST: "0.0.0.0"
# FUNCNODES_SINGLE_WORKER_PORT: "9382"
volumes:
- ./funcnodes_config:/usr/local/app/.funcnodes
56 changes: 55 additions & 1 deletion docs/content/getting-started/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ FuncNodes publishes Docker images to GitHub Container Registry:
ghcr.io/linkdlab/funcnodes
```

Use Docker when you want to run the FuncNodes web UI and worker manager without installing Python packages on the host.
Use Docker when you want to run FuncNodes without installing Python packages on the host. The image supports two runtime modes:

- **Manager mode**: starts the web UI and uses a Workermanager. This is the default.
- **Single-worker mode**: starts one persistent worker and a web UI connected directly to it, without a Workermanager.

## Pull the Image

Expand Down Expand Up @@ -56,6 +59,37 @@ http://localhost:8000

The worker manager listens on port `9380`. Worker websocket ports use the exposed range `9382-9482`.

## Single-Worker Mode

Use single-worker mode when you want one empty worker and the web frontend without a Workermanager:

```bash
docker run --rm \
-p 8000:8000 \
-p 9382:9382 \
-e FUNCNODES_DOCKER_MODE=single-worker \
-e FUNCNODES_RUNSERVER_HOST=0.0.0.0 \
-e FUNCNODES_RUNSERVER_PORT=8000 \
-e FUNCNODES_SINGLE_WORKER_HOST=0.0.0.0 \
-e FUNCNODES_SINGLE_WORKER_PORT=9382 \
-v ./funcnodes_config:/usr/local/app/.funcnodes \
ghcr.io/linkdlab/funcnodes:latest
```

The container creates the worker once and reuses it on later starts through the mounted `funcnodes_config` volume. Startup uses `funcnodes runserver --no-manager --worker-uuid ...`: if the worker is already running, the frontend attaches to its existing port; otherwise the server starts it on `FUNCNODES_SINGLE_WORKER_PORT` and stops it again when the server exits.

Optional single-worker settings:

| Environment variable | Default | Description |
| -------------------- | ------- | ----------- |
| `FUNCNODES_SINGLE_WORKER_UUID` | `00000000000000000000000000000001` | Stable worker id used for the persisted worker config. |
| `FUNCNODES_SINGLE_WORKER_NAME` | `docker-single-worker` | Display name for the worker. |
| `FUNCNODES_SINGLE_WORKER_HOST` | `0.0.0.0` | Bind host inside the container. |
| `FUNCNODES_SINGLE_WORKER_PORT` | `9382` | Worker websocket port. |
| `FUNCNODES_SINGLE_WORKER_PUBLIC_HOST` | empty | Browser-facing worker host for reverse proxies or remote deployments. |

For local Docker use, leave `FUNCNODES_SINGLE_WORKER_PUBLIC_HOST` empty. Set it only when the browser must connect to a public hostname that differs from the HTTP request host.

## Run with Docker Compose

Use this `docker-compose.yaml`:
Expand All @@ -80,6 +114,26 @@ services:
- ./funcnodes_config:/usr/local/app/.funcnodes
```

For single-worker mode, remove the worker-manager port and expose one worker port:

```yaml
services:
funcnodes:
image: ghcr.io/linkdlab/funcnodes:latest
ports:
- "8000:8000"
- "9382:9382"
environment:
FUNCNODES_DOCKER_MODE: "single-worker"
FUNCNODES_RUNSERVER_HOST: "0.0.0.0"
FUNCNODES_RUNSERVER_PORT: "8000"
FUNCNODES_SINGLE_WORKER_HOST: "0.0.0.0"
FUNCNODES_SINGLE_WORKER_PORT: "9382"
FUNCNODES_UPDATE_PACKAGES: ""
volumes:
- ./funcnodes_config:/usr/local/app/.funcnodes
```

Start the service:

```bash
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "funcnodes"
version = "1.7.1"
version = "1.7.2a0"
description = "funcnodes"

authors = [{name = "Julian Kimmig", email = "[email protected]"}]
Expand Down
33 changes: 33 additions & 0 deletions scripts/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,35 @@ if [ -n "${FUNCNODES_UPDATE_PACKAGES:-}" ]; then
set +f
fi

run_single_worker() {
worker_config="${FUNCNODES_CONFIG_DIR}/workers/worker_${FUNCNODES_SINGLE_WORKER_UUID}.json"
worker_public_host="${FUNCNODES_SINGLE_WORKER_PUBLIC_HOST:-${FUNCNODES_SINGLE_WORKER_HOST}}"

# Create the fixed worker once. `--not-in-venv` keeps the worker in the
# container environment, which is already isolated by Docker and can be
# updated at startup via FUNCNODES_UPDATE_PACKAGES.
if [ ! -f "$worker_config" ]; then
funcnodes worker --uuid "${FUNCNODES_SINGLE_WORKER_UUID}" \
--name "${FUNCNODES_SINGLE_WORKER_NAME}" \
new --create-only --not-in-venv \
--host "${FUNCNODES_SINGLE_WORKER_HOST}" \
--port "${FUNCNODES_SINGLE_WORKER_PORT}"
fi

# Serve the React Flow frontend without Workermanager discovery. `runserver`
# attaches to the configured worker if it is already running; otherwise it
# starts it with the configured port and stops it during server shutdown.
exec funcnodes runserver \
--host "${FUNCNODES_RUNSERVER_HOST}" \
--port "${FUNCNODES_RUNSERVER_PORT}" \
--no-browser \
--no-manager \
--worker-uuid "${FUNCNODES_SINGLE_WORKER_UUID}" \
--worker_host "$worker_public_host" \
--worker_port "${FUNCNODES_SINGLE_WORKER_PORT}" \
"$@"
}

# Default command path.
#
# `docker run image` and `docker run image runserver` both start the FuncNodes
Expand All @@ -33,6 +62,10 @@ if [ "$#" -eq 0 ] || [ "$1" = "runserver" ]; then
shift
fi

if [ "${FUNCNODES_DOCKER_MODE:-manager}" = "single-worker" ]; then
run_single_worker "$@"
fi

exec funcnodes runserver \
--host "${FUNCNODES_RUNSERVER_HOST}" \
--port "${FUNCNODES_RUNSERVER_PORT}" \
Expand Down
19 changes: 19 additions & 0 deletions src/funcnodes/cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ def add_runserver_parser(subparsers):
type=int,
help="The port to run the worker on",
)
parser.add_argument(
"--worker-uuid",
default=None,
help=(
"Existing worker UUID to use in --no-manager mode. If the worker is "
"not running, runserver starts it and stops it on shutdown."
),
)
parser.add_argument(
"--worker_ssl",
action="store_true",
Expand Down Expand Up @@ -220,6 +228,17 @@ def add_worker_parser(subparsers):
new_worker_parser.add_argument(
"--not-in-venv", action="store_false", dest="in_venv", help="Do not use a venv"
)
new_worker_parser.add_argument(
"--host",
default=None,
help="The host to bind the new worker to",
)
new_worker_parser.add_argument(
"--port",
default=None,
type=int,
help="The port to bind the new worker to",
)
autostart_group = new_worker_parser.add_mutually_exclusive_group()
autostart_group.add_argument(
"--autostart",
Expand Down
133 changes: 120 additions & 13 deletions src/funcnodes/cli/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import asyncio
import textwrap
import threading
import time
from typing import Optional

from pathlib import Path

import funcnodes as fn
from funcnodes_core.utils.files import write_json_secure

from .utils import parse_command_kwargs
from .worker import (
Expand All @@ -29,6 +31,83 @@
# =============================================================================


def _get_runserver_worker_config(worker_uuid: str, debug: bool):
"""Return the manager and worker config for a direct runserver worker."""
manager = fn.worker.worker_manager.WorkerManager(debug=debug)
for worker_config in manager.get_all_workercfg():
if worker_config["uuid"] == worker_uuid:
return manager, worker_config

raise ValueError(f"No worker found with uuid {worker_uuid!r}")


def _write_runserver_worker_config(manager, worker_config):
"""Persist direct worker host/port changes before starting the worker."""
worker_config_file = (
Path(manager.worker_dir) / f"worker_{worker_config['uuid']}.json"
)
write_json_secure(worker_config, worker_config_file, indent=2)


def _check_runserver_worker(worker_config) -> bool:
"""Check whether the configured worker websocket is reachable."""
_, is_running = asyncio.run(fn.worker.worker_manager.check_worker(worker_config))
return is_running


def _wait_for_runserver_worker(worker_uuid: str, debug: bool, timeout: float = 30.0):
"""Wait for a worker started by runserver to publish a reachable endpoint."""
deadline = time.time() + timeout

while time.time() < deadline:
_, worker_config = _get_runserver_worker_config(worker_uuid, debug=debug)
if _check_runserver_worker(worker_config):
return worker_config
time.sleep(0.5)

raise TimeoutError(f"Worker {worker_uuid!r} did not become reachable")


def _stop_runserver_worker(worker_uuid: str, debug: bool):
"""Stop a worker that was started for a direct runserver session."""
manager = fn.worker.worker_manager.WorkerManager(debug=debug)
asyncio.run(manager.stop_worker(worker_uuid))


def _prepare_direct_worker_for_runserver(args: argparse.Namespace):
"""Attach to or start the worker requested by runserver --worker-uuid."""
worker_uuid = getattr(args, "worker_uuid", None)
if not worker_uuid:
return None, False

if getattr(args, "no_manager", True):
raise ValueError("--worker-uuid requires --no-manager")

debug = getattr(args, "debug", False)
manager, worker_config = _get_runserver_worker_config(worker_uuid, debug=debug)

if _check_runserver_worker(worker_config):
return worker_config, False

worker_config["host"] = (
worker_config.get("host") or getattr(args, "worker_host", None) or "localhost"
)
worker_config["port"] = getattr(args, "worker_port", None) or worker_config.get(
"port", 9380
)
worker_config["ssl"] = getattr(args, "worker_ssl", False)
worker_config.pop("pid", None)
_write_runserver_worker_config(manager, worker_config)

fn.worker.worker_manager.start_worker(worker_config, debug=debug)
try:
worker_config = _wait_for_runserver_worker(worker_uuid, debug=debug)
except Exception:
_stop_runserver_worker(worker_uuid, debug=debug)
raise
return worker_config, True


def task_run_server(args: argparse.Namespace):
"""Run the FuncNodes server with the specified frontend."""
frontend = args.frontend
Expand All @@ -37,21 +116,47 @@ def task_run_server(args: argparse.Namespace):
else:
raise Exception(f"Unknown frontend: {frontend}")

run_server(
port=args.port,
host=args.host,
open_browser=args.no_browser,
worker_manager_host=args.worker_manager_host,
worker_manager_port=args.worker_manager_port,
worker_manager_ssl=args.worker_manager_ssl,
start_worker_manager=args.no_manager,
has_worker_manager=args.no_manager,
worker_host=args.worker_host,
worker_port=args.worker_port,
worker_ssl=args.worker_ssl,
debug=args.debug,
direct_worker_config, started_direct_worker = _prepare_direct_worker_for_runserver(
args
)

worker_host = args.worker_host
worker_port = args.worker_port
worker_ssl = args.worker_ssl
shutdown_handler_callback = None

if direct_worker_config is not None:
worker_host = direct_worker_config.get("host")
worker_port = direct_worker_config.get("port")
worker_ssl = direct_worker_config.get("ssl", False)
if args.worker_host and args.worker_host != "localhost":
worker_host = args.worker_host

def _direct_worker_shutdown_handler(handler):
return handler

shutdown_handler_callback = _direct_worker_shutdown_handler

try:
run_server(
port=args.port,
host=args.host,
open_browser=args.no_browser,
worker_manager_host=args.worker_manager_host,
worker_manager_port=args.worker_manager_port,
worker_manager_ssl=args.worker_manager_ssl,
start_worker_manager=args.no_manager,
has_worker_manager=args.no_manager,
worker_host=worker_host,
worker_port=worker_port,
worker_ssl=worker_ssl,
debug=args.debug,
register_shutdown_handler=shutdown_handler_callback,
)
finally:
if started_direct_worker and direct_worker_config is not None:
_stop_runserver_worker(direct_worker_config["uuid"], debug=args.debug)


def task_standalone(args: argparse.Namespace):
"""Run a standalone .fnw file with its own worker."""
Expand Down Expand Up @@ -156,6 +261,8 @@ def task_worker(args: argparse.Namespace):
create_only=args.create_only,
profile=args.profile,
autostart_policy=args.autostart_policy,
host=getattr(args, "host", None),
port=getattr(args, "port", None),
)
elif workertask == "list":
return list_workers(args)
Expand Down
Loading
Loading