diff --git a/docker-compose.yml b/docker-compose.yml index 1959051..2519b60 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -137,16 +137,50 @@ services: init-perms: condition: service_completed_successfully + # Renders nginx/default.conf into a volume with the DNS resolver this runtime actually uses. + # + # It cannot be done in the UI image: that image's entrypoint is `nginx` directly — no + # /docker-entrypoint.d/, no /etc/nginx/templates, and it runs as nonroot 65532 — so the usual + # envsubst-on-a-template trick is unavailable. nginx itself has never read /etc/resolv.conf for its + # `resolver` directive, so the address has to be substituted before nginx starts. + # + # Asking the network rather than branching on the runtime: any container on this network is handed + # the correct nameserver by the runtime itself, so busybox reads its own /etc/resolv.conf and there + # is no docker-vs-podman case to keep in sync (verified: 127.0.0.11 on docker, the network gateway + # on podman). + # + # $$ not $: compose interpolates $VAR in `command:` before the shell sees it, and an interpolated-away + # variable yields an empty resolver and an nginx that will not load its config at all. + init-nginx-conf: + image: busybox:1.36 + command: + - sh + - -c + - | + R="$$(awk '/^nameserver/{print $$2; exit}' /etc/resolv.conf)" + [ -n "$$R" ] || { echo "no nameserver in /etc/resolv.conf" >&2; exit 1; } + sed "s|__RESOLVER__|$$R|" /tpl/default.conf > /out/default.conf + chmod 0644 /out/default.conf + echo "nginx resolver = $$R" + volumes: + - ./nginx/default.conf:/tpl/default.conf:ro + - nginx_conf:/out + duplo-ui: image: quay.io/duplocloud/duplo-ai-helpdesk-ui:${UI_TAG} restart: unless-stopped ports: - "${UI_PORT:-4200}:8080" volumes: - # Published image serves the SPA only; this conf proxies API prefixes to the studio (same-origin, no CORS). - - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + # Published image serves the SPA only; the rendered conf proxies API prefixes to the studio + # (same-origin, no CORS). The whole directory is replaced: the image ships only default.conf in + # there, and tests/test-ui-nginx.sh guards that assumption. + - nginx_conf:/etc/nginx/conf.d:ro depends_on: - - duplo-ai-studio + duplo-ai-studio: + condition: service_started + init-nginx-conf: + condition: service_completed_successfully xterm: image: quay.io/duplocloud/duplo-xterm:${XTERM_TAG:-main-d323e0b} @@ -209,6 +243,7 @@ services: - DUPLO_ADMIN_TOKEN=${DUPLO_ADMIN_TOKEN:-} volumes: + nginx_conf: mongo_data: platform_data: extension_studio_data: diff --git a/nginx/default.conf b/nginx/default.conf index e2ce12a..1402ed1 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -36,9 +36,13 @@ server { # of the container, so recreating the studio (any compose change to it OR to mongo, which it # depends_on) strands this container proxying to a dead address — every API call 502s and the # login page renders with no auth methods, since it is those 502s that decide what to show. - # Docker's embedded DNS at 127.0.0.11 plus a variable in proxy_pass forces a fresh lookup. + # The resolver address differs per runtime — Docker's embedded DNS is 127.0.0.11, podman's + # aardvark-dns sits on the network gateway (10.89.0.x, varying by network) — so it is NOT + # hard-coded here. __RESOLVER__ is substituted at startup by the init-nginx-conf service, which + # reads the value out of its own /etc/resolv.conf on this same network. Hard-coding either + # address serves the SPA fine on the other runtime while every proxied API call fails. # $request_uri is required: a variable proxy_pass no longer forwards the URI implicitly. - resolver 127.0.0.11 valid=10s ipv6=off; + resolver __RESOLVER__ valid=10s ipv6=off; set $studio http://duplo-ai-studio:60021; proxy_pass $studio$request_uri; proxy_http_version 1.1; diff --git a/tests/test-ui-nginx.sh b/tests/test-ui-nginx.sh new file mode 100755 index 0000000..8b574e1 --- /dev/null +++ b/tests/test-ui-nginx.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Checks that the UI's nginx config is runtime-agnostic — specifically that the DNS resolver it proxies +# through is discovered at run time rather than hard-coded to one container runtime's address. +# +# Static checks only: nothing here starts the stack or needs docker/podman installed, so it runs the +# same on a docker laptop, a podman box and CI. +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 +PASS=0; FAIL=0 +t() { printf ' %s … ' "$1"; } +ok() { echo "ok"; PASS=$((PASS+1)); } +bad() { echo "FAIL: $1"; FAIL=$((FAIL+1)); } + +CONF=nginx/default.conf +COMPOSE=docker-compose.yml + +echo "UI nginx resolver is runtime-agnostic:" + +# 127.0.0.11 is Docker's embedded DNS and does not exist under podman, whose aardvark-dns sits on the +# network gateway (10.89.0.1 on a default install, but it varies by network). Hard-coding either one +# breaks the other runtime: the SPA still serves, every proxied API call fails, and the login page is +# simply inert — the worst shape of broken. +t "nginx conf does not hard-code a runtime-specific DNS address" +if grep -qE 'resolver[[:space:]]+(127\.0\.0\.11|10\.[0-9]+\.[0-9]+\.[0-9]+)' "$CONF"; then + bad "resolver is pinned to one runtime's DNS: $(grep -E '^[[:space:]]*resolver' "$CONF" | tr -d ' ')" +else ok; fi + +t "nginx conf carries a resolver placeholder for substitution" +grep -qE '^[[:space:]]*resolver[[:space:]]+__RESOLVER__' "$CONF" && ok || bad "no __RESOLVER__ placeholder" + +t "compose defines an init service that renders the conf" +grep -qE '^[[:space:]]*init-nginx-conf:' "$COMPOSE" && ok || bad "no init-nginx-conf service" + +# Extract ONLY the service definition, from its own key to the next top-level service. A plain +# `grep -A` also matches the depends_on reference in duplo-ui and runs off the end into the next +# service, which made this file's first draft report xterm's ${XTERM_TAG} as an unescaped variable. +svc_block() { awk '/^ init-nginx-conf:$/{f=1;next} f&&/^ [a-z]/{exit} f' "$COMPOSE"; } + +t "the init service discovers DNS from its own resolv.conf, not a literal" +if svc_block | grep -q '/etc/resolv.conf'; then ok +else bad "init does not read /etc/resolv.conf"; fi + +t "duplo-ui waits for the render to complete before starting" +if grep -A22 'duplo-ui:' "$COMPOSE" | grep -A2 'init-nginx-conf:' | grep -q 'service_completed_successfully'; then ok +else bad "duplo-ui does not depend on init-nginx-conf completing"; fi + +# Compose interpolates $VAR inside `command:` before the shell ever sees it, so a shell variable must be +# written $$VAR. Getting this wrong yields an empty resolver and nginx fails to load the conf at all. +t "shell variables in the init command are escaped for compose interpolation" +BLOCK="$(svc_block)" +if printf '%s' "$BLOCK" | grep -qE '(^|[^$])\$[A-Za-z{(]'; then + bad "unescaped \$ in the init command — compose will interpolate it away" +else ok; fi + +echo +echo "$PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]