fix the manager

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

View File

@@ -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
View 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
View 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

View File

@@ -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

View 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()

View 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>

View File

@@ -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}

View File

@@ -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

View File

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

View File

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

View File

@@ -12,9 +12,10 @@ import os
import socket import 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( return True
f"[{container_name}] Port {APP_PORT} is open, waiting " if not waiting_for_http:
f"{STARTUP_STABILIZATION_SECONDS}s for backend stabilization" log(f"[{container_name}] Port {APP_PORT} is open, waiting for HTTP readiness")
) waiting_for_http = True
time.sleep(STARTUP_STABILIZATION_SECONDS)
stabilized = True
continue
return 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():

View File

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

View File

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

View File

@@ -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:
#