diff --git a/Dockerfile b/Dockerfile index a583731..8d97cc3 100755 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,11 @@ RUN mkdir -p /app /defaults \ RUN ln -snf /usr/share/zoneinfo/Europe/Rome /etc/localtime && \ echo "Europe/Rome" > /etc/timezone +# The base image may include Docker's apt repository, but this image does not +# install Docker packages. Disable it so apt updates do not fail on stale keys. +RUN rm -f /etc/apt/sources.list.d/docker.list \ + /etc/apt/sources.list.d/download_docker_com_linux_debian.list + RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ firefox-esr gnumeric xdg-utils \ @@ -255,4 +260,4 @@ RUN chmod +x /entrypoint.sh # Set entrypoint and default cmd ENTRYPOINT ["/entrypoint.sh"] -CMD ["/init"] \ No newline at end of file +CMD ["/init"] diff --git a/configure.sh b/configure.sh new file mode 100755 index 0000000..3668ae2 --- /dev/null +++ b/configure.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "${ROOT_DIR}/manager-solution" +exec python3 configurator_server.py diff --git a/docker-run-commands.sh b/docker-run-commands.sh new file mode 100755 index 0000000..ec3a634 --- /dev/null +++ b/docker-run-commands.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -euo pipefail + +# Generated by ./configure.sh from manager-solution/manager/config.json. +# These commands recreate/start the Fiscality user containers directly with Docker. +# The manager normally proxies user ports; the first command block matches the manager +# container creation and does not publish ports. + +# User: mario - manager-equivalent container +docker volume create v-conf-mario >/dev/null +docker run -d \ + --name fis-mario \ + --restart always \ + --volume v-conf-mario:/config:rw \ + --env PUID=1000 \ + --env PGID=1000 \ + --env PASSWORD=mario \ + --env CUSTOM_USER=mario \ + --memory 3g \ + --memory-swap 5g \ + --privileged \ + --tty \ + --interactive \ + fis + +# Emergency direct access for mario on host port 3001: +# docker rm -f fis-mario || true +# docker run -d \ +# --name fis-mario \ +# --restart always \ +# -p 3001:3000 \ +# --volume v-conf-mario:/config:rw \ +# --env PUID=1000 \ +# --env PGID=1000 \ +# --env PASSWORD=mario \ +# --env CUSTOM_USER=mario \ +# --memory 3g \ +# --memory-swap 5g \ +# --privileged \ +# --tty \ +# --interactive \ +# fis diff --git a/install.sh b/install.sh index f213f15..7fd5572 100755 --- a/install.sh +++ b/install.sh @@ -21,6 +21,9 @@ if [[ ! -f "${MANAGER_DIR}/manager/config.json" ]]; then exit 1 fi +echo "Validating manager config" +python3 "${MANAGER_DIR}/manager/validate_config.py" "${MANAGER_DIR}/manager/config.json" >/dev/null + ensure_image_exists() { local image_name="$1" if ! docker image inspect "${image_name}" >/dev/null 2>&1; then diff --git a/manager-solution/__pycache__/configurator_server.cpython-313.pyc b/manager-solution/__pycache__/configurator_server.cpython-313.pyc new file mode 100644 index 0000000..614f0c2 Binary files /dev/null and b/manager-solution/__pycache__/configurator_server.cpython-313.pyc differ diff --git a/manager-solution/configurator_server.py b/manager-solution/configurator_server.py new file mode 100644 index 0000000..1692086 --- /dev/null +++ b/manager-solution/configurator_server.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +import json +import os +import shlex +import socket +import subprocess +import sys +from http import HTTPStatus +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parent +PROJECT_DIR = ROOT_DIR.parent +DASHBOARD_DIR = ROOT_DIR / "dashboard" +CONFIG_PATH = ROOT_DIR / "manager" / "config.json" +MANAGER_DIR = ROOT_DIR / "manager" +RECOVERY_COMMANDS_PATH = PROJECT_DIR / "docker-run-commands.sh" + +sys.path.insert(0, str(MANAGER_DIR)) + +from config_model import compact_config, normalize_config # noqa: E402 + + +def shell_quote(value): + return shlex.quote(str(value)) + + +def append_option(lines, option, value=None): + if value is None or value == "": + return + if value is True: + lines.append(f" {option} \\") + else: + lines.append(f" {option} {shell_quote(value)} \\") + + +def build_docker_run_command(user, image_name, publish_port=False): + lines = [ + "docker run -d \\", + f" --name {shell_quote(user['container_name'])} \\", + " --restart always \\", + ] + + if publish_port: + lines.append(f" -p {int(user['port'])}:3000 \\") + + for volume_name, mount_path in (user.get("volumes") or {}).items(): + lines.append(f" --volume {shell_quote(f'{volume_name}:{mount_path}:rw')} \\") + + for key, value in (user.get("environment") or {}).items(): + lines.append(f" --env {shell_quote(f'{key}={value}')} \\") + + append_option(lines, "--memory", user.get("mem_limit")) + append_option(lines, "--memory-swap", user.get("memswap_limit")) + + if user.get("privileged"): + lines.append(" --privileged \\") + + for target, options in (user.get("tmpfs") or {}).items(): + tmpfs_value = target if not options else f"{target}:{options}" + lines.append(f" --tmpfs {shell_quote(tmpfs_value)} \\") + + for dns_server in user.get("dns") or []: + lines.append(f" --dns {shell_quote(dns_server)} \\") + + for host, address in (user.get("extra_hosts") or {}).items(): + lines.append(f" --add-host {shell_quote(f'{host}:{address}')} \\") + + lines.extend([ + " --tty \\", + " --interactive \\", + f" {shell_quote(image_name)}", + ]) + return "\n".join(lines) + + +def write_recovery_commands(config): + image_name = os.getenv("FIS_APP_IMAGE") or os.getenv("APP_IMAGE_NAME") or "fis" + normalized = normalize_config(config) + lines = [ + "#!/bin/bash", + "set -euo pipefail", + "", + "# Generated by ./configure.sh from manager-solution/manager/config.json.", + "# These commands recreate/start the Fiscality user containers directly with Docker.", + "# The manager normally proxies user ports; the first command block matches the manager", + "# container creation and does not publish ports.", + "", + ] + + for user in normalized["users"]: + lines.extend([ + f"# User: {user['id']} - manager-equivalent container", + f"docker volume create {shell_quote(next(iter(user['volumes'].keys())))} >/dev/null", + ]) + for volume_name in list(user["volumes"].keys())[1:]: + lines.append(f"docker volume create {shell_quote(volume_name)} >/dev/null") + lines.append(build_docker_run_command(user, image_name, publish_port=False)) + lines.extend([ + "", + f"# Emergency direct access for {user['id']} on host port {user['port']}:", + f"# docker rm -f {shell_quote(user['container_name'])} || true", + ]) + for command_line in build_docker_run_command(user, image_name, publish_port=True).splitlines(): + lines.append(f"# {command_line}") + lines.append("") + + RECOVERY_COMMANDS_PATH.write_text("\n".join(lines), encoding="utf-8") + RECOVERY_COMMANDS_PATH.chmod(0o755) + + +def write_recovery_commands_from_disk(): + if not CONFIG_PATH.exists(): + return + with CONFIG_PATH.open(encoding="utf-8") as handle: + write_recovery_commands(json.load(handle)) + + +def detect_lan_ip(): + configured_ip = os.getenv("CONFIGURATOR_PUBLIC_IP") + if configured_ip: + return configured_ip + + try: + output = subprocess.check_output( + ["ip", "route", "get", "1.1.1.1"], + stderr=subprocess.DEVNULL, + text=True, + ).strip() + parts = output.split() + if "src" in parts: + candidate = parts[parts.index("src") + 1] + if is_public_configurator_ip(candidate): + return candidate + except (OSError, subprocess.SubprocessError, IndexError): + pass + + try: + output = subprocess.check_output( + ["ip", "-o", "-4", "addr", "show", "scope", "global"], + stderr=subprocess.DEVNULL, + text=True, + ) + for line in output.splitlines(): + parts = line.split() + interface = parts[1] + candidate = parts[3].split("/", 1)[0] + if is_ignored_interface(interface): + continue + if is_public_configurator_ip(candidate): + return candidate + except (OSError, subprocess.SubprocessError, IndexError): + pass + + try: + output = subprocess.check_output(["hostname", "-I"], stderr=subprocess.DEVNULL, text=True).strip() + for candidate in output.split(): + if is_public_configurator_ip(candidate): + return candidate + except (OSError, subprocess.SubprocessError): + pass + + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect(("8.8.8.8", 80)) + candidate = sock.getsockname()[0] + if is_public_configurator_ip(candidate): + return candidate + except OSError: + pass + + candidate = socket.gethostbyname(socket.gethostname()) + if is_public_configurator_ip(candidate): + return candidate + return "127.0.0.1" + + +def is_ignored_interface(interface): + ignored_prefixes = ("docker", "br-", "veth", "virbr", "podman", "lo") + return interface.startswith(ignored_prefixes) + + +def is_public_configurator_ip(candidate): + if not candidate or "." not in candidate: + return False + if candidate.startswith("127.") or candidate.startswith("169.254."): + return False + if candidate.startswith("172.17."): + return False + return True + + +class ConfiguratorHandler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(DASHBOARD_DIR), **kwargs) + + def do_GET(self): + if self.path == "/api/config": + try: + self._send_json(self._read_config()) + except ValueError as exc: + self._send_json({"ok": False, "error": str(exc)}, status=HTTPStatus.BAD_REQUEST) + return + return super().do_GET() + + def do_POST(self): + if self.path != "/api/config": + self.send_error(HTTPStatus.NOT_FOUND, "Unknown endpoint") + return + + try: + content_length = int(self.headers.get("Content-Length", "0")) + payload = self.rfile.read(content_length) + raw = json.loads(payload.decode("utf-8")) + normalized = normalize_config(raw) + compact = compact_config(raw) + CONFIG_PATH.write_text(json.dumps(compact, indent=2) + "\n", encoding="utf-8") + write_recovery_commands(compact) + except ValueError as exc: + self._send_json({"ok": False, "error": str(exc)}, status=HTTPStatus.BAD_REQUEST) + return + except json.JSONDecodeError as exc: + self._send_json({"ok": False, "error": f"Invalid JSON: {exc}"}, status=HTTPStatus.BAD_REQUEST) + return + + self._send_json({"ok": True, "config": compact, "normalized": normalized}) + + def log_message(self, format, *args): + return + + def _read_config(self): + if not CONFIG_PATH.exists(): + return { + "defaults": { + "container_prefix": "fis-", + "volumes": {"v-conf-{id}": "/config"}, + "environment": {"PUID": "1000", "PGID": "1000"}, + "mem_limit": "3g", + "memswap_limit": "5g", + "privileged": True, + }, + "users": [], + } + with CONFIG_PATH.open(encoding="utf-8") as handle: + return compact_config(json.load(handle)) + + def _send_json(self, payload, status=HTTPStatus.OK): + body = json.dumps(payload, indent=2).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def main(): + bind_host = os.getenv("CONFIGURATOR_BIND_HOST", "0.0.0.0") + public_host = detect_lan_ip() + port = int(os.getenv("CONFIGURATOR_PORT", "8765")) + try: + write_recovery_commands_from_disk() + except ValueError as exc: + print(f"Could not generate {RECOVERY_COMMANDS_PATH.name}: {exc}", flush=True) + server = ThreadingHTTPServer((bind_host, port), ConfiguratorHandler) + print(f"Configurator available at http://{public_host}:{port}", flush=True) + print(f"Docker recovery commands: {RECOVERY_COMMANDS_PATH}", flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/manager-solution/dashboard/index.html b/manager-solution/dashboard/index.html new file mode 100644 index 0000000..5b8045d --- /dev/null +++ b/manager-solution/dashboard/index.html @@ -0,0 +1,493 @@ + + + + + + Installazione Fiscality + + + +
+
+
+

