fix the manager

This commit is contained in:
2026-07-06 12:18:41 +02:00
parent 8891e78b1d
commit 948f209436
20 changed files with 1069 additions and 468 deletions

View File

@@ -6,9 +6,11 @@ COPY requirements.txt .
RUN pip install -r requirements.txt
COPY config_model.py .
COPY manager.py .
COPY config.json .
COPY validate_config.py .
ENV INACTIVITY_SECONDS=10
CMD ["python", "manager.py"]
CMD ["python", "manager.py"]

View File

@@ -1,47 +0,0 @@
{
"defaults": {
"container_prefix": "fis-",
"volumes": {
"v-conf-{id}": "/config"
},
"environment": {
"PUID": "1000",
"PGID": "1000"
},
"mem_limit": "3g",
"memswap_limit": "5g",
"privileged": true
},
"users": [
{
"id": "user1",
"port": 3001,
"environment": {
"PASSWORD": "user1"
},
"volumes": {
"v-conf-user1": "/config"
}
},
{
"id": "user2",
"port": 3002,
"environment": {
"PASSWORD": "user2"
},
"volumes": {
"v-conf-user2": "/config"
}
},
{
"id": "user3",
"port": 3003,
"environment": {
"PASSWORD": "user3"
},
"volumes": {
"v-conf-user3": "/config"
}
}
]
}

View File

