fix the manager
This commit is contained in:
@@ -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"]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
189
manager-solution/manager/config_model.py
Normal file
189
manager-solution/manager/config_model.py
Normal 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)
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
18
manager-solution/manager/validate_config.py
Normal file
18
manager-solution/manager/validate_config.py
Normal 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())
|
||||
Reference in New Issue
Block a user