Configuratore installazione

+

Configura Fiscality senza modificare il JSON a mano.

+

Crea gli utenti aziendali, controlla porte e nomi dei container, salva in manager/config.json, poi esegui ./install.sh.

+
+ + + + +
+
Caricamento configurazione...
+
+
+

Procedura consigliata

+
+
1. Apri questa pagina con ./configure.sh.
+
2. Aggiungi una voce per ogni dipendente o postazione che deve usare Fiscality.
+
3. Salva la configurazione, poi installa con ./install.sh.
+
4. Comunica agli utenti la porta dedicata o l'URL gateway condiviso, se lo abiliti.
+
+
+
+ +
+

Valori predefiniti

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+

Utenti

+
+
+
+
+ + + + + + diff --git a/manager-solution/docker-compose.yml b/manager-solution/docker-compose.yml index e8cd86c..db55534 100644 --- a/manager-solution/docker-compose.yml +++ b/manager-solution/docker-compose.yml @@ -16,4 +16,5 @@ services: - INACTIVITY_SECONDS=${INACTIVITY_SECONDS:-10} - IDLE_CHECK_INTERVAL=${IDLE_CHECK_INTERVAL:-10} - STARTUP_TIMEOUT_SECONDS=${STARTUP_TIMEOUT_SECONDS:-60} - - STARTUP_STABILIZATION_SECONDS=${STARTUP_STABILIZATION_SECONDS:-5} + - HTTP_READY_PATH=${HTTP_READY_PATH:-/} + - HTTP_READY_TIMEOUT_SECONDS=${HTTP_READY_TIMEOUT_SECONDS:-1.5} diff --git a/manager-solution/manager/Dockerfile b/manager-solution/manager/Dockerfile index 3cc182b..982e641 100644 --- a/manager-solution/manager/Dockerfile +++ b/manager-solution/manager/Dockerfile @@ -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"] \ No newline at end of file +CMD ["python", "manager.py"] diff --git a/manager-solution/manager/__pycache__/config_model.cpython-313.pyc b/manager-solution/manager/__pycache__/config_model.cpython-313.pyc new file mode 100644 index 0000000..4231a7a Binary files /dev/null and b/manager-solution/manager/__pycache__/config_model.cpython-313.pyc differ diff --git a/manager-solution/manager/__pycache__/manager.cpython-313.pyc b/manager-solution/manager/__pycache__/manager.cpython-313.pyc index 6585f27..d23b70d 100644 Binary files a/manager-solution/manager/__pycache__/manager.cpython-313.pyc and b/manager-solution/manager/__pycache__/manager.cpython-313.pyc differ diff --git a/manager-solution/manager/__pycache__/test_manager.cpython-313.pyc b/manager-solution/manager/__pycache__/test_manager.cpython-313.pyc index 223547c..0f0622b 100644 Binary files a/manager-solution/manager/__pycache__/test_manager.cpython-313.pyc and b/manager-solution/manager/__pycache__/test_manager.cpython-313.pyc differ diff --git a/manager-solution/manager/__pycache__/validate_config.cpython-313.pyc b/manager-solution/manager/__pycache__/validate_config.cpython-313.pyc new file mode 100644 index 0000000..bd09e44 Binary files /dev/null and b/manager-solution/manager/__pycache__/validate_config.cpython-313.pyc differ diff --git a/manager-solution/manager/config.json b/manager-solution/manager/config.json deleted file mode 100644 index 8bf8ded..0000000 --- a/manager-solution/manager/config.json +++ /dev/null @@ -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" - } - } - ] -} diff --git a/manager-solution/manager/config_model.py b/manager-solution/manager/config_model.py new file mode 100644 index 0000000..4bf620a --- /dev/null +++ b/manager-solution/manager/config_model.py @@ -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) diff --git a/manager-solution/manager/manager.py b/manager-solution/manager/manager.py index 1548e7b..3c2f0a5 100644 --- a/manager-solution/manager/manager.py +++ b/manager-solution/manager/manager.py @@ -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(): diff --git a/manager-solution/manager/test_manager.py b/manager-solution/manager/test_manager.py deleted file mode 100644 index 71f3353..0000000 --- a/manager-solution/manager/test_manager.py +++ /dev/null @@ -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() diff --git a/manager-solution/manager/validate_config.py b/manager-solution/manager/validate_config.py new file mode 100644 index 0000000..3d09516 --- /dev/null +++ b/manager-solution/manager/validate_config.py @@ -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()) diff --git a/prova-docker-compose.yml.txt b/prova-docker-compose.yml.txt deleted file mode 100755 index 26b361a..0000000 --- a/prova-docker-compose.yml.txt +++ /dev/null @@ -1,59 +0,0 @@ -version: '3.8' - -services: - fis: - image: fis - container_name: fis - ports: - - "3000:3000" - environment: - - PUID=1000 - - PGID=1000 - - CUSTOM_USER=tomas - - PASSWORD=tommal - - LC_ALL=it_IT.UTF-8 - - TZ=Europe/Rome - mem_limit: 3g - memswap_limit: 5g - privileged: true - tmpfs: - - /tmp:exec,mode=777 - dns: - - 10.100.0.8 - - 10.200.100.1 - extra_hosts: - - "scsv28.sanmarco.lan:10.200.100.108" - volumes: - - v-conf:/config - -volumes: - v-conf: - - - - - -# ONLY IN PRODUCTION -#version: '3.8' -# -#services: -# fis: -# image: fis -# container_name: fis -# ports: -# - "3003:3000" -# environment: -# - PUID=1000 -# - PGID=1000 -# - PASSWORD=tommal -# mem_limit: 700m -# memswap_limit: 2g -# privileged: true -# volumes: -# - v-fis:/app/Fiscality -# - v-config:/config -# -#volumes: -# v-fis: -# v-config: -# diff --git a/stop.sh b/uninstall.sh similarity index 100% rename from stop.sh rename to uninstall.sh