diff --git a/.env.example b/.env.example index 3ce0c3f..4073af1 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,19 @@ # localhost URL points the backend at its own empty ports. Containers reach # each other by service name over the compose network instead. +# --- Ports (container only) ----------------------------------------------- +# The image serves 3000 and 8000 at once - 3000 because that is what Dokploy +# routes a domain to, 8000 because the README, the vite dev proxy and +# docker-compose all use it. Serving both means the container works whichever +# one the platform points at. +# +# PORTS the comma-separated pair to bind. PORT pins a single port instead and +# takes precedence, e.g. PORT=8080 serves only 8080. +# +# Neither affects running uvicorn directly for local development. +# PORTS=3000,8000 +# PORT=8080 + # --- Persistence (IMPORTANT in Docker) ------------------------------------ # The three directories the app WRITES to at runtime. The defaults point inside # the repo/image and are right for local development; in a container each one diff --git a/Dockerfile b/Dockerfile index 7c68159..89e3a1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,6 +44,7 @@ COPY app ./app COPY cli ./cli COPY scripts ./scripts COPY data ./data +COPY serve.py . # Pristine copies of everything the app also WRITES to, kept at a path that is # never mounted over. @@ -68,31 +69,29 @@ RUN mkdir -p /app/.bundled \ # file) name them properly; this is the floor, not the recommended setup. VOLUME ["/app/data", "/app/app/intelligence/artifacts"] -# The port uvicorn binds. Overridable because Dokploy assigns the container -# port per service - the frontend image answers on both 80 and 3000 for the -# same reason. A single process cannot listen twice, so this is the knob: -# PORT=3000 in the service's environment, if you standardise on 3000. -ENV PORT=8000 -EXPOSE 8000 - -# Liveness only. /api/health always answers 200 - it reports Postgres and -# Ollama in the body as "degraded" rather than failing - which is deliberate -# here: a healthcheck that went red whenever Postgres blinked would have -# Dokploy restart a perfectly healthy API in a loop. +# Answers on BOTH ports, the same way the frontend image does (nginx.conf has +# `listen 80; listen 3000;`). 3000 is what Dokploy routes a domain to; 8000 is +# what this project's README, the vite dev proxy and docker-compose all use. +# Serving both means the container works whichever one the platform is pointed +# at, instead of returning 502 from a perfectly healthy process. # -# The 10s timeout is not padding: the handler probes Ollama over HTTP with a -# 3s timeout of its own, so an unreachable Ollama makes every check take ~3s. +# serve.py binds both sockets and hands them to one uvicorn - see the note +# there. To pin a single port, set PORT (PORT=8080 serves only 8080); to change +# the pair, set PORTS. +ENV PORTS=3000,8000 +EXPOSE 3000 8000 + +# Liveness only, and passes if EITHER port answers. /api/health always returns +# 200 - it reports Postgres and Ollama in the body as "degraded" rather than +# failing - which is deliberate: a check that went red whenever Postgres blinked +# would have Dokploy restart a perfectly healthy API in a loop. +# +# The 10s timeout is not padding: the handler probes Ollama over HTTP with a 3s +# timeout of its own, so an unreachable Ollama makes every check take ~3s. # start-period covers first boot, where the venv is still cold. HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ - CMD python -c "import os,urllib.request;urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','8000')+'/api/health',timeout=8)" || exit 1 + CMD ["python", "serve.py", "--healthcheck"] -# `exec` matters: without it the shell stays PID 1 and Docker's SIGTERM never -# reaches uvicorn, so every deploy waits out the 10s kill timeout instead of -# shutting down cleanly. sh is only here to expand $PORT. -# -# --proxy-headers/--forwarded-allow-ips: this container is never reached -# directly - nginx (and, in prod, Dokploy's Traefik) sit in front of it. -# Without these, uvicorn ignores X-Forwarded-Proto and reports every request as -# plain http, so any redirect or generated absolute URL would downgrade an -# https request. -CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000} --proxy-headers --forwarded-allow-ips '*'"] +# Exec form: python is PID 1, so Docker's SIGTERM reaches it directly and a +# redeploy shuts down cleanly instead of waiting out the 10s kill timeout. +CMD ["python", "serve.py"] diff --git a/README.md b/README.md index 6737d32..c5371c0 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,28 @@ permission check; `user` holds the product/store/inventory permissions. `AUTH_ENABLED=false` disables all of it for local work — never in a deployment. See the Authentication section of `../DEPLOYMENT.md` for the full endpoint map. +## Ports + +The container answers on **3000 and 8000 at the same time**, the same way the +frontend image answers on 80 and 3000. 3000 is what Dokploy routes a domain to; +8000 is what this README, the vite dev proxy and `docker-compose.yml` use. Both +being live means the deployment works whichever one it is pointed at, instead of +returning 502 from a healthy container. + +`serve.py` is what makes that possible - uvicorn's CLI binds a single `--port`, +but `Server.run()` accepts a list of pre-bound sockets, so it is still one +process. If one port is unavailable it logs and carries on with the other; it +exits non-zero only when nothing is listening. + +```bash +python serve.py # 3000 and 8000 +PORT=8080 python serve.py # only 8080 (PORT pins a single port) +PORTS=80,3000 python serve.py # a different pair +``` + +For local development `uvicorn app.main:app --reload --port 8000` is still the +normal thing to run - one port is all you need, and it gives you autoreload. + ## Persistence: the two volumes a deployment needs Most state lives in Postgres, but three things are written to the filesystem, diff --git a/docker-compose.yml b/docker-compose.yml index 6e5a1f5..f924573 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,9 @@ services: OLLAMA_BASE_URL: http://host.docker.internal:11434 extra_hosts: - "host.docker.internal:host-gateway" + # The container listens on 3000 and 8000 at once (see serve.py), so either + # side of this mapping can change without touching the image. 8000 is + # published because vite.config.js proxies /api to 127.0.0.1:8000. ports: - "8000:8000" volumes: diff --git a/serve.py b/serve.py new file mode 100644 index 0000000..8699d13 --- /dev/null +++ b/serve.py @@ -0,0 +1,154 @@ +""" +Container entry point: serve the API on every port the platform might route to. + +The frontend image answers on both 80 and 3000 (``listen 80; listen 3000;`` in +nginx.conf) so it works whatever port the deployment is configured to hit. This +does the same for the API, which otherwise has to guess: Dokploy routes a domain +to one container port, this project's own README, vite.config.js proxy and +docker-compose mapping all say 8000, and picking wrong produces a 502 with a +perfectly healthy container behind it. + +uvicorn's CLI binds a single ``--port``, but ``Server.run()`` accepts a list of +already-bound sockets, so one process can listen on several. That is what this +does - no extra worker, no second process to supervise. + +Usage:: + + python serve.py # binds PORTS (default "3000,8000") + PORT=8080 python serve.py # binds only 8080 + python serve.py --healthcheck # probe mode, used by HEALTHCHECK + +Run it directly rather than through ``uvicorn app.main:app`` when you need the +multi-port behaviour; the plain uvicorn command still works for local +development where one port is all anyone wants. +""" +from __future__ import annotations + +import logging +import os +import socket +import sys +import urllib.error +import urllib.request + +logger = logging.getLogger("serve") + +# Both of the ports this project actually uses anywhere: 3000 because that is +# what the platform routes a domain to by default, 8000 because the README, +# the vite dev proxy and docker-compose all target it. +DEFAULT_PORTS = "3000,8000" + +HOST = os.getenv("HOST", "0.0.0.0") + + +def configured_ports() -> list[int]: + """ + The ports to bind, in order. + + ``PORT`` wins over ``PORTS`` and is treated as an explicit single choice: + setting it means "serve here", not "serve here as well". ``PORTS`` takes a + comma-separated list for the both-at-once case. + """ + raw = os.getenv("PORT") or os.getenv("PORTS") or DEFAULT_PORTS + + ports: list[int] = [] + for chunk in raw.split(","): + chunk = chunk.strip() + if not chunk: + continue + try: + port = int(chunk) + except ValueError: + logger.warning("Ignoring non-numeric port %r in %r", chunk, raw) + continue + if not 1 <= port <= 65535: + logger.warning("Ignoring out-of-range port %d", port) + continue + if port not in ports: # binding the same port twice would fail + ports.append(port) + + if not ports: + logger.error("No usable port in %r - falling back to %s", raw, DEFAULT_PORTS) + return [int(p) for p in DEFAULT_PORTS.split(",")] + return ports + + +def _bind(port: int) -> socket.socket | None: + """Bind one listening socket, or return None with the reason logged.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind((HOST, port)) + except OSError as exc: + # Not fatal on its own. If the platform only routes to one of these, + # losing the other (already in use, not permitted) should not take the + # service down - _run() fails only when nothing at all is listening. + logger.warning("Could not bind %s:%d - %s", HOST, port, exc) + sock.close() + return None + sock.listen(2048) + sock.set_inheritable(True) + return sock + + +def _run() -> int: + import uvicorn + + ports = configured_ports() + sockets = [s for s in (_bind(p) for p in ports) if s is not None] + + if not sockets: + logger.error( + "Could not bind any of %s. The API is not listening; exiting so the " + "platform restarts or reports the container as failed.", + ", ".join(str(p) for p in ports), + ) + return 1 + + bound = [s.getsockname()[1] for s in sockets] + logger.info("Serving on %s port(s): %s", HOST, ", ".join(str(p) for p in bound)) + + config = uvicorn.Config( + "app.main:app", + # proxy_headers/forwarded_allow_ips: this container is never reached + # directly - nginx, and Dokploy's Traefik, sit in front of it. Without + # them uvicorn ignores X-Forwarded-Proto and reports every request as + # plain http, so any redirect or generated absolute URL would downgrade + # an https request. + proxy_headers=True, + forwarded_allow_ips="*", + ) + uvicorn.Server(config).run(sockets=sockets) + return 0 + + +def _healthcheck() -> int: + """ + Probe the API, passing if ANY configured port answers. + + Shares configured_ports() with the server so the check cannot drift from + what is actually bound - the reason this lives here rather than being a + python -c one-liner in the Dockerfile. + """ + ports = configured_ports() + for port in ports: + try: + with urllib.request.urlopen( + f"http://127.0.0.1:{port}/api/health", timeout=8 + ) as resp: + if 200 <= resp.status < 400: + return 0 + except (urllib.error.URLError, OSError, ValueError): + continue + print( + f"health: no response from any of {', '.join(str(p) for p in ports)}", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + if "--healthcheck" in sys.argv: + sys.exit(_healthcheck()) + sys.exit(_run())