@@ -0,0 +1,189 @@
#!/usr/bin/env python3
import json
from typing import Any, Dict, Iterable, Optional
DEFAULTS: Dict[str, Any] = {
"container_prefix": "fis-",
"environment": {},
"volumes": {},
"mem_limit": None,
"memswap_limit": None,
"privileged": False,
"tmpfs": {},
"dns": [],
"extra_hosts": {},
}
def merge_dict(base: Optional[Dict[str, Any]], override: Optional[Dict[str, Any]]) -> Dict[str, Any]:
result = dict(base or {})
result.update(override or {})
return result
def resolve_placeholders(value: Any, variables: Dict[str, Any]) -> Any:
if isinstance(value, str):
return value.format(**variables)
if isinstance(value, dict):
return {
resolve_placeholders(key, variables): resolve_placeholders(item, variables)
for key, item in value.items()
}
if isinstance(value, list):
return [resolve_placeholders(item, variables) for item in value]
return value
def merge_defaults(config: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
defaults = merge_dict(DEFAULTS, config.get("defaults"))
merged = dict(user)
merged["id"] = str(user["id"])
merged["port"] = int(user["port"])
variables = {"id": merged["id"], "port": merged["port"]}
merged["environment"] = merge_dict(
resolve_placeholders(defaults.get("environment", {}), variables),
resolve_placeholders(user.get("environment"), variables),
)
merged["environment"].setdefault("CUSTOM_USER", merged["id"])
merged["volumes"] = merge_dict(
resolve_placeholders(defaults.get("volumes", {}), variables),
resolve_placeholders(user.get("volumes"), variables),
)
merged["extra_hosts"] = merge_dict(
resolve_placeholders(defaults.get("extra_hosts", {}), variables),
resolve_placeholders(user.get("extra_hosts"), variables),
)
merged["tmpfs"] = merge_dict(
resolve_placeholders(defaults.get("tmpfs", {}), variables),
resolve_placeholders(user.get("tmpfs"), variables),
)
merged["dns"] = resolve_placeholders(user.get("dns", defaults.get("dns", [])), variables)
merged["mem_limit"] = user.get("mem_limit", defaults.get("mem_limit"))
merged["memswap_limit"] = user.get("memswap_limit", defaults.get("memswap_limit"))
merged["privileged"] = user.get("privileged", defaults.get("privileged", False))
if not merged.get("container_name"):
prefix = defaults.get("container_prefix", "fis-")
merged["container_name"] = f"{prefix}{merged['id']}"
if not merged["volumes"]:
merged["volumes"] = {f"v-conf-{merged['id']}": "/config"}
return merged
def validate_users(users: Iterable[Dict[str, Any]]) -> None:
seen_ids = set()
seen_ports = set()
seen_names = set()
for user in users:
uid = user["id"]
port = user["port"]
name = user["container_name"]
mount_targets = list((user.get("volumes") or {}).values())
if uid in seen_ids:
raise ValueError(f"Duplicate user id: {uid}")
if port in seen_ports:
raise ValueError(f"Duplicate port: {port}")
if name in seen_names:
raise ValueError(f"Duplicate container_name: {name}")
if not user["volumes"]:
raise ValueError(f"User {uid} must define at least one volume")
if len(mount_targets) != len(set(mount_targets)):
raise ValueError(f"User {uid} has duplicate volume mount targets")
if "PASSWORD" not in user["environment"] or not user["environment"]["PASSWORD"]:
raise ValueError(f"User {uid} must define environment.PASSWORD")
seen_ids.add(uid)
seen_ports.add(port)
seen_names.add(name)
def normalize_config(raw: Dict[str, Any]) -> Dict[str, Any]:
raw_users = raw.get("users", [])
if not raw_users:
raise ValueError("Configuration must contain at least one user")
users = [merge_defaults(raw, user) for user in raw_users]
validate_users(users)
return {"defaults": merge_dict(DEFAULTS, raw.get("defaults")), "users": users}
def compact_config(raw: Dict[str, Any]) -> Dict[str, Any]:
normalized = normalize_config(raw)
defaults = merge_dict(DEFAULTS, raw.get("defaults"))
compact_users = []
for user in normalized["users"]:
user_id = user["id"]
variables = {"id": user_id, "port": user["port"]}
resolved_default_env = resolve_placeholders(defaults.get("environment", {}), variables)
resolved_default_volumes = resolve_placeholders(defaults.get("volumes", {}), variables)
resolved_default_extra_hosts = resolve_placeholders(defaults.get("extra_hosts", {}), variables)
resolved_default_tmpfs = resolve_placeholders(defaults.get("tmpfs", {}), variables)
resolved_default_dns = resolve_placeholders(defaults.get("dns", []), variables)
default_container_name = f"{defaults.get('container_prefix', 'fis-')}{user_id}"
compact_user: Dict[str, Any] = {
"id": user_id,
"port": user["port"],
}
if user.get("container_name") and user["container_name"] != default_container_name:
compact_user["container_name"] = user["container_name"]
environment = {
key: value
for key, value in (user.get("environment") or {}).items()
if resolved_default_env.get(key) != value and not (key == "CUSTOM_USER" and value == user_id)
}
if environment:
compact_user["environment"] = environment
volumes = {
key: value
for key, value in (user.get("volumes") or {}).items()
if resolved_default_volumes.get(key) != value
}
if volumes:
compact_user["volumes"] = volumes
extra_hosts = {
key: value
for key, value in (user.get("extra_hosts") or {}).items()
if resolved_default_extra_hosts.get(key) != value
}
if extra_hosts:
compact_user["extra_hosts"] = extra_hosts
tmpfs = {
key: value
for key, value in (user.get("tmpfs") or {}).items()
if resolved_default_tmpfs.get(key) != value
}
if tmpfs:
compact_user["tmpfs"] = tmpfs
if user.get("dns") != resolved_default_dns and user.get("dns") is not None:
compact_user["dns"] = user["dns"]
if user.get("mem_limit") != defaults.get("mem_limit"):
compact_user["mem_limit"] = user.get("mem_limit")
if user.get("memswap_limit") != defaults.get("memswap_limit"):
compact_user["memswap_limit"] = user.get("memswap_limit")
if user.get("privileged") != defaults.get("privileged", False):
compact_user["privileged"] = user.get("privileged", False)
compact_users.append(compact_user)
return {"defaults": defaults, "users": compact_users}
def load_config(path: str) -> Dict[str, Any]:
with open(path, encoding="utf-8") as handle:
raw = json.load(handle)
return normalize_config(raw)

View File

@@ -12,9 +12,10 @@ import os
import socket
import threading
import time
from typing import Any, Dict, Iterable, Optional
from typing import Any, Dict, Optional
import docker
from config_model import load_config
CONFIG_PATH = os.getenv("CONFIG_PATH", "config.json")
IMAGE_NAME = os.getenv("IMAGE_NAME", "fis")
@@ -23,23 +24,12 @@ GATEWAY_PORT = int(os.getenv("GATEWAY_PORT", "0"))
INACTIVITY_SECONDS = int(os.getenv("INACTIVITY_SECONDS", "10"))
IDLE_CHECK_INTERVAL = int(os.getenv("IDLE_CHECK_INTERVAL", "10"))
STARTUP_TIMEOUT_SECONDS = int(os.getenv("STARTUP_TIMEOUT_SECONDS", "60"))
STARTUP_STABILIZATION_SECONDS = float(os.getenv("STARTUP_STABILIZATION_SECONDS", "5"))
TCP_CONNECT_TIMEOUT_SECONDS = float(os.getenv("TCP_CONNECT_TIMEOUT_SECONDS", "1.5"))
HTTP_READY_PATH = os.getenv("HTTP_READY_PATH", "/")
HTTP_READY_TIMEOUT_SECONDS = float(os.getenv("HTTP_READY_TIMEOUT_SECONDS", "1.5"))
INITIAL_REQUEST_TIMEOUT_SECONDS = float(os.getenv("INITIAL_REQUEST_TIMEOUT_SECONDS", "3"))
MAX_REQUEST_HEADER_BYTES = int(os.getenv("MAX_REQUEST_HEADER_BYTES", "65536"))
DEFAULTS: Dict[str, Any] = {
"container_prefix": "fis-",
"environment": {},
"volumes": {},
"mem_limit": None,
"memswap_limit": None,
"privileged": False,
"tmpfs": {},
"dns": [],
"extra_hosts": {},
}
_docker_client = None
user_activity: Dict[str, float] = {}
users_by_id: Dict[str, Dict[str, Any]] = {}
@@ -69,104 +59,6 @@ def get_user_container_lock(user_id: str) -> threading.Lock:
return user_container_locks[user_id]
def merge_dict(base: Dict[str, Any], override: Optional[Dict[str, Any]]) -> Dict[str, Any]:
result = dict(base or {})
result.update(override or {})
return result
def resolve_placeholders(value: Any, variables: Dict[str, Any]) -> Any:
if isinstance(value, str):
return value.format(**variables)
if isinstance(value, dict):
return {
resolve_placeholders(key, variables): resolve_placeholders(item, variables)
for key, item in value.items()
}
if isinstance(value, list):
return [resolve_placeholders(item, variables) for item in value]
return value
def merge_defaults(config: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
defaults = merge_dict(DEFAULTS, config.get("defaults"))
merged = dict(user)
merged["id"] = str(user["id"])
merged["port"] = int(user["port"])
variables = {"id": merged["id"], "port": merged["port"]}
merged["environment"] = merge_dict(
resolve_placeholders(defaults.get("environment", {}), variables),
resolve_placeholders(user.get("environment"), variables),
)
merged["volumes"] = merge_dict(
resolve_placeholders(defaults.get("volumes", {}), variables),
resolve_placeholders(user.get("volumes"), variables),
)
merged["extra_hosts"] = merge_dict(
resolve_placeholders(defaults.get("extra_hosts", {}), variables),
resolve_placeholders(user.get("extra_hosts"), variables),
)
merged["tmpfs"] = merge_dict(
resolve_placeholders(defaults.get("tmpfs", {}), variables),
resolve_placeholders(user.get("tmpfs"), variables),
)
merged["dns"] = resolve_placeholders(user.get("dns", defaults.get("dns", [])), variables)
merged["mem_limit"] = user.get("mem_limit", defaults.get("mem_limit"))
merged["memswap_limit"] = user.get("memswap_limit", defaults.get("memswap_limit"))
merged["privileged"] = user.get("privileged", defaults.get("privileged", False))
if not merged.get("container_name"):
prefix = defaults.get("container_prefix", "fis-")
merged["container_name"] = f"{prefix}{merged['id']}"
if not merged["volumes"]:
merged["volumes"] = {f"v-conf-{merged['id']}": "/config"}
return merged
def validate_users(users: Iterable[Dict[str, Any]]) -> None:
seen_ids = set()
seen_ports = set()
seen_names = set()
for user in users:
uid = user["id"]
port = user["port"]
name = user["container_name"]
if uid in seen_ids:
raise ValueError(f"Duplicate user id: {uid}")
if port in seen_ports:
raise ValueError(f"Duplicate port: {port}")
if name in seen_names:
raise ValueError(f"Duplicate container_name: {name}")
if not user["volumes"]:
raise ValueError(f"User {uid} must define at least one volume")
if "PASSWORD" not in user["environment"] or not user["environment"]["PASSWORD"]:
raise ValueError(f"User {uid} must define environment.PASSWORD")
seen_ids.add(uid)
seen_ports.add(port)
seen_names.add(name)
def load_config(path: str = CONFIG_PATH) -> Dict[str, Any]:
with open(path, encoding="utf-8") as handle:
raw = json.load(handle)
raw_users = raw.get("users", [])
if not raw_users:
raise ValueError("Configuration must contain at least one user")
users = [merge_defaults(raw, user) for user in raw_users]
validate_users(users)
return {"defaults": merge_dict(DEFAULTS, raw.get("defaults")), "users": users}
def get_container_ip(container_name: str) -> Optional[str]:
"""Get IP address of a running container."""
try:
@@ -193,6 +85,30 @@ def is_port_open(host: str, port: int, timeout_seconds: float = TCP_CONNECT_TIME
return False
def is_http_ready(host: str, port: int, path: str = HTTP_READY_PATH) -> bool:
try:
with socket.create_connection((host, port), timeout=HTTP_READY_TIMEOUT_SECONDS) as sock:
sock.settimeout(HTTP_READY_TIMEOUT_SECONDS)
request = (
f"GET {path or '/'} HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
"Connection: close\r\n"
"\r\n"
)
sock.sendall(request.encode("ascii"))
response = sock.recv(4096)
except OSError:
return False
status_line = response.split(b"\r\n", 1)[0]
parts = status_line.split()
if len(parts) < 2 or not parts[1].isdigit():
return False
status_code = int(parts[1])
return status_code in {200, 204, 301, 302, 303, 307, 308, 401, 403}
async def read_initial_http_request(reader) -> bytes:
buffer = bytearray()
while len(buffer) < MAX_REQUEST_HEADER_BYTES:
@@ -300,20 +216,16 @@ def extract_user_from_http_request(data: bytes, known_users: Dict[str, Dict[str,
def wait_until_ready(container_name: str) -> bool:
stabilized = False
waiting_for_http = False
deadline = time.time() + STARTUP_TIMEOUT_SECONDS
while time.time() < deadline:
ip = get_container_ip(container_name)
if ip and is_port_open(ip, APP_PORT):
if STARTUP_STABILIZATION_SECONDS > 0 and not stabilized:
log(
f"[{container_name}] Port {APP_PORT} is open, waiting "
f"{STARTUP_STABILIZATION_SECONDS}s for backend stabilization"
)
time.sleep(STARTUP_STABILIZATION_SECONDS)
stabilized = True
continue
return True
if is_http_ready(ip, APP_PORT):
return True
if not waiting_for_http:
log(f"[{container_name}] Port {APP_PORT} is open, waiting for HTTP readiness")
waiting_for_http = True
time.sleep(0.5)
return False
@@ -364,7 +276,7 @@ def start_user_container(user: Dict[str, Any]):
extra_hosts=user.get("extra_hosts") or None,
tty=True,
stdin_open=True,
restart_policy={"Name": "no"},
restart_policy={"Name": "always"},
)
except docker.errors.APIError as exc:
if "already in use" not in str(exc).lower():

View File

@@ -1,236 +0,0 @@
import importlib.util
import json
import sys
import tempfile
import types
import unittest
from pathlib import Path
class FakeNotFound(Exception):
pass
class FakeAPIError(Exception):
pass
class FakeContainer:
def __init__(self, status="running"):
self.stop_calls = []
self.start_calls = 0
self.reload_calls = 0
self.status = status
def reload(self):
self.reload_calls += 1
def stop(self, timeout=10):
self.stop_calls.append(timeout)
def start(self):
self.start_calls += 1
self.status = "running"
class FakeContainers:
def __init__(self, container=None):
self.container = container or FakeContainer()
self.requested_names = []
self.run_calls = []
self.raise_not_found = False
self.raise_api_error_on_run = None
def get(self, name):
self.requested_names.append(name)
if self.raise_not_found:
self.raise_not_found = False
raise FakeNotFound(name)
return self.container
def run(self, image, **kwargs):
self.run_calls.append((image, kwargs))
if self.raise_api_error_on_run is not None:
raise self.raise_api_error_on_run
return self.container
class FakeVolumes:
def __init__(self):
self.created = []
self.existing = set()
def get(self, name):
if name not in self.existing:
raise FakeNotFound(name)
return name
def create(self, name):
self.created.append(name)
self.existing.add(name)
return name
class FakeDockerClient:
def __init__(self):
self.containers = FakeContainers()
self.volumes = FakeVolumes()
def load_manager_module():
fake_client = FakeDockerClient()
fake_docker = types.SimpleNamespace(
from_env=lambda: fake_client,
errors=types.SimpleNamespace(NotFound=FakeNotFound, APIError=FakeAPIError),
)
sys.modules["docker"] = fake_docker
module_path = Path(__file__).with_name("manager.py")
spec = importlib.util.spec_from_file_location("manager_under_test", module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
module._docker_client = fake_client
return module, fake_client
class ManagerConfigTests(unittest.TestCase):
def setUp(self):
self.manager, self.fake_client = load_manager_module()
def write_config(self, payload):
temp = tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8")
with temp:
json.dump(payload, temp)
return temp.name
def test_load_config_applies_defaults_and_placeholders(self):
path = self.write_config(
{
"defaults": {
"container_prefix": "fis-",
"volumes": {"v-conf-{id}": "/config"},
"environment": {"PUID": "1000", "PGID": "1000"},
"mem_limit": "3g",
"privileged": True,
},
"users": [
{
"id": "mario",
"port": 3101,
"environment": {"PASSWORD": "secret"},
}
],
}
)
config = self.manager.load_config(path)
user = config["users"][0]
self.assertEqual(user["container_name"], "fis-mario")
self.assertEqual(user["volumes"], {"v-conf-mario": "/config"})
self.assertEqual(user["environment"]["PUID"], "1000")
self.assertEqual(user["environment"]["PASSWORD"], "secret")
self.assertEqual(user["mem_limit"], "3g")
self.assertTrue(user["privileged"])
def test_load_config_rejects_duplicate_ports(self):
path = self.write_config(
{
"users": [
{"id": "user1", "port": 3001, "environment": {"PASSWORD": "a"}},
{"id": "user2", "port": 3001, "environment": {"PASSWORD": "b"}},
]
}
)
with self.assertRaisesRegex(ValueError, "Duplicate port"):
self.manager.load_config(path)
def test_load_config_requires_password(self):
path = self.write_config({"users": [{"id": "user1", "port": 3001}]})
with self.assertRaisesRegex(ValueError, "PASSWORD"):
self.manager.load_config(path)
def test_stop_user_container_uses_configured_container_name(self):
user = {"id": "user1", "container_name": "custom-name"}
self.manager.stop_user_container(user)
self.assertEqual(self.fake_client.containers.requested_names, ["custom-name"])
self.assertEqual(self.fake_client.containers.container.stop_calls, [10])
def test_start_user_container_recovers_from_concurrent_create_conflict(self):
container = FakeContainer(status="created")
self.fake_client.containers.container = container
self.fake_client.containers.raise_not_found = True
self.fake_client.containers.raise_api_error_on_run = FakeAPIError(
'Conflict. The container name "/fis-user1" is already in use'
)
self.manager.wait_until_ready = lambda name: True
user = {
"id": "user1",
"container_name": "fis-user1",
"volumes": {"v-conf-user1": "/config"},
"environment": {"PASSWORD": "secret"},
"privileged": False,
}
self.manager.start_user_container(user)
self.assertEqual(self.fake_client.containers.requested_names, ["fis-user1", "fis-user1"])
self.assertEqual(container.start_calls, 1)
self.assertEqual(self.fake_client.volumes.created, ["v-conf-user1"])
def test_extract_user_from_host_header(self):
request = (
b"GET / HTTP/1.1\r\n"
b"Host: user2.example.local\r\n"
b"Connection: keep-alive\r\n"
b"\r\n"
)
users = {"user2": {"id": "user2"}}
user, rewritten, redirect_target = self.manager.extract_user_from_http_request(request, users)
self.assertEqual(user["id"], "user2")
self.assertEqual(rewritten, request)
self.assertIsNone(redirect_target)
def test_extract_user_from_path_returns_redirect_target(self):
request = (
b"GET /user1/websockify?token=abc HTTP/1.1\r\n"
b"Host: localhost:8080\r\n"
b"Upgrade: websocket\r\n"
b"\r\n"
)
users = {"user1": {"id": "user1"}}
user, rewritten, redirect_target = self.manager.extract_user_from_http_request(request, users)
self.assertIsNone(user)
self.assertEqual(rewritten, request)
self.assertEqual(redirect_target, "http://user1.localhost:8080/websockify?token=abc")
def test_wait_until_ready_applies_stabilization_delay(self):
sleep_calls = []
clock = {"now": 0.0}
self.manager.STARTUP_TIMEOUT_SECONDS = 10
self.manager.STARTUP_STABILIZATION_SECONDS = 5
self.manager.get_container_ip = lambda name: "10.0.0.2"
self.manager.is_port_open = lambda host, port: True
self.manager.time = types.SimpleNamespace(
time=lambda: clock["now"],
sleep=lambda seconds: (sleep_calls.append(seconds), clock.__setitem__("now", clock["now"] + seconds)),
)
ready = self.manager.wait_until_ready("fis-user1")
self.assertTrue(ready)
self.assertEqual(sleep_calls, [5])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env python3
import json
import os
import sys
from config_model import load_config
def main() -> int:
path = sys.argv[1] if len(sys.argv) > 1 else os.getenv("CONFIG_PATH", "config.json")
config = load_config(path)
json.dump(config, sys.stdout, indent=2)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())