Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -209,6 +243,7 @@ services:
- DUPLO_ADMIN_TOKEN=${DUPLO_ADMIN_TOKEN:-}

volumes:
nginx_conf:
mongo_data:
platform_data:
extension_studio_data:
Expand Down
8 changes: 6 additions & 2 deletions nginx/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
57 changes: 57 additions & 0 deletions tests/test-ui-nginx.sh
Original file line number Diff line number Diff line change
@@ -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<n>` 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 ]
Loading