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

Configuratore installazione

+

Configura Fiscality senza modificare il JSON a mano.

+

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

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

Procedura consigliata

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

Valori predefiniti

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

Utenti

+
+
+
+
+ + + + + + diff --git a/manager-solution/docker-compose.yml b/manager-solution/docker-compose.yml index e8cd86c..db55534 100644 --- a/manager-solution/docker-compose.yml +++ b/manager-solution/docker-compose.yml @@ -16,4 +16,5 @@ services: - INACTIVITY_SECONDS=${INACTIVITY_SECONDS:-10} - IDLE_CHECK_INTERVAL=${IDLE_CHECK_INTERVAL:-10} - STARTUP_TIMEOUT_SECONDS=${STARTUP_TIMEOUT_SECONDS:-60} - - STARTUP_STABILIZATION_SECONDS=${STARTUP_STABILIZATION_SECONDS:-5} + - HTTP_READY_PATH=${HTTP_READY_PATH:-/} + - HTTP_READY_TIMEOUT_SECONDS=${HTTP_READY_TIMEOUT_SECONDS:-1.5} diff --git a/manager-solution/manager/Dockerfile b/manager-solution/manager/Dockerfile index 3cc182b..982e641 100644 --- a/manager-solution/manager/Dockerfile +++ b/manager-solution/manager/Dockerfile @@ -6,9 +6,11 @@ COPY requirements.txt . RUN pip install -r requirements.txt +COPY config_model.py . COPY manager.py . COPY config.json . +COPY validate_config.py . ENV INACTIVITY_SECONDS=10 -CMD ["python", "manager.py"] \ No newline at end of file +CMD ["python", "manager.py"] diff --git a/manager-solution/manager/__pycache__/config_model.cpython-313.pyc b/manager-solution/manager/__pycache__/config_model.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4231a7ad4b24f2f108475f183aae23b562f9af21 GIT binary patch literal 9798 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 delta 9805 zcmb6;3shUzk?%=Q($fb?2qeA&VZO2r$bfCYHZ~aJFWBZG8w0i?kT7zvgs+4@G_6Rt zyCre<80YP-aht74`U&o~8##1a()OfI+tcIhc6UYPCdylHH~nPyi$#;tOcBsfSJziak3*~kQt-!SnxT$4;WmnDpkfmSPfe>7c2ZiSuxqGZXs4^n zG$eH$5Ya)onvzP_0%kYGY!*7{nBv6FC$|A5Br%8&)AjA41GWfjObjdOqWGD^5`28U-aSxAsCI0Y4$QSUt z$HmVnua#MazV_Ze=MfitckDm3f1tf@|KT2&uxIz-o=!nxH;YZGa`Dru&P^Y1qrPBm zmt8FxM*{wk+voQZ*97s7`YuXZum8M{1pH^c{*a_P9~hrJ>kW#zjaA~d#zHmUxu>h$ zd8khi|4H?}!U4|GAim0%vvuMxd6#1o^n(}=ZKNKcBp(l)CR?D@f&?9aY33fMo~fS8 zjd3NUPAt;A%G$-ZH0vtxh)U|v81cG2KL2TnI}{js)XtKvI5H_x+@Q58wgX~^*sbk0 zaKr8(*dTD;ONh_o6(dC!$1qU;ni)*SWQK7vJJ=e);l~3p-UeYM6|wy?r%Y@w%?&%* zKg?ufoU#gr*kQa~CUG9z0OjO_#~t$89h)t=e$81gIqh}f zAhH_>7*~Ro0Kg1c*|Qbb*3YerWu352cf~o)r@J5To~^l7H#dBjD~oHhr@KiVuu6*a z?(s>lq(1K^KHT--Nb;@%BEzWR59ho;3Ts}w@;1dW|9YG^@(}(4C9wSOx_V$w=pSC=9gE%xC$7N zl0KSf17#F8sfW~pes-0l@dbVUV94zs@k;Vwh)BxO06FUpkzGKS6k%V;dp0QL!o~&0 z&wE`H zRoSzZ&*Z$WvM#HvaYOEv$DVv_*-$g*0}$nFC^`I0?(3?OWmUE1*1MVd6PjedAN@~%`swWUmoie%Zkn7js2Q%YQz1qK^3f=ew^&;36l?^ zeadI#28ItOpzwF z&8s8if+mAco8;3zgio8~+hns~4BuqSD@bS3$><2v>X1!zmFDH?Q$kknf|8W8gfsGI zzrv}|ulk#wX_SE!J)KN0DA5O3$Ac+^Y@kxlL{7ovV4+WU*a;!Zp3@`o9`C3dZv{!_ z^GMu8fQ0NaB7hvyhX9k+FtK8@96=d^bqGK`NQ)8J0CY+E^gZo&!>v+;BnZpQllZne+0b0))hTi3bk|JNHWVbuDZt;&-LFJx<0f}yJU)4w=el({60kX-RQsGKYuo6ZHfSD*L{WDssuSZmbn55 zjs@lEf%Zsm%-XzUkMW)9fxej45smKNaVp7M0eS{DISE6kXOGlsW5ei&vn+ZS?Q$iJB%sa(qcPVq~{x4n0IV=YHw_M=f@ zD3*H?I5$7u9WN}oQFFa!ep`h9hVGVbX)xy4A1gcn#BIx5Uc6-8jj`)v^Wi1_JGz&2 zw_UMjXRPEHP*c49Xhq!_d;ZizPYq?-@)tcC0STi~AR1E1ELER`%PcLX-kQN7R z1Iu_I*rfV`=8ua4NxDo)5Ov?#YR*!mWZUqLM2s>6BS%bFmBw_xoXECLGxn^WjDY0? zjiz!sCnv^zBai{xa4&5>PpfUpm_}ll159BWvo?6O+Gxm|QaEw+l+AW_G8nRX!cczr zy*78qHtux;A^@F4`62N~+6CeG;oeRfF{jFsBN78L>Xo__pIr_apEo3Fj$!V+hmZh~ z|D`2eIWVubEA2Ni|b9ZO;4Pek;e@=vt3U-HWTKc z70s%vy$lT<-Fux~i-ygLeh?x#XbqC{G^IYG2?XuMo(>lUS z+|tSA=7V<*yxx2~gZ)6%dN5`>6xAJilQ&+`J)xTw?(zk3-gHI(gnl-3moHvzzs488 zTghZw5`_yl;SPHpAqcfzY2FUVSCGRDt}mpW~>j3iPfF!(Q(C)k+d zvR;+ANtdILW8NWNJ(?4Sx+I-y3S7#Qy>KN%g~6?8nyxVX3P>)~nNG&Z z8H9tI_zXfdlK4157f3Q1b=jCt>QmyJ3PD*XdpgG&p4uTVWky+Atw7oe6^g3o1I1)$ zw6@vKO1#%U62O9&`sLKd-82WEByOzh6K%22Njc{Bd*DSuPTUtHHGrf$<0+uBKavS(QMxOU za*%VtHV1#f%K*UZ)mby1x#K@lS1yDt_w=SKz9)Ti%9y@%M*g0h(Nsajlbw4_byYQ| znm5Ia71R5FqRqXh&yN=Djp?yq`Dx?!m~O|5EelOgotoSIT2|GqmgzlF-Hv;lX?F8n zuHYB>>%P#uAiQw==JCittaeu{zx~dhWo>Vi>wV7*6Z}|R`P+B(OjcX45KDy1c^ww{ zD<*SCHTPYMrlUedFpw?Y)b5s<9ng|As}U+l?Ip5$+xiViQG%TVP!RGxflyaq((jSf zdoGT6>AMSk+OR8#Q#`2OR7_tyjF4}kQIc;ez6tUvkn*L)IlYqAaAN@|LoxFc|JLw7 z^?weHL>~AT&|3R)pz{S?7?fPNpX4urz9cRg&n(8xd)Pfb2HcO#0P;QuAWM^_fvr~$ zUkZ8Mgt#xky9*40*Ca+#xr3MdBffydUvT?EE+~K{4xdSW+_<$(Z6?n?P6zHp7auo%0X97Myto^glAsU*iva6mDia*{3)ucV0BA2@`%3_Vn7^me&79#$ z-?+y$779(ch}YvIUU*6QGBCU$dW-h5b&FpudXhCrY^$Um4~)3S(ZEyUK5JdQqzJ+# z6{2%M)Tg$_wuIg8 zw`(MLhy#~Wdc4%KFX9k1FNq+yh2U5b!WR&rW{Cymlb5jlO|hh+Z8KU8A1vSzuN$$Q z2&gh&$JRHHtR8{$P46js-pSWQv0@Y!{6R&9;w~`!cCoy2mSyAO*Q#ECD$=y`{de`^ znd&;t>ig2S#pkLg*e{AXHRbHExV6S!4wDV0eqe!@{MNsMys881yc!UQAI~=Nvo-6C zX(mWcl#MF!S2boaUSlpv6H@USew|VI+3u$p=Cn%u=bAE>2j(5&FG8QSt9!Z#-GK`r zdrEcEb_5QHZOUZx1#NgtHrmj~A(q;vih?$9b=$Cengi!-BhNLvF2Mav62Q3c;-b4@& zBW4GQ$4?_Yag+X#ZybEr?YTt$4d{08m*C$)W15L)Tc!_)zq7Z7H!hru85}Pc-ZSRJ zEqQbLXkPXFg|A#(I1_C?a4)YZYS|p!d}Kw=ntRy?a(T81>V<6cHOK>?#RT(LiCzr10N|FOolQpo7C6F0LVkyp8f!TG>m zi_ffw+Ld^5buw}R-#L*YacAMU!{NaYN$_%pb64B@~A<6mpM@B~*NfgbxdlheDTdFTnsb$Vz&62ozsvycDL(-Htk3 zL_n8&30qSL5-tHgKtkk0Mv#+9Kfx4{;94cV{m6crdeYiEahlNusJp?%dj#8Pbn-J~ zhwFOeQ8LZs0^-jAvF64VztvQzc^9zz(0@i3|D)-9th{r+ZShN6PRh(^%D1W4;-x_F zd=Np*Z`mJqyh2Qi_f(=*baybaELAtcsrF89s<8Mkk?RnC4$7fkgbyuN$!E`kp{M{ zKuR5_X;Lrd?kZD+mzK;NBCLf5OYp+n@;t9)&ueO(2=;f}NAc2dQT#)U%i4peU z@{1k+$|>n{&IR$uUCr<=s^43l^#P0nuS7>7Pm67PtHZc)2^|$Mj3^O&3UO&WpgbCQ z;Jl$~M2iTz9Vn0Lg)Rd7MfH*Z8AM;hfPPFO6hFkTa+3c;5V(B)Y`NqBEsRT$p5ypS+ajZt?z zRA}N5Y!@|K43I$;0ChrZP+Q#5Q>w6npdT%|d+g{ymwOw+s2O=3fe`^U)FEt9uc5&R zYWSo=$Dk$2CsMxr9(3*SlbJtoAz?S|I24pFH~0$tR)l(`d@E@+oey$f*T|R`t_woe zfiCQC#5Q4Bi)RYWeZNP(SlB19>_O4*bg??|Cw&$UQvW>h*$ug(?U-3R2oDadlZBs4 z_2RB$wX8w(A1mF7E2SU72(Q})1+?J~%!YRI-B2(=J12?HZOkaasr2@3CAr-v6Sa7{ z=IG6*KM@WViS9@*9S-Yi&Ch!mt>iR#n z>6f4ay+x-~jyn2p$|{VxA-S@hh5&xSeaS{ID=5Ehn1I?u?Fpg54x`}5IJ({PjO{ig z^8ZN~o+uc=)fnoG;_QiXnN=qK!-=|$eSnA&>4OGi2W#TIX8%zdTV-PfG!U?yCzaPkNO*k-(#x5v{@TWj@B}wU?fDqv! zD(KJ+m6Wi&F)vwt_!RpGt?R3iTnk`Lu!T9`?!!2Rd5H#cj^MQT&B2$9QT>Lm99n3L z>RRve+v2=IyfM_Wlh@App^C1$J~Y>}us+haRB>w{a_F|~c2jg;|MKn=(T;)Wz+hB2 zbeBH~V`$ZU{x!bjoh&BX_P{6xPp%JdVshHqcPvcd82gTqG4-(TXc)x-_WdB_Q_Lsx zx+>Z0hOR&i%lw#WoNCmLrsS zmkZb9a=|9nfk^uO~9^RINj$HJ3}wO)dFlZ+SM+k$G}|{&zM$4*3#o_MwA0dlSAG;H>~d<7=Sj&KoSsXWquOHaGE8y_HcrLclE%{9%-;Yo?V zex_}Ofqo=6GP2Yd8M{?NySF><%I@sf}A;$#LG&ZiC+Te)`qol>nG-;+y$4MuxHUYG`bz3^qY2%PgC1J)MC4Kz= zxw{|%_BeH~(5LhK@Bg3k{r~yz;TvObo+gePcDqG@=f0;NdSX-TqGLo$&M{fLi3HiV zwHHh^&~=W`T9(nh>GDHGF{H-FrR>N#4Kzmd@6hU`wIHlQ&?Dd!)NSe!a}}v;hFVldS7Hwz*jFAC)q?&nTAr!L36wW;AzUUO&*z#L zarvxV&Gk62A2l>|W3ii!S;M58ov{9K7ayxtjKfq)<)sltD@%FBBt4au^ROzk5yr~| zplk-)Z+kJ!Cu!(ID2(gA7Kbf1cRlmi|1MZE;z1n8jlda-HZ?Lr*RlossEzkvV?27N zzPq@-12}bnz3m8-0npdae(w14%92>LMX?O$viVdxD^Z!^V8wViH#U)xhKMr1U4XSgT*R}%@EB5rKSN{?*>uOV92XLv<+xJtgoMYCZu}Xnq1ld>RMu~Leh$6 z_O}FT|gZ?ux^qfX9 zjYtPl6JvQ<(NDmd$@F!$F%Vh18+wo04tD@I4|HTwG@YY3fxZl&So?>>#E$LBA@PAh z`c-WHLjVX0FJO6+u|T_-$B;u%XFm*_Hex}pCTz5(VBTBsuetQ_Wzz#wEx`>W@H%v< z@9zHKT`SE0BWxfk`U*Q%8zL{T*J{J=Nob>g0)XKK%ZJ$2+Qa1esqJ+c(u^YLI|zRY z0CDhDET2U{VRQ=N&)Bv4C&+2`P>5R0(4d9i8&}!&y0A;scM;J5KgO6wG{tnxRo}_3 z2Y+vJsbE#u1r&A?AgWm?FA%O=_fb!2xp=K(e`@&9#5e@N+sg12Obt@*9w0#}t<^YZf zMW<)R87eqK*IZTC>Y9q7NU?R9vu-oY~~7oYh(a?pdwP?zo|K zxGcwYvmwFZp0S4u_V629r@MAuvF|F{TrX{VZrhA4Qm{qNCNA6BKGF(~K{C@}-*wf# zYe9xR6uuqY6wrP@VA<4a94>_nXcYwP;|u>IR?r1mWu=GJN4z^lg2ZhUM2)Bwb+8$F z_%Xm{7)6t47A-M@XccX|)hgO!TG7ErslCpav6G0dn5InpEMfl~t(qia1$Pph=@zXZ z)f3Zp-ik}yJ-l(nF`Ubcr-t)Mqy||(} z*k9uult^Dg;KbzIeg&%Mbo7>kDAr_hbS$?&_3)USOwu>8TlE<@AyrG4P%4Ju6Kr3+ z21zPqa)%{3S&9uw>8bHlR!--#va$)NRz3Nd$6ti^%Wne!4c(Qe+fTO7xZ4Wuwi)+| zf_ugE%FRD+j15)Hp+WalHy$7&q2^+dUjSZs`$Q3`lqQ+ZJAUHjhdLdzF`1-R3Ob+FY|HEi*% z4*gbJJ#`_pnl$49a@4At){#wRr1C-X26RvZK-2_>o2${^j9#Ul**qEJc`MIlF@1t* z{D?Zwb~L_Bu2212<3DIjm?lEz9c15WI#jt1DvMLny--(1aUIz&TGma*){fz%k)MLe1(>+XXWo?fkf*e+hLpV)2dtI#dVF>D7S34Y(bxco=Db z0Z}LQ;j}#n+!8dRLe1#i*w}|K$=X`lRhzk`0c_;rxK)tMV*|JUJbSw3ZE|C(wbe=3 zZ+vD`1fu^bK_u)o6ZaVVSSfRa>_rn`4GmT7de{=gu#X`h*bjaHh(TYUB}2lXfoF(T z$Phj3@oF;(v9qRX?tJewcwM3uoo51sj)!5e6NdR}REM&Qj;^KA-WgA@-!#=V&?~4g zCd%|Gz+GGMNNFp4ld!gOGBx&O>WB;pwbnsbEDH2kybA9=vtu5g9mldwW*lBp&Mj5linQtVBRULaMMV6TfX*_2U);Q8P7&=-xaHm>(r1sV+t2c;mf9;pSAX} z-*4Tx&Rad>jTF3*8Ez+{)2KnmzB;LxaIIAEdA~T?2>vs zo@eLVp4ZfnsTKFVP7HiIUSJebdq1CdiBN0O-ndH8r5&jF)zaH$~QP3R4T z9qH=qyX7oVjiNi4gXKL7`Yb*+=w4``9|LSeZXo9%Q03V%Q>E1dl^giYv!1ZA?&_pS z%4u3epw-YGC5m=DHx9`WMma89v2s0!4@$#_WEs!hQ0SO?332O9oA6EOXdfRFE?K|l zeAoFNxx8vC``51S=uH%41OVCkVF;#&xQpK@qMxue-Orl-2M20s3;SVrP$LlbAKkUq zTd%pFFz2#uP3Xs^jkvZUYUMttREddAgNdP?$;5-(AJ{RJU|(6*I;q&x@}gy?66$gQ zK@S>J?zeAY6=?^bOz5(To^J^iaKwLMrw*;d$QBB1+_+J7-ZlJe#=V3O-+X$kw2wf_d8Mdcao3EOdfu_}0GHY|iHmeisDpBd3IF%=1YA!hbo%$q;6( z3^|7gIF6RPhpvwdv9sZNz*K!D+i3A=2CaBQ5r8wIJgv+lo*^0I?4q}lEGvDf+YHn^ zYKofKbW31z$%cT%^Q^Jq{L`H(EZrBK=MF)yvPFj+1`v9(%RFW87_kAvPV#%_!IPMaX95$({=7d#U zYaRRVrr>(oTh^y^DBykAa58^nTvD`9QZ)JfcN`5S5d0KSE2->5EkEC)p_W|OE>seE zj7kPo6>kMhXvQ*MOBWPQ3A^f$h&kTpf2fwlrH4myej?)mcRjFg+mE06y_ z=zLpT0<^wSTH5;NQ?}2jH=e}s+}8V-P%CGHSYD#OQHGn`)YzD^7j+-{e5@$RdKeU^ zwFnp)hc!rs$Kb4xJD|EHk=WVU9qSJDcJ+qhxqN5}T6BD8S038gsUZ=y{wboid6#1wcv zgsg<7QNZ*TpT*7|g;C>I$Cr`^S)-+EeIBcfEO zm)>}Hk%g!RU+DBB7?r++*A0e6hY#dD65K|}Jfj|_=oK38Tb*B{p+R62Cj z>w(X_#ii?k&pdt`+Om!ZB94B)E;)fxjJ~C6+^OL%s_v`xSw3>Rjj8e~=npxAW zkGnJ8SA$;-&iH!@{+Z0vXBRf&c&j delta 2694 zcmaJ@eN0=|6@T~H&o&tI{oyab48nNf!*&*+2@FD^+aQf07&DD%abK{ZX2z!X84{SL zu>OIPwrkRyrD@l!qfMiXwTTjWlhSRflugRkR$W&q(USMavh1&AQl*upnwCk^&bfw+ zRZ}1Gk8{u0JNKTC`!1Ye=aYqhcR2DTe11FkUgB!?mBN^bsi2$smc!XpM0% z%$hG0J8@xHw7I!>DyGWFY)~$Fe8jZ!`BW;Anntb**W5!;2OldDCwbVyBp)G_@n)E? z)D>5g@CyV@1T7GEw+4CzXNGi;X8@dNYFu`5Rl0l z#+Lm?UKOj^hAY*~+eso6J2!Py6UP61{&#E1fD5*8B76#cVQT|cTwxbsc9UWsMQHI8 z>4&7fp7~*ag$vf~#{)v&WLPJ8T+75`hDncS3|st2LeJpI@H({78N%jP;84Ny^}4 z*?@@oFNLQ%H{8;GjQq)TDx)P*ajtVxHmnQj+rqu@lzEu zYy|c{8icbIHC|pg?NAGi(tSBc{pr+HVtNey==Yw0BC`t|m7N}OswPf;4C}@#`2?=8 zC8Cvnre#l54l!F7azq*4tqQRi1e_UpmBF1lU-r7Qnn9U+JF9{ox7z8X%4nzB!N(^} zOR6ihWS&astxNWWR!rMe>$elJ?>w;jiB3!#=3pQYP~lbg7uh&mcaOSGprI-owpe^h zn@?tRLz&kp&o4vJ)8HRR=~BUlr&*0B(wq$VvjiszUO>QxTOoQ8UhuT!Q-2!fIW6-f zT=$%@&frr2w*)coN7>=(Hs*er_+yfjXAx zSK;HD1$GRs)mLVhYQN4J$qj#l;5-73mlugXLqHDs8o^ue58n(s4TtJDTx!qDUQ`>{ zQ8HSxPtNO^^lWQNn~iT8|A0&^2&#=!07e za95Gu+N?$=l~k1c!ASEGdnNmR^M|sHdJ{*U2VQUcRtaT&>l`=$HU8w$pAgrKuq<63 z%k>Lw;6WZ`nL-ObOpsXd<0#D?!S|8+3MNOKjMyCIGnYZi8}}ioMZALO|L#a^rh|B; zl4RQ$fs0@Zv^0vniyga2F5HPd;LIe3FkOX-z?62MQ}H#6aDf*_{Hb|7D=ree3U!^&$Um2|7dy`}3vHag2;;jRsi6Gu zYXrX{5P|&~&h0vBc^B9EBwXq2fy2A|okwV_$OlcQCBHOl3bU{rV6$@A1h3gD3z8eH zBS~R1F<@keT~^$%YO8{?Eq2+GBV%3u3aeY1u|?#FX{Q{<%WuXO&f6smn|Hd}Sw7tF z>Q_x+v;W~}676UY8KzYF@HECG9#LNR6!>O$L*tSy7-*xv9o&jS{x-t@rSR8wWQvIgf@F9ruF<$g1j&LcU>jF zRfWg;M#mx}(XqjOj}1r0qT$E-heHElqk2<-{~sNE zd~l*~Y;ZUPKkjWh$|unQj}aIIza;n}0VNu%$cz6B1@F_?BEL!e+Xy0hpKs3|@kw9= zMav!55*eK8nM5)w)T5tsj>bT4dc(U-rF7=;&(Xa_FFb|t&t>UWrSq8mR!QYcrjrH7 zlt1}{$IK@Rju+gpwcKj&*ra#dZtC5nx7}{&+NA3suWzsWsnyAww(X+7@o+^mQi+Gu zBKpo~TS7WFWM3odBEe;XpAx)7@Ed~n2=3CQJF-9|!4vq?={sgcT9=v9yKXTnuJr=1 z(z4!dSDM$|W`qKZvY)LNS19|~dYc0oH4fz<`?RK5Y0eFu1&9p!DiY|1KTQxPSSFZ+ zXNEdz#h1?jt;KDhLCUbv{nxKylP|!Xp{D%5B9l5!e-29aM%@lpd*AmpnKi%U{DW)z SN3QMnC0hM$+zij{_525+^Og+& diff --git a/manager-solution/manager/__pycache__/validate_config.cpython-313.pyc b/manager-solution/manager/__pycache__/validate_config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd09e44e58afb80385c98a32874d69f6016e7ea0 GIT binary patch literal 975 zcmah{&1(}u6o0cj*(RGrs)pKzsxf*Ar7fa`wthg-R@*`=*;Wb(E=#gaHzc!Rc2iAH z)k~`gw%DSg;I&>w&_BVW8bO`rRy_0;h<|`HN!At-d@%FgyqVwoy^m>x!zyqYKRceU zAb>q_@C3JwMw%Pjpn-;_fwnZFsCkyC#I-anPhpBr}CjtCixvA&`~jGRgP9lKC=Et?Fs z9fQk8!O9x0+5QIur3%AE!y@zqM+W$E?(l6DYSELgGOw3jEWPaCIa7`HJ-+Z{_~G!z z*rQADj=k@B+w7q>Fc@|*dcqhD}e4R@?f z-Jkk~BcE|(W9}=Ceh-EkoB^0$*9EbzCjwOToQeTbF;5n1RihW}tXXLFK(>h6%u(Qo zQ8aa(sanNx&Eohy%Y6g1Tfn;n+<%z^%LVf)JM}xT4}!Z8*ah_`oU1E7 l6kgB#0 Dict[str, Any]: + result = dict(base or {}) + result.update(override or {}) + return result + + +def resolve_placeholders(value: Any, variables: Dict[str, Any]) -> Any: + if isinstance(value, str): + return value.format(**variables) + if isinstance(value, dict): + return { + resolve_placeholders(key, variables): resolve_placeholders(item, variables) + for key, item in value.items() + } + if isinstance(value, list): + return [resolve_placeholders(item, variables) for item in value] + return value + + +def merge_defaults(config: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]: + defaults = merge_dict(DEFAULTS, config.get("defaults")) + merged = dict(user) + + merged["id"] = str(user["id"]) + merged["port"] = int(user["port"]) + variables = {"id": merged["id"], "port": merged["port"]} + + merged["environment"] = merge_dict( + resolve_placeholders(defaults.get("environment", {}), variables), + resolve_placeholders(user.get("environment"), variables), + ) + merged["environment"].setdefault("CUSTOM_USER", merged["id"]) + merged["volumes"] = merge_dict( + resolve_placeholders(defaults.get("volumes", {}), variables), + resolve_placeholders(user.get("volumes"), variables), + ) + merged["extra_hosts"] = merge_dict( + resolve_placeholders(defaults.get("extra_hosts", {}), variables), + resolve_placeholders(user.get("extra_hosts"), variables), + ) + merged["tmpfs"] = merge_dict( + resolve_placeholders(defaults.get("tmpfs", {}), variables), + resolve_placeholders(user.get("tmpfs"), variables), + ) + merged["dns"] = resolve_placeholders(user.get("dns", defaults.get("dns", [])), variables) + merged["mem_limit"] = user.get("mem_limit", defaults.get("mem_limit")) + merged["memswap_limit"] = user.get("memswap_limit", defaults.get("memswap_limit")) + merged["privileged"] = user.get("privileged", defaults.get("privileged", False)) + + if not merged.get("container_name"): + prefix = defaults.get("container_prefix", "fis-") + merged["container_name"] = f"{prefix}{merged['id']}" + + if not merged["volumes"]: + merged["volumes"] = {f"v-conf-{merged['id']}": "/config"} + + return merged + + +def validate_users(users: Iterable[Dict[str, Any]]) -> None: + seen_ids = set() + seen_ports = set() + seen_names = set() + + for user in users: + uid = user["id"] + port = user["port"] + name = user["container_name"] + mount_targets = list((user.get("volumes") or {}).values()) + + if uid in seen_ids: + raise ValueError(f"Duplicate user id: {uid}") + if port in seen_ports: + raise ValueError(f"Duplicate port: {port}") + if name in seen_names: + raise ValueError(f"Duplicate container_name: {name}") + if not user["volumes"]: + raise ValueError(f"User {uid} must define at least one volume") + if len(mount_targets) != len(set(mount_targets)): + raise ValueError(f"User {uid} has duplicate volume mount targets") + if "PASSWORD" not in user["environment"] or not user["environment"]["PASSWORD"]: + raise ValueError(f"User {uid} must define environment.PASSWORD") + + seen_ids.add(uid) + seen_ports.add(port) + seen_names.add(name) + + +def normalize_config(raw: Dict[str, Any]) -> Dict[str, Any]: + raw_users = raw.get("users", []) + if not raw_users: + raise ValueError("Configuration must contain at least one user") + + users = [merge_defaults(raw, user) for user in raw_users] + validate_users(users) + + return {"defaults": merge_dict(DEFAULTS, raw.get("defaults")), "users": users} + + +def compact_config(raw: Dict[str, Any]) -> Dict[str, Any]: + normalized = normalize_config(raw) + defaults = merge_dict(DEFAULTS, raw.get("defaults")) + compact_users = [] + + for user in normalized["users"]: + user_id = user["id"] + variables = {"id": user_id, "port": user["port"]} + resolved_default_env = resolve_placeholders(defaults.get("environment", {}), variables) + resolved_default_volumes = resolve_placeholders(defaults.get("volumes", {}), variables) + resolved_default_extra_hosts = resolve_placeholders(defaults.get("extra_hosts", {}), variables) + resolved_default_tmpfs = resolve_placeholders(defaults.get("tmpfs", {}), variables) + resolved_default_dns = resolve_placeholders(defaults.get("dns", []), variables) + default_container_name = f"{defaults.get('container_prefix', 'fis-')}{user_id}" + + compact_user: Dict[str, Any] = { + "id": user_id, + "port": user["port"], + } + + if user.get("container_name") and user["container_name"] != default_container_name: + compact_user["container_name"] = user["container_name"] + + environment = { + key: value + for key, value in (user.get("environment") or {}).items() + if resolved_default_env.get(key) != value and not (key == "CUSTOM_USER" and value == user_id) + } + if environment: + compact_user["environment"] = environment + + volumes = { + key: value + for key, value in (user.get("volumes") or {}).items() + if resolved_default_volumes.get(key) != value + } + if volumes: + compact_user["volumes"] = volumes + + extra_hosts = { + key: value + for key, value in (user.get("extra_hosts") or {}).items() + if resolved_default_extra_hosts.get(key) != value + } + if extra_hosts: + compact_user["extra_hosts"] = extra_hosts + + tmpfs = { + key: value + for key, value in (user.get("tmpfs") or {}).items() + if resolved_default_tmpfs.get(key) != value + } + if tmpfs: + compact_user["tmpfs"] = tmpfs + + if user.get("dns") != resolved_default_dns and user.get("dns") is not None: + compact_user["dns"] = user["dns"] + if user.get("mem_limit") != defaults.get("mem_limit"): + compact_user["mem_limit"] = user.get("mem_limit") + if user.get("memswap_limit") != defaults.get("memswap_limit"): + compact_user["memswap_limit"] = user.get("memswap_limit") + if user.get("privileged") != defaults.get("privileged", False): + compact_user["privileged"] = user.get("privileged", False) + + compact_users.append(compact_user) + + return {"defaults": defaults, "users": compact_users} + + +def load_config(path: str) -> Dict[str, Any]: + with open(path, encoding="utf-8") as handle: + raw = json.load(handle) + return normalize_config(raw) diff --git a/manager-solution/manager/manager.py b/manager-solution/manager/manager.py index 1548e7b..3c2f0a5 100644 --- a/manager-solution/manager/manager.py +++ b/manager-solution/manager/manager.py @@ -12,9 +12,10 @@ import os import socket import threading import time -from typing import Any, Dict, Iterable, Optional +from typing import Any, Dict, Optional import docker +from config_model import load_config CONFIG_PATH = os.getenv("CONFIG_PATH", "config.json") IMAGE_NAME = os.getenv("IMAGE_NAME", "fis") @@ -23,23 +24,12 @@ GATEWAY_PORT = int(os.getenv("GATEWAY_PORT", "0")) INACTIVITY_SECONDS = int(os.getenv("INACTIVITY_SECONDS", "10")) IDLE_CHECK_INTERVAL = int(os.getenv("IDLE_CHECK_INTERVAL", "10")) STARTUP_TIMEOUT_SECONDS = int(os.getenv("STARTUP_TIMEOUT_SECONDS", "60")) -STARTUP_STABILIZATION_SECONDS = float(os.getenv("STARTUP_STABILIZATION_SECONDS", "5")) TCP_CONNECT_TIMEOUT_SECONDS = float(os.getenv("TCP_CONNECT_TIMEOUT_SECONDS", "1.5")) +HTTP_READY_PATH = os.getenv("HTTP_READY_PATH", "/") +HTTP_READY_TIMEOUT_SECONDS = float(os.getenv("HTTP_READY_TIMEOUT_SECONDS", "1.5")) INITIAL_REQUEST_TIMEOUT_SECONDS = float(os.getenv("INITIAL_REQUEST_TIMEOUT_SECONDS", "3")) MAX_REQUEST_HEADER_BYTES = int(os.getenv("MAX_REQUEST_HEADER_BYTES", "65536")) -DEFAULTS: Dict[str, Any] = { - "container_prefix": "fis-", - "environment": {}, - "volumes": {}, - "mem_limit": None, - "memswap_limit": None, - "privileged": False, - "tmpfs": {}, - "dns": [], - "extra_hosts": {}, -} - _docker_client = None user_activity: Dict[str, float] = {} users_by_id: Dict[str, Dict[str, Any]] = {} @@ -69,104 +59,6 @@ def get_user_container_lock(user_id: str) -> threading.Lock: return user_container_locks[user_id] -def merge_dict(base: Dict[str, Any], override: Optional[Dict[str, Any]]) -> Dict[str, Any]: - result = dict(base or {}) - result.update(override or {}) - return result - - -def resolve_placeholders(value: Any, variables: Dict[str, Any]) -> Any: - if isinstance(value, str): - return value.format(**variables) - if isinstance(value, dict): - return { - resolve_placeholders(key, variables): resolve_placeholders(item, variables) - for key, item in value.items() - } - if isinstance(value, list): - return [resolve_placeholders(item, variables) for item in value] - return value - - -def merge_defaults(config: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]: - defaults = merge_dict(DEFAULTS, config.get("defaults")) - merged = dict(user) - - merged["id"] = str(user["id"]) - merged["port"] = int(user["port"]) - variables = {"id": merged["id"], "port": merged["port"]} - - merged["environment"] = merge_dict( - resolve_placeholders(defaults.get("environment", {}), variables), - resolve_placeholders(user.get("environment"), variables), - ) - merged["volumes"] = merge_dict( - resolve_placeholders(defaults.get("volumes", {}), variables), - resolve_placeholders(user.get("volumes"), variables), - ) - merged["extra_hosts"] = merge_dict( - resolve_placeholders(defaults.get("extra_hosts", {}), variables), - resolve_placeholders(user.get("extra_hosts"), variables), - ) - merged["tmpfs"] = merge_dict( - resolve_placeholders(defaults.get("tmpfs", {}), variables), - resolve_placeholders(user.get("tmpfs"), variables), - ) - merged["dns"] = resolve_placeholders(user.get("dns", defaults.get("dns", [])), variables) - merged["mem_limit"] = user.get("mem_limit", defaults.get("mem_limit")) - merged["memswap_limit"] = user.get("memswap_limit", defaults.get("memswap_limit")) - merged["privileged"] = user.get("privileged", defaults.get("privileged", False)) - - if not merged.get("container_name"): - prefix = defaults.get("container_prefix", "fis-") - merged["container_name"] = f"{prefix}{merged['id']}" - - if not merged["volumes"]: - merged["volumes"] = {f"v-conf-{merged['id']}": "/config"} - - return merged - - -def validate_users(users: Iterable[Dict[str, Any]]) -> None: - seen_ids = set() - seen_ports = set() - seen_names = set() - - for user in users: - uid = user["id"] - port = user["port"] - name = user["container_name"] - - if uid in seen_ids: - raise ValueError(f"Duplicate user id: {uid}") - if port in seen_ports: - raise ValueError(f"Duplicate port: {port}") - if name in seen_names: - raise ValueError(f"Duplicate container_name: {name}") - if not user["volumes"]: - raise ValueError(f"User {uid} must define at least one volume") - if "PASSWORD" not in user["environment"] or not user["environment"]["PASSWORD"]: - raise ValueError(f"User {uid} must define environment.PASSWORD") - - seen_ids.add(uid) - seen_ports.add(port) - seen_names.add(name) - - -def load_config(path: str = CONFIG_PATH) -> Dict[str, Any]: - with open(path, encoding="utf-8") as handle: - raw = json.load(handle) - - raw_users = raw.get("users", []) - if not raw_users: - raise ValueError("Configuration must contain at least one user") - - users = [merge_defaults(raw, user) for user in raw_users] - validate_users(users) - - return {"defaults": merge_dict(DEFAULTS, raw.get("defaults")), "users": users} - - def get_container_ip(container_name: str) -> Optional[str]: """Get IP address of a running container.""" try: @@ -193,6 +85,30 @@ def is_port_open(host: str, port: int, timeout_seconds: float = TCP_CONNECT_TIME return False +def is_http_ready(host: str, port: int, path: str = HTTP_READY_PATH) -> bool: + try: + with socket.create_connection((host, port), timeout=HTTP_READY_TIMEOUT_SECONDS) as sock: + sock.settimeout(HTTP_READY_TIMEOUT_SECONDS) + request = ( + f"GET {path or '/'} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + "Connection: close\r\n" + "\r\n" + ) + sock.sendall(request.encode("ascii")) + response = sock.recv(4096) + except OSError: + return False + + status_line = response.split(b"\r\n", 1)[0] + parts = status_line.split() + if len(parts) < 2 or not parts[1].isdigit(): + return False + + status_code = int(parts[1]) + return status_code in {200, 204, 301, 302, 303, 307, 308, 401, 403} + + async def read_initial_http_request(reader) -> bytes: buffer = bytearray() while len(buffer) < MAX_REQUEST_HEADER_BYTES: @@ -300,20 +216,16 @@ def extract_user_from_http_request(data: bytes, known_users: Dict[str, Dict[str, def wait_until_ready(container_name: str) -> bool: - stabilized = False + waiting_for_http = False deadline = time.time() + STARTUP_TIMEOUT_SECONDS while time.time() < deadline: ip = get_container_ip(container_name) if ip and is_port_open(ip, APP_PORT): - if STARTUP_STABILIZATION_SECONDS > 0 and not stabilized: - log( - f"[{container_name}] Port {APP_PORT} is open, waiting " - f"{STARTUP_STABILIZATION_SECONDS}s for backend stabilization" - ) - time.sleep(STARTUP_STABILIZATION_SECONDS) - stabilized = True - continue - return True + if is_http_ready(ip, APP_PORT): + return True + if not waiting_for_http: + log(f"[{container_name}] Port {APP_PORT} is open, waiting for HTTP readiness") + waiting_for_http = True time.sleep(0.5) return False @@ -364,7 +276,7 @@ def start_user_container(user: Dict[str, Any]): extra_hosts=user.get("extra_hosts") or None, tty=True, stdin_open=True, - restart_policy={"Name": "no"}, + restart_policy={"Name": "always"}, ) except docker.errors.APIError as exc: if "already in use" not in str(exc).lower(): diff --git a/manager-solution/manager/test_manager.py b/manager-solution/manager/test_manager.py deleted file mode 100644 index 71f3353..0000000 --- a/manager-solution/manager/test_manager.py +++ /dev/null @@ -1,236 +0,0 @@ -import importlib.util -import json -import sys -import tempfile -import types -import unittest -from pathlib import Path - - -class FakeNotFound(Exception): - pass - - -class FakeAPIError(Exception): - pass - - -class FakeContainer: - def __init__(self, status="running"): - self.stop_calls = [] - self.start_calls = 0 - self.reload_calls = 0 - self.status = status - - def reload(self): - self.reload_calls += 1 - - def stop(self, timeout=10): - self.stop_calls.append(timeout) - - def start(self): - self.start_calls += 1 - self.status = "running" - - -class FakeContainers: - def __init__(self, container=None): - self.container = container or FakeContainer() - self.requested_names = [] - self.run_calls = [] - self.raise_not_found = False - self.raise_api_error_on_run = None - - def get(self, name): - self.requested_names.append(name) - if self.raise_not_found: - self.raise_not_found = False - raise FakeNotFound(name) - return self.container - - def run(self, image, **kwargs): - self.run_calls.append((image, kwargs)) - if self.raise_api_error_on_run is not None: - raise self.raise_api_error_on_run - return self.container - - -class FakeVolumes: - def __init__(self): - self.created = [] - self.existing = set() - - def get(self, name): - if name not in self.existing: - raise FakeNotFound(name) - return name - - def create(self, name): - self.created.append(name) - self.existing.add(name) - return name - - -class FakeDockerClient: - def __init__(self): - self.containers = FakeContainers() - self.volumes = FakeVolumes() - - -def load_manager_module(): - fake_client = FakeDockerClient() - fake_docker = types.SimpleNamespace( - from_env=lambda: fake_client, - errors=types.SimpleNamespace(NotFound=FakeNotFound, APIError=FakeAPIError), - ) - sys.modules["docker"] = fake_docker - - module_path = Path(__file__).with_name("manager.py") - spec = importlib.util.spec_from_file_location("manager_under_test", module_path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - module._docker_client = fake_client - return module, fake_client - - -class ManagerConfigTests(unittest.TestCase): - def setUp(self): - self.manager, self.fake_client = load_manager_module() - - def write_config(self, payload): - temp = tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") - with temp: - json.dump(payload, temp) - return temp.name - - def test_load_config_applies_defaults_and_placeholders(self): - path = self.write_config( - { - "defaults": { - "container_prefix": "fis-", - "volumes": {"v-conf-{id}": "/config"}, - "environment": {"PUID": "1000", "PGID": "1000"}, - "mem_limit": "3g", - "privileged": True, - }, - "users": [ - { - "id": "mario", - "port": 3101, - "environment": {"PASSWORD": "secret"}, - } - ], - } - ) - - config = self.manager.load_config(path) - user = config["users"][0] - - self.assertEqual(user["container_name"], "fis-mario") - self.assertEqual(user["volumes"], {"v-conf-mario": "/config"}) - self.assertEqual(user["environment"]["PUID"], "1000") - self.assertEqual(user["environment"]["PASSWORD"], "secret") - self.assertEqual(user["mem_limit"], "3g") - self.assertTrue(user["privileged"]) - - def test_load_config_rejects_duplicate_ports(self): - path = self.write_config( - { - "users": [ - {"id": "user1", "port": 3001, "environment": {"PASSWORD": "a"}}, - {"id": "user2", "port": 3001, "environment": {"PASSWORD": "b"}}, - ] - } - ) - - with self.assertRaisesRegex(ValueError, "Duplicate port"): - self.manager.load_config(path) - - def test_load_config_requires_password(self): - path = self.write_config({"users": [{"id": "user1", "port": 3001}]}) - - with self.assertRaisesRegex(ValueError, "PASSWORD"): - self.manager.load_config(path) - - def test_stop_user_container_uses_configured_container_name(self): - user = {"id": "user1", "container_name": "custom-name"} - - self.manager.stop_user_container(user) - - self.assertEqual(self.fake_client.containers.requested_names, ["custom-name"]) - self.assertEqual(self.fake_client.containers.container.stop_calls, [10]) - - def test_start_user_container_recovers_from_concurrent_create_conflict(self): - container = FakeContainer(status="created") - self.fake_client.containers.container = container - self.fake_client.containers.raise_not_found = True - self.fake_client.containers.raise_api_error_on_run = FakeAPIError( - 'Conflict. The container name "/fis-user1" is already in use' - ) - self.manager.wait_until_ready = lambda name: True - - user = { - "id": "user1", - "container_name": "fis-user1", - "volumes": {"v-conf-user1": "/config"}, - "environment": {"PASSWORD": "secret"}, - "privileged": False, - } - - self.manager.start_user_container(user) - - self.assertEqual(self.fake_client.containers.requested_names, ["fis-user1", "fis-user1"]) - self.assertEqual(container.start_calls, 1) - self.assertEqual(self.fake_client.volumes.created, ["v-conf-user1"]) - - def test_extract_user_from_host_header(self): - request = ( - b"GET / HTTP/1.1\r\n" - b"Host: user2.example.local\r\n" - b"Connection: keep-alive\r\n" - b"\r\n" - ) - users = {"user2": {"id": "user2"}} - - user, rewritten, redirect_target = self.manager.extract_user_from_http_request(request, users) - - self.assertEqual(user["id"], "user2") - self.assertEqual(rewritten, request) - self.assertIsNone(redirect_target) - - def test_extract_user_from_path_returns_redirect_target(self): - request = ( - b"GET /user1/websockify?token=abc HTTP/1.1\r\n" - b"Host: localhost:8080\r\n" - b"Upgrade: websocket\r\n" - b"\r\n" - ) - users = {"user1": {"id": "user1"}} - - user, rewritten, redirect_target = self.manager.extract_user_from_http_request(request, users) - - self.assertIsNone(user) - self.assertEqual(rewritten, request) - self.assertEqual(redirect_target, "http://user1.localhost:8080/websockify?token=abc") - - def test_wait_until_ready_applies_stabilization_delay(self): - sleep_calls = [] - clock = {"now": 0.0} - - self.manager.STARTUP_TIMEOUT_SECONDS = 10 - self.manager.STARTUP_STABILIZATION_SECONDS = 5 - self.manager.get_container_ip = lambda name: "10.0.0.2" - self.manager.is_port_open = lambda host, port: True - self.manager.time = types.SimpleNamespace( - time=lambda: clock["now"], - sleep=lambda seconds: (sleep_calls.append(seconds), clock.__setitem__("now", clock["now"] + seconds)), - ) - - ready = self.manager.wait_until_ready("fis-user1") - - self.assertTrue(ready) - self.assertEqual(sleep_calls, [5]) - - -if __name__ == "__main__": - unittest.main() diff --git a/manager-solution/manager/validate_config.py b/manager-solution/manager/validate_config.py new file mode 100644 index 0000000..3d09516 --- /dev/null +++ b/manager-solution/manager/validate_config.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +import json +import os +import sys + +from config_model import load_config + + +def main() -> int: + path = sys.argv[1] if len(sys.argv) > 1 else os.getenv("CONFIG_PATH", "config.json") + config = load_config(path) + json.dump(config, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/prova-docker-compose.yml.txt b/prova-docker-compose.yml.txt deleted file mode 100755 index 26b361a..0000000 --- a/prova-docker-compose.yml.txt +++ /dev/null @@ -1,59 +0,0 @@ -version: '3.8' - -services: - fis: - image: fis - container_name: fis - ports: - - "3000:3000" - environment: - - PUID=1000 - - PGID=1000 - - CUSTOM_USER=tomas - - PASSWORD=tommal - - LC_ALL=it_IT.UTF-8 - - TZ=Europe/Rome - mem_limit: 3g - memswap_limit: 5g - privileged: true - tmpfs: - - /tmp:exec,mode=777 - dns: - - 10.100.0.8 - - 10.200.100.1 - extra_hosts: - - "scsv28.sanmarco.lan:10.200.100.108" - volumes: - - v-conf:/config - -volumes: - v-conf: - - - - - -# ONLY IN PRODUCTION -#version: '3.8' -# -#services: -# fis: -# image: fis -# container_name: fis -# ports: -# - "3003:3000" -# environment: -# - PUID=1000 -# - PGID=1000 -# - PASSWORD=tommal -# mem_limit: 700m -# memswap_limit: 2g -# privileged: true -# volumes: -# - v-fis:/app/Fiscality -# - v-config:/config -# -#volumes: -# v-fis: -# v-config: -# diff --git a/stop.sh b/uninstall.sh similarity index 100% rename from stop.sh rename to uninstall.sh