fix the manager
This commit is contained in:
@@ -11,6 +11,11 @@ RUN mkdir -p /app /defaults \
|
|||||||
RUN ln -snf /usr/share/zoneinfo/Europe/Rome /etc/localtime && \
|
RUN ln -snf /usr/share/zoneinfo/Europe/Rome /etc/localtime && \
|
||||||
echo "Europe/Rome" > /etc/timezone
|
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 && \
|
RUN apt-get update && \
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
firefox-esr gnumeric xdg-utils \
|
firefox-esr gnumeric xdg-utils \
|
||||||
|
|||||||
7
configure.sh
Executable file
7
configure.sh
Executable file
@@ -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
|
||||||
42
docker-run-commands.sh
Executable file
42
docker-run-commands.sh
Executable file
@@ -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
|
||||||
BIN
fiscality-installer.zip
Normal file
BIN
fiscality-installer.zip
Normal file
Binary file not shown.
@@ -21,6 +21,9 @@ if [[ ! -f "${MANAGER_DIR}/manager/config.json" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "Validating manager config"
|
||||||
|
python3 "${MANAGER_DIR}/manager/validate_config.py" "${MANAGER_DIR}/manager/config.json" >/dev/null
|
||||||
|
|
||||||
ensure_image_exists() {
|
ensure_image_exists() {
|
||||||
local image_name="$1"
|
local image_name="$1"
|
||||||
if ! docker image inspect "${image_name}" >/dev/null 2>&1; then
|
if ! docker image inspect "${image_name}" >/dev/null 2>&1; then
|
||||||
|
|||||||
BIN
manager-solution/__pycache__/configurator_server.cpython-313.pyc
Normal file
BIN
manager-solution/__pycache__/configurator_server.cpython-313.pyc
Normal file
Binary file not shown.
271
manager-solution/configurator_server.py
Normal file
271
manager-solution/configurator_server.py
Normal file
@@ -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()
|
||||||
493
manager-solution/dashboard/index.html
Normal file
493
manager-solution/dashboard/index.html
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Installazione Fiscality</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f5f1e8;
|
||||||
|
--panel: #fffdf8;
|
||||||
|
--ink: #1f2a2e;
|
||||||
|
--muted: #617076;
|
||||||
|
--line: #d9cfbf;
|
||||||
|
--accent: #0f6d62;
|
||||||
|
--accent-2: #d38b32;
|
||||||
|
--danger: #9f2f2f;
|
||||||
|
--ok: #156c3d;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(211, 139, 50, 0.18), transparent 24rem),
|
||||||
|
linear-gradient(180deg, #f8f3ea 0%, var(--bg) 100%);
|
||||||
|
}
|
||||||
|
.shell {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem 3rem;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: 1.2fr 0.8fr;
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.hero-panel, .panel {
|
||||||
|
background: color-mix(in srgb, var(--panel) 94%, white 6%);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: 0 16px 40px rgba(31, 42, 46, 0.06);
|
||||||
|
}
|
||||||
|
h1, h2, h3 { margin: 0 0 0.75rem; }
|
||||||
|
h1 { font-size: clamp(2rem, 4vw, 3.8rem); line-height: 0.95; }
|
||||||
|
h2 { font-size: 1.2rem; }
|
||||||
|
p { margin: 0; color: var(--muted); line-height: 1.5; }
|
||||||
|
.actions, .toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.8rem 1.1rem;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
color: white;
|
||||||
|
background: var(--ink);
|
||||||
|
}
|
||||||
|
button.secondary { background: var(--accent); }
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
button.danger { background: var(--danger); }
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
input, textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
textarea { min-height: 7rem; resize: vertical; }
|
||||||
|
.users {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.user-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.65);
|
||||||
|
}
|
||||||
|
.user-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #ece7dd;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.status.ok { background: rgba(21, 108, 61, 0.12); color: var(--ok); }
|
||||||
|
.status.error { background: rgba(159, 47, 47, 0.12); color: var(--danger); }
|
||||||
|
.steps {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f0e8d9;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: "SFMono-Regular", Consolas, monospace;
|
||||||
|
background: rgba(15, 109, 98, 0.08);
|
||||||
|
padding: 0.15rem 0.35rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.hero, .grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-panel">
|
||||||
|
<p>Configuratore installazione</p>
|
||||||
|
<h1>Configura Fiscality senza modificare il JSON a mano.</h1>
|
||||||
|
<p>Crea gli utenti aziendali, controlla porte e nomi dei container, salva in <code>manager/config.json</code>, poi esegui <code>./install.sh</code>.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="saveButton" class="secondary">Salva configurazione</button>
|
||||||
|
<button id="validateButton">Valida</button>
|
||||||
|
<button id="downloadButton" class="ghost">Scarica JSON</button>
|
||||||
|
<button id="addUserButton" class="ghost">Aggiungi utente</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status">Caricamento configurazione...</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Procedura consigliata</h2>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step">1. Apri questa pagina con <code>./configure.sh</code>.</div>
|
||||||
|
<div class="step">2. Aggiungi una voce per ogni dipendente o postazione che deve usare Fiscality.</div>
|
||||||
|
<div class="step">3. Salva la configurazione, poi installa con <code>./install.sh</code>.</div>
|
||||||
|
<div class="step">4. Comunica agli utenti la porta dedicata o l'URL gateway condiviso, se lo abiliti.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Valori predefiniti</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label for="containerPrefix">Prefisso container</label>
|
||||||
|
<input id="containerPrefix" type="text">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="memLimit">Limite memoria</label>
|
||||||
|
<input id="memLimit" type="text" placeholder="3g">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="memswapLimit">Limite swap</label>
|
||||||
|
<input id="memswapLimit" type="text" placeholder="5g">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="privileged">Privilegiato</label>
|
||||||
|
<input id="privileged" type="text" placeholder="true o false">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="defaultEnv">Ambiente predefinito JSON</label>
|
||||||
|
<textarea id="defaultEnv"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="defaultVolumes">Volumi predefiniti JSON</label>
|
||||||
|
<textarea id="defaultVolumes"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="defaultAdvanced">Altre impostazioni predefinite JSON</label>
|
||||||
|
<textarea id="defaultAdvanced"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="toolbar">
|
||||||
|
<h2 style="margin-right:auto">Utenti</h2>
|
||||||
|
</div>
|
||||||
|
<div id="users" class="users"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<template id="userTemplate">
|
||||||
|
<article class="user-card">
|
||||||
|
<div class="user-head">
|
||||||
|
<h3>Utente</h3>
|
||||||
|
<button class="danger remove-user" type="button">Rimuovi</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label>ID utente</label>
|
||||||
|
<input data-field="id" type="text" placeholder="mario">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Porta</label>
|
||||||
|
<input data-field="port" type="number" placeholder="3001">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Password</label>
|
||||||
|
<input data-field="password" type="text" placeholder="segreta">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Nome container personalizzato</label>
|
||||||
|
<input data-field="container_name" type="text" placeholder="opzionale">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Ambiente aggiuntivo JSON</label>
|
||||||
|
<textarea data-field="environment"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Volumi JSON</label>
|
||||||
|
<textarea data-field="volumes"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Altre impostazioni utente JSON</label>
|
||||||
|
<textarea data-field="advanced"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const usersEl = document.getElementById("users");
|
||||||
|
const template = document.getElementById("userTemplate");
|
||||||
|
|
||||||
|
const defaultConfig = {
|
||||||
|
defaults: {
|
||||||
|
container_prefix: "fis-",
|
||||||
|
environment: { PUID: "1000", PGID: "1000" },
|
||||||
|
volumes: { "v-conf-{id}": "/config" },
|
||||||
|
mem_limit: "3g",
|
||||||
|
memswap_limit: "5g",
|
||||||
|
privileged: true
|
||||||
|
},
|
||||||
|
users: []
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readJsonResponse(response) {
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
if (!contentType.includes("application/json")) {
|
||||||
|
throw new Error(
|
||||||
|
"Backend del configuratore non disponibile. Apri questa pagina tramite ./configure.sh all'indirizzo http://127.0.0.1:8765, non da un'anteprima dell'IDE."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error("Il backend ha restituito JSON non valido.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message, kind = "") {
|
||||||
|
statusEl.textContent = translateMessage(message);
|
||||||
|
statusEl.className = `status ${kind}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateMessage(message) {
|
||||||
|
const replacements = [
|
||||||
|
[/^Duplicate user id: (.+)$/, "ID utente duplicato: $1"],
|
||||||
|
[/^Duplicate port: (.+)$/, "Porta duplicata: $1"],
|
||||||
|
[/^Duplicate container_name: (.+)$/, "Nome container duplicato: $1"],
|
||||||
|
[/^User (.+) must define at least one volume$/, "L'utente $1 deve definire almeno un volume"],
|
||||||
|
[/^User (.+) has duplicate volume mount targets$/, "L'utente $1 ha destinazioni di montaggio volume duplicate"],
|
||||||
|
[/^User (.+) must define environment\.PASSWORD$/, "L'utente $1 deve definire environment.PASSWORD"],
|
||||||
|
[/^Configuration must contain at least one user$/, "La configurazione deve contenere almeno un utente"]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [pattern, replacement] of replacements) {
|
||||||
|
if (pattern.test(message)) {
|
||||||
|
return message.replace(pattern, replacement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringify(value) {
|
||||||
|
return JSON.stringify(value ?? {}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonField(value, label) {
|
||||||
|
if (!value.trim()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`${label} deve contenere JSON valido`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDefaults(config) {
|
||||||
|
const advanced = { ...(config.defaults ?? {}) };
|
||||||
|
delete advanced.container_prefix;
|
||||||
|
delete advanced.mem_limit;
|
||||||
|
delete advanced.memswap_limit;
|
||||||
|
delete advanced.privileged;
|
||||||
|
delete advanced.environment;
|
||||||
|
delete advanced.volumes;
|
||||||
|
|
||||||
|
document.getElementById("containerPrefix").value = config.defaults.container_prefix ?? "fis-";
|
||||||
|
document.getElementById("memLimit").value = config.defaults.mem_limit ?? "";
|
||||||
|
document.getElementById("memswapLimit").value = config.defaults.memswap_limit ?? "";
|
||||||
|
document.getElementById("privileged").value = String(config.defaults.privileged ?? false);
|
||||||
|
document.getElementById("defaultEnv").value = stringify(config.defaults.environment);
|
||||||
|
document.getElementById("defaultVolumes").value = stringify(config.defaults.volumes);
|
||||||
|
document.getElementById("defaultAdvanced").value = stringify(advanced);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUserCard(user = {}) {
|
||||||
|
const node = template.content.firstElementChild.cloneNode(true);
|
||||||
|
const advanced = { ...user };
|
||||||
|
delete advanced.id;
|
||||||
|
delete advanced.port;
|
||||||
|
delete advanced.container_name;
|
||||||
|
delete advanced.environment;
|
||||||
|
delete advanced.volumes;
|
||||||
|
|
||||||
|
node.querySelector('[data-field="id"]').value = user.id ?? "";
|
||||||
|
node.querySelector('[data-field="port"]').value = user.port ?? "";
|
||||||
|
node.querySelector('[data-field="password"]').value = user.environment?.PASSWORD ?? "";
|
||||||
|
node.querySelector('[data-field="container_name"]').value = user.container_name ?? "";
|
||||||
|
|
||||||
|
const environment = { ...(user.environment ?? {}) };
|
||||||
|
delete environment.PASSWORD;
|
||||||
|
node.querySelector('[data-field="environment"]').value = stringify(environment);
|
||||||
|
node.querySelector('[data-field="volumes"]').value = stringify(user.volumes ?? {});
|
||||||
|
node.querySelector('[data-field="advanced"]').value = stringify(advanced);
|
||||||
|
|
||||||
|
node.querySelector(".remove-user").addEventListener("click", () => {
|
||||||
|
node.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
usersEl.appendChild(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsers(config) {
|
||||||
|
usersEl.innerHTML = "";
|
||||||
|
config.users.forEach(addUserCard);
|
||||||
|
if (config.users.length === 0) {
|
||||||
|
addUserCard({ port: 3001 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectConfig() {
|
||||||
|
const defaultAdvanced = parseJsonField(document.getElementById("defaultAdvanced").value, "Altre impostazioni predefinite");
|
||||||
|
const config = {
|
||||||
|
defaults: {
|
||||||
|
...defaultAdvanced,
|
||||||
|
container_prefix: document.getElementById("containerPrefix").value.trim() || "fis-",
|
||||||
|
mem_limit: document.getElementById("memLimit").value.trim() || null,
|
||||||
|
memswap_limit: document.getElementById("memswapLimit").value.trim() || null,
|
||||||
|
privileged: document.getElementById("privileged").value.trim().toLowerCase() === "true",
|
||||||
|
environment: parseJsonField(document.getElementById("defaultEnv").value, "Ambiente predefinito"),
|
||||||
|
volumes: parseJsonField(document.getElementById("defaultVolumes").value, "Volumi predefiniti")
|
||||||
|
},
|
||||||
|
users: []
|
||||||
|
};
|
||||||
|
|
||||||
|
usersEl.querySelectorAll(".user-card").forEach((card) => {
|
||||||
|
const environment = parseJsonField(card.querySelector('[data-field="environment"]').value, "Ambiente utente");
|
||||||
|
const password = card.querySelector('[data-field="password"]').value.trim();
|
||||||
|
if (password) {
|
||||||
|
environment.PASSWORD = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
const advanced = parseJsonField(card.querySelector('[data-field="advanced"]').value, "Altre impostazioni utente");
|
||||||
|
config.users.push({
|
||||||
|
...advanced,
|
||||||
|
id: card.querySelector('[data-field="id"]').value.trim(),
|
||||||
|
port: Number(card.querySelector('[data-field="port"]').value),
|
||||||
|
container_name: card.querySelector('[data-field="container_name"]').value.trim() || undefined,
|
||||||
|
environment,
|
||||||
|
volumes: parseJsonField(card.querySelector('[data-field="volumes"]').value, "Volumi utente")
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/config");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
"Backend del configuratore non disponibile. Avvialo con ./configure.sh e apri http://127.0.0.1:8765."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const config = await readJsonResponse(response);
|
||||||
|
renderDefaults(config);
|
||||||
|
renderUsers(config);
|
||||||
|
setStatus("Configurazione caricata. Controlla i valori e salva quando pronta.");
|
||||||
|
} catch (error) {
|
||||||
|
renderDefaults(defaultConfig);
|
||||||
|
renderUsers(defaultConfig);
|
||||||
|
setStatus(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
try {
|
||||||
|
const payload = collectConfig();
|
||||||
|
const response = await fetch("/api/config", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const result = await readJsonResponse(response);
|
||||||
|
if (!response.ok || !result.ok) {
|
||||||
|
throw new Error(result.error || "Impossibile salvare la configurazione");
|
||||||
|
}
|
||||||
|
renderDefaults(result.config);
|
||||||
|
renderUsers(result.config);
|
||||||
|
setStatus("Configurazione salvata in manager/config.json.", "ok");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateConfig() {
|
||||||
|
try {
|
||||||
|
const payload = collectConfig();
|
||||||
|
const response = await fetch("/api/config", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const result = await readJsonResponse(response);
|
||||||
|
if (!response.ok || !result.ok) {
|
||||||
|
throw new Error(result.error || "Validazione non riuscita");
|
||||||
|
}
|
||||||
|
setStatus("Validazione completata e configurazione normalizzata salvata.", "ok");
|
||||||
|
renderDefaults(result.config);
|
||||||
|
renderUsers(result.config);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadConfig() {
|
||||||
|
try {
|
||||||
|
const payload = collectConfig();
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "config.json";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setStatus("config.json scaricato. Usalo solo se ti serve una copia esterna.", "ok");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("saveButton").addEventListener("click", saveConfig);
|
||||||
|
document.getElementById("validateButton").addEventListener("click", validateConfig);
|
||||||
|
document.getElementById("downloadButton").addEventListener("click", downloadConfig);
|
||||||
|
document.getElementById("addUserButton").addEventListener("click", () => addUserCard({ port: 3001 }));
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -16,4 +16,5 @@ services:
|
|||||||
- INACTIVITY_SECONDS=${INACTIVITY_SECONDS:-10}
|
- INACTIVITY_SECONDS=${INACTIVITY_SECONDS:-10}
|
||||||
- IDLE_CHECK_INTERVAL=${IDLE_CHECK_INTERVAL:-10}
|
- IDLE_CHECK_INTERVAL=${IDLE_CHECK_INTERVAL:-10}
|
||||||
- STARTUP_TIMEOUT_SECONDS=${STARTUP_TIMEOUT_SECONDS:-60}
|
- 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}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ COPY requirements.txt .
|
|||||||
|
|
||||||
RUN pip install -r requirements.txt
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
|
COPY config_model.py .
|
||||||
COPY manager.py .
|
COPY manager.py .
|
||||||
COPY config.json .
|
COPY config.json .
|
||||||
|
COPY validate_config.py .
|
||||||
|
|
||||||
ENV INACTIVITY_SECONDS=10
|
ENV INACTIVITY_SECONDS=10
|
||||||
|
|
||||||
|
|||||||
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 socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, Iterable, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import docker
|
import docker
|
||||||
|
from config_model import load_config
|
||||||
|
|
||||||
CONFIG_PATH = os.getenv("CONFIG_PATH", "config.json")
|
CONFIG_PATH = os.getenv("CONFIG_PATH", "config.json")
|
||||||
IMAGE_NAME = os.getenv("IMAGE_NAME", "fis")
|
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"))
|
INACTIVITY_SECONDS = int(os.getenv("INACTIVITY_SECONDS", "10"))
|
||||||
IDLE_CHECK_INTERVAL = int(os.getenv("IDLE_CHECK_INTERVAL", "10"))
|
IDLE_CHECK_INTERVAL = int(os.getenv("IDLE_CHECK_INTERVAL", "10"))
|
||||||
STARTUP_TIMEOUT_SECONDS = int(os.getenv("STARTUP_TIMEOUT_SECONDS", "60"))
|
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"))
|
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"))
|
INITIAL_REQUEST_TIMEOUT_SECONDS = float(os.getenv("INITIAL_REQUEST_TIMEOUT_SECONDS", "3"))
|
||||||
MAX_REQUEST_HEADER_BYTES = int(os.getenv("MAX_REQUEST_HEADER_BYTES", "65536"))
|
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
|
_docker_client = None
|
||||||
user_activity: Dict[str, float] = {}
|
user_activity: Dict[str, float] = {}
|
||||||
users_by_id: Dict[str, Dict[str, Any]] = {}
|
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]
|
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]:
|
def get_container_ip(container_name: str) -> Optional[str]:
|
||||||
"""Get IP address of a running container."""
|
"""Get IP address of a running container."""
|
||||||
try:
|
try:
|
||||||
@@ -193,6 +85,30 @@ def is_port_open(host: str, port: int, timeout_seconds: float = TCP_CONNECT_TIME
|
|||||||
return False
|
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:
|
async def read_initial_http_request(reader) -> bytes:
|
||||||
buffer = bytearray()
|
buffer = bytearray()
|
||||||
while len(buffer) < MAX_REQUEST_HEADER_BYTES:
|
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:
|
def wait_until_ready(container_name: str) -> bool:
|
||||||
stabilized = False
|
waiting_for_http = False
|
||||||
deadline = time.time() + STARTUP_TIMEOUT_SECONDS
|
deadline = time.time() + STARTUP_TIMEOUT_SECONDS
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
ip = get_container_ip(container_name)
|
ip = get_container_ip(container_name)
|
||||||
if ip and is_port_open(ip, APP_PORT):
|
if ip and is_port_open(ip, APP_PORT):
|
||||||
if STARTUP_STABILIZATION_SECONDS > 0 and not stabilized:
|
if is_http_ready(ip, APP_PORT):
|
||||||
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
|
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)
|
time.sleep(0.5)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -364,7 +276,7 @@ def start_user_container(user: Dict[str, Any]):
|
|||||||
extra_hosts=user.get("extra_hosts") or None,
|
extra_hosts=user.get("extra_hosts") or None,
|
||||||
tty=True,
|
tty=True,
|
||||||
stdin_open=True,
|
stdin_open=True,
|
||||||
restart_policy={"Name": "no"},
|
restart_policy={"Name": "always"},
|
||||||
)
|
)
|
||||||
except docker.errors.APIError as exc:
|
except docker.errors.APIError as exc:
|
||||||
if "already in use" not in str(exc).lower():
|
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())
|
||||||
BIN
maschera.zip
Normal file
BIN
maschera.zip
Normal file
Binary file not shown.
@@ -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:
|
|
||||||
#
|
|
||||||
Reference in New Issue
Block a user