fix the manager
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user