From 4a550fe4b961eb9f6e485562e37d05984b9c93ed Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 1 Jun 2026 17:04:05 -0400 Subject: [PATCH] feat: render proxy routes under openresty and add per-backend tests Bumps the openresty sidecar to 0.12.0, which introduces the openresty.route..* label family for path-based routing. The openresty-vhosts plugin now emits route labels for non-web containers targeted by a proxy:route:set, mirroring the traefik and caddy patterns. Adds integration tests for every rendering backend (proxy-route-{nginx,traefik,caddy,openresty}.bats covering basic routing, --strip-prefix, and removal) plus per-ingress-class scheduler-k3s tests (proxy-route-k3s-{nginx,traefik}.bats covering the same three cases against a real cluster). The nginx file additionally asserts the rendered nginx config carries the WebSocket upgrade headers in each route's location block. --- docs/networking/proxy-management.md | 6 +- .../docker-args-process-deploy | 19 ++- plugins/openresty-vhosts/internal-functions | 29 +++++ plugins/proxy/route_subcommands.go | 2 +- tests/unit/proxy-route-caddy.bats | 88 ++++++++++++++ tests/unit/proxy-route-k3s-nginx.bats | 111 ++++++++++++++++++ tests/unit/proxy-route-k3s-traefik.bats | 110 +++++++++++++++++ tests/unit/proxy-route-nginx.bats | 107 +++++++++++++++++ tests/unit/proxy-route-openresty.bats | 88 ++++++++++++++ tests/unit/proxy-route-traefik.bats | 90 ++++++++++++++ tests/unit/proxy-route.bats | 14 --- 11 files changed, 644 insertions(+), 20 deletions(-) create mode 100644 tests/unit/proxy-route-caddy.bats create mode 100644 tests/unit/proxy-route-k3s-nginx.bats create mode 100644 tests/unit/proxy-route-k3s-traefik.bats create mode 100644 tests/unit/proxy-route-nginx.bats create mode 100644 tests/unit/proxy-route-openresty.bats create mode 100644 tests/unit/proxy-route-traefik.bats diff --git a/docs/networking/proxy-management.md b/docs/networking/proxy-management.md index f5bb0a279..2e28861b1 100644 --- a/docs/networking/proxy-management.md +++ b/docs/networking/proxy-management.md @@ -196,7 +196,7 @@ See the [port management documentation](/docs/networking/port-management.md) for By default, every HTTP/HTTPS request is routed to the `web` process declared in the app's `Procfile`. Use `proxy:route:*` to route specific path prefixes to a different process within the same app - for example, sending `/api/v0/*` to an `api` process or `/ws` to a `websocket` process. The `web` process always handles every other path as an implicit catch-all. > [!NOTE] -> Path-based routing is rendered by the `nginx`, `traefik`, `caddy`, and `k3s` proxy backends. The `openresty` and `haproxy` backends accept and store route declarations, but the routes are inert (no labels are emitted) because the sidecar images do not yet support path-prefix vocabulary. Routes stored ahead of a backend switch will activate automatically once the proxy is changed to a rendering backend. Tracked at [dokku/openresty-docker-proxy#137](https://github.com/dokku/openresty-docker-proxy/issues/137) and the `haproxy-vhosts` sidecar. +> Path-based routing is rendered by the `nginx`, `traefik`, `caddy`, `openresty`, and `k3s` proxy backends. The `haproxy` backend accepts and stores route declarations but the routes are inert (no labels are emitted) because the `byjg/easy-haproxy` sidecar image only supports host-based routing. Routes stored ahead of a backend switch will activate automatically once the proxy is changed to a rendering backend. #### Adding a route @@ -271,9 +271,9 @@ dokku proxy:route:set node-js-app api /api/v0/admin #### Caveats per backend - **nginx**: route changes take effect immediately on the next `proxy-build-config` reload (which `proxy:route:*` invokes automatically). -- **traefik** and **caddy**: routes are applied via Docker labels on the target containers. Containers must be recreated for new or removed labels to take effect. The command prints a notice instructing you to run `dokku ps:rebuild ` to recreate containers. +- **traefik**, **caddy**, and **openresty**: routes are applied via Docker labels on the target containers. Containers must be recreated for new or removed labels to take effect. The command prints a notice instructing you to run `dokku ps:rebuild ` to recreate containers. - **k3s**: route changes take effect on the next deploy when the chart is re-rendered. -- **openresty** and **haproxy**: routes are stored but inert under these backends. They activate automatically if you switch to a rendering backend (`nginx`, `traefik`, `caddy`, or `k3s`). +- **haproxy**: routes are stored but inert under this backend. They activate automatically if you switch to a rendering backend (`nginx`, `traefik`, `caddy`, `openresty`, or `k3s`). - **WebSocket** traffic is forwarded transparently on `nginx`, `traefik`, `caddy`, and `k3s` backends - no additional configuration is required. - A route to a process that is scaled to `0` will resolve to no upstream until the process is scaled up (`dokku ps:scale =1`). `proxy:route:set` warns when this is the case. diff --git a/plugins/openresty-vhosts/docker-args-process-deploy b/plugins/openresty-vhosts/docker-args-process-deploy index 2b34911d8..efd9fd57a 100755 --- a/plugins/openresty-vhosts/docker-args-process-deploy +++ b/plugins/openresty-vhosts/docker-args-process-deploy @@ -12,11 +12,26 @@ trigger-openresty-vhosts-docker-args-process-deploy() { local include_dir output value DATA local STDIN=$(cat) - if [[ "$PROC_TYPE" != "web" ]]; then + if [[ "$(plugn trigger proxy-type "$APP")" != "openresty" ]] && [[ "$(plugn trigger proxy-type "$APP")" != "nginx" ]]; then return fi - if [[ "$(plugn trigger proxy-type "$APP")" != "openresty" ]] && [[ "$(plugn trigger proxy-type "$APP")" != "nginx" ]]; then + if [[ "$PROC_TYPE" != "web" ]]; then + # Non-web processes are only labeled when at least one path-based route + # targets the process. Otherwise openresty should ignore the container. + local route_proc + local has_route=false + while IFS='|' read -r route_proc _ _ _; do + if [[ "$route_proc" == "$PROC_TYPE" ]]; then + has_route=true + break + fi + done < <(plugn trigger proxy-routes-list "$APP") + if [[ "$has_route" != "true" ]]; then + echo -n "$STDIN" + return + fi + fn-openresty-route-labels "$APP" "$PROC_TYPE" "$STDIN" return fi diff --git a/plugins/openresty-vhosts/internal-functions b/plugins/openresty-vhosts/internal-functions index d3100f708..d555dacb9 100755 --- a/plugins/openresty-vhosts/internal-functions +++ b/plugins/openresty-vhosts/internal-functions @@ -11,6 +11,35 @@ fn-get-pagesize() { "$PLUGIN_CORE_AVAILABLE_PATH/nginx-vhosts/pagesize" } +fn-openresty-route-labels() { + declare desc="emits per-route openresty labels for a non-web container" + declare APP="$1" PROC_TYPE="$2" STDIN="$3" + local output route_proc route_port route_path route_strip route_index=0 + + output="" + while IFS='|' read -r route_proc route_port route_path route_strip; do + [[ -z "$route_proc" ]] && continue + [[ "$route_proc" != "$PROC_TYPE" ]] && continue + + output="$output --label 'openresty.route.${route_index}.path-prefix=${route_path}'" + output="$output --label 'openresty.route.${route_index}.port=${route_port}'" + if [[ "$route_strip" == "1" ]]; then + output="$output --label 'openresty.route.${route_index}.strip-prefix=true'" + fi + route_index=$((route_index + 1)) + done < <(plugn trigger proxy-routes-list "$APP") + + local proxy_labels_file_path=$(fn-proxy-get-labels-file-path "openresty" "$APP") + if [[ -f "$proxy_labels_file_path" ]]; then + while read -r line; do + [[ -z "$line" ]] && continue + output="$output --label '$line'" + done <"$proxy_labels_file_path" + fi + + echo -n "$STDIN $output" +} + fn-openresty-access-log-format() { declare desc="get the raw per-app access log format" declare APP="$1" diff --git a/plugins/proxy/route_subcommands.go b/plugins/proxy/route_subcommands.go index ee98e8c5e..8238569c6 100644 --- a/plugins/proxy/route_subcommands.go +++ b/plugins/proxy/route_subcommands.go @@ -105,7 +105,7 @@ func CommandRouteClear(appName string) error { // labels are baked in at create-time). func warnIfRebuildRequired(appName string) { switch getComputedProxyType(appName) { - case "traefik", "caddy": + case "traefik", "caddy", "openresty": common.LogWarn(fmt.Sprintf( "Routes are applied via container labels under the %s proxy backend; run `dokku ps:rebuild %s` to recreate containers and pick up the change", getComputedProxyType(appName), appName, diff --git a/tests/unit/proxy-route-caddy.bats b/tests/unit/proxy-route-caddy.bats new file mode 100644 index 000000000..a5fad8eec --- /dev/null +++ b/tests/unit/proxy-route-caddy.bats @@ -0,0 +1,88 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + global_setup + dokku nginx:stop + dokku caddy:start + create_app + dokku proxy:set "$TEST_APP" caddy +} + +teardown() { + dokku proxy:route:clear "$TEST_APP" >/dev/null 2>&1 || true + destroy_app + dokku caddy:stop + dokku nginx:start + global_teardown +} + +@test "(proxy-route:caddy) basic routing - /api/v0 hits api, / hits web" { + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" +} + +@test "(proxy-route:caddy) --strip-prefix toggles upstream path visibility" { + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001" + assert_success + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "" "404" + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001 --strip-prefix" + assert_success + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" +} + +@test "(proxy-route:caddy) removing a route falls back to web" { + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001 --strip-prefix" + assert_success + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" + + run /bin/bash -c "dokku proxy:route:remove $TEST_APP /api/v0" + assert_success + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + + run curl --connect-to "${TEST_APP}.${DOKKU_DOMAIN}:80:localhost:80" -kSso /tmp/route-removed-body "http://${TEST_APP}.${DOKKU_DOMAIN}:80/api/v0/Procfile" + run /bin/bash -c "grep -c 'python3 -m http.server' /tmp/route-removed-body || true" + assert_output "0" +} + +add_api_process_callback() { + local APP="$1" + local APP_REPO_DIR="$2" + echo "api: python3 -m http.server 5001" >>"$APP_REPO_DIR/Procfile" +} diff --git a/tests/unit/proxy-route-k3s-nginx.bats b/tests/unit/proxy-route-k3s-nginx.bats new file mode 100644 index 000000000..f6200babb --- /dev/null +++ b/tests/unit/proxy-route-k3s-nginx.bats @@ -0,0 +1,111 @@ +#!/usr/bin/env bats + +load test_helper + +TEST_APP="rdmtestapp" + +setup() { + uninstall_k3s || true + global_setup + dokku nginx:stop + export KUBECONFIG="/etc/rancher/k3s/k3s.yaml" +} + +teardown() { + global_teardown + dokku nginx:start + uninstall_k3s || true +} + +@test "(proxy-route:k3s-nginx) basic routing - /api/v0 hits api, / hits web" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=nginx install_k3s + + run /bin/bash -c "dokku apps:create $TEST_APP" + assert_success + + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001" + echo "output: $output" + echo "status: $status" + assert_success + + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" +} + +@test "(proxy-route:k3s-nginx) --strip-prefix renders rewrite-target Ingress and strips upstream path" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=nginx install_k3s + + run /bin/bash -c "dokku apps:create $TEST_APP" + assert_success + + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001 --strip-prefix" + assert_success + + # The chart should emit an additional Ingress carrying the rewrite-target + # annotation when --strip-prefix is set. + run /bin/bash -c "kubectl get ingress -o json | jq -r '.items[].metadata.annotations | select(.[\"nginx.ingress.kubernetes.io/rewrite-target\"] != null) | .[\"nginx.ingress.kubernetes.io/rewrite-target\"]'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "/\$2" + + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" +} + +@test "(proxy-route:k3s-nginx) removing a route drops the extra Ingress paths" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=nginx install_k3s + + run /bin/bash -c "dokku apps:create $TEST_APP" + assert_success + + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001 --strip-prefix" + assert_success + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" + + run /bin/bash -c "dokku proxy:route:remove $TEST_APP /api/v0" + assert_success + + # The rewrite-target Ingress should be gone after removal. + run /bin/bash -c "kubectl get ingress -o json | jq -r '[.items[] | select(.metadata.annotations[\"nginx.ingress.kubernetes.io/rewrite-target\"] != null)] | length'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "0" +} + +add_api_process_callback() { + local APP="$1" + local APP_REPO_DIR="$2" + echo "api: python3 -m http.server 5001" >>"$APP_REPO_DIR/Procfile" +} diff --git a/tests/unit/proxy-route-k3s-traefik.bats b/tests/unit/proxy-route-k3s-traefik.bats new file mode 100644 index 000000000..3b20847c3 --- /dev/null +++ b/tests/unit/proxy-route-k3s-traefik.bats @@ -0,0 +1,110 @@ +#!/usr/bin/env bats + +load test_helper + +TEST_APP="rdmtestapp" + +setup() { + uninstall_k3s || true + global_setup + dokku nginx:stop + export KUBECONFIG="/etc/rancher/k3s/k3s.yaml" +} + +teardown() { + global_teardown + dokku nginx:start + uninstall_k3s || true +} + +@test "(proxy-route:k3s-traefik) basic routing - /api/v0 hits api, / hits web" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=traefik install_k3s + + run /bin/bash -c "dokku apps:create $TEST_APP" + assert_success + + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001" + echo "output: $output" + echo "status: $status" + assert_success + + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" +} + +@test "(proxy-route:k3s-traefik) --strip-prefix renders stripPrefix Middleware and strips upstream path" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=traefik install_k3s + + run /bin/bash -c "dokku apps:create $TEST_APP" + assert_success + + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001 --strip-prefix" + assert_success + + # The chart should emit a StripPrefix Middleware CRD when --strip-prefix + # is set. + run /bin/bash -c "kubectl get middleware.traefik.io -o json | jq -r '.items[].spec.stripPrefix.prefixes[]?' | head -1" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "/api/v0" + + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" +} + +@test "(proxy-route:k3s-traefik) removing a route drops the stripPrefix Middleware" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=traefik install_k3s + + run /bin/bash -c "dokku apps:create $TEST_APP" + assert_success + + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001 --strip-prefix" + assert_success + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" + + run /bin/bash -c "dokku proxy:route:remove $TEST_APP /api/v0" + assert_success + + run /bin/bash -c "kubectl get middleware.traefik.io -o json | jq -r '[.items[] | select(.spec.stripPrefix != null)] | length'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "0" +} + +add_api_process_callback() { + local APP="$1" + local APP_REPO_DIR="$2" + echo "api: python3 -m http.server 5001" >>"$APP_REPO_DIR/Procfile" +} diff --git a/tests/unit/proxy-route-nginx.bats b/tests/unit/proxy-route-nginx.bats new file mode 100644 index 000000000..1c9846a3e --- /dev/null +++ b/tests/unit/proxy-route-nginx.bats @@ -0,0 +1,107 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + global_setup + create_app +} + +teardown() { + dokku proxy:route:clear "$TEST_APP" >/dev/null 2>&1 || true + destroy_app + global_teardown +} + +@test "(proxy-route:nginx) basic routing - /api/v0 hits api, / hits web" { + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001" + echo "output: $output" + echo "status: $status" + assert_success + + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" +} + +@test "(proxy-route:nginx) --strip-prefix toggles upstream path visibility" { + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + echo "output: $output" + echo "status: $status" + assert_success + + # Without --strip-prefix, /api/v0/Procfile is forwarded as-is to api, + # which does not have an /api/v0/Procfile file on disk. + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001" + assert_success + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "" "404" + + # With --strip-prefix, /api/v0/Procfile becomes /Procfile upstream and api + # returns the Procfile content. + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001 --strip-prefix" + assert_success + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" +} + +@test "(proxy-route:nginx) removing a route falls back to web" { + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001 --strip-prefix" + assert_success + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" + + run /bin/bash -c "dokku proxy:route:remove $TEST_APP /api/v0" + assert_success + + # Web does not serve /api/v0/Procfile; we just assert it is no longer the + # api-served Procfile content. + run curl --connect-to "${TEST_APP}.${DOKKU_DOMAIN}:80:localhost:80" -kSso /tmp/route-removed-body "http://${TEST_APP}.${DOKKU_DOMAIN}:80/api/v0/Procfile" + run /bin/bash -c "grep -c 'python3 -m http.server' /tmp/route-removed-body || true" + assert_output "0" +} + +@test "(proxy-route:nginx) websocket upgrade headers are present in rendered config" { + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + assert_success + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001" + assert_success + + run /bin/bash -c "dokku nginx:show-config $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "location /api/v0" + assert_output_contains "proxy_http_version 1.1" + assert_output_contains "proxy_set_header Upgrade \$http_upgrade" + assert_output_contains "proxy_set_header Connection \$http_connection" +} + +add_api_process_callback() { + local APP="$1" + local APP_REPO_DIR="$2" + # api serves /app's working directory on port 5001. GET /Procfile returns + # the literal Procfile bytes, which web.py does not serve - this gives the + # HTTP assertions a clear marker for which process handled the request. + echo "api: python3 -m http.server 5001" >>"$APP_REPO_DIR/Procfile" +} diff --git a/tests/unit/proxy-route-openresty.bats b/tests/unit/proxy-route-openresty.bats new file mode 100644 index 000000000..b55ba58cc --- /dev/null +++ b/tests/unit/proxy-route-openresty.bats @@ -0,0 +1,88 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + global_setup + dokku nginx:stop + dokku openresty:start + create_app + dokku proxy:set "$TEST_APP" openresty +} + +teardown() { + dokku proxy:route:clear "$TEST_APP" >/dev/null 2>&1 || true + destroy_app + dokku openresty:stop + dokku nginx:start + global_teardown +} + +@test "(proxy-route:openresty) basic routing - /api/v0 hits api, / hits web" { + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" +} + +@test "(proxy-route:openresty) --strip-prefix toggles upstream path visibility" { + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001" + assert_success + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "" "404" + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001 --strip-prefix" + assert_success + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" +} + +@test "(proxy-route:openresty) removing a route falls back to web" { + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001 --strip-prefix" + assert_success + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" + + run /bin/bash -c "dokku proxy:route:remove $TEST_APP /api/v0" + assert_success + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + + run curl --connect-to "${TEST_APP}.${DOKKU_DOMAIN}:80:localhost:80" -kSso /tmp/route-removed-body "http://${TEST_APP}.${DOKKU_DOMAIN}:80/api/v0/Procfile" + run /bin/bash -c "grep -c 'python3 -m http.server' /tmp/route-removed-body || true" + assert_output "0" +} + +add_api_process_callback() { + local APP="$1" + local APP_REPO_DIR="$2" + echo "api: python3 -m http.server 5001" >>"$APP_REPO_DIR/Procfile" +} diff --git a/tests/unit/proxy-route-traefik.bats b/tests/unit/proxy-route-traefik.bats new file mode 100644 index 000000000..9007e28a3 --- /dev/null +++ b/tests/unit/proxy-route-traefik.bats @@ -0,0 +1,90 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + global_setup + dokku nginx:stop + dokku traefik:start + create_app + dokku proxy:set "$TEST_APP" traefik +} + +teardown() { + dokku proxy:route:clear "$TEST_APP" >/dev/null 2>&1 || true + destroy_app + dokku traefik:stop + dokku nginx:start + global_teardown +} + +@test "(proxy-route:traefik) basic routing - /api/v0 hits api, / hits web" { + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001" + echo "output: $output" + echo "status: $status" + assert_success + + # Routes are applied via container labels - rebuild so the api container + # restarts with the new labels attached. + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" +} + +@test "(proxy-route:traefik) --strip-prefix toggles upstream path visibility" { + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001" + assert_success + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "" "404" + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001 --strip-prefix" + assert_success + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" +} + +@test "(proxy-route:traefik) removing a route falls back to web" { + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" add_api_process_callback + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1 api=1" + assert_success + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 5001 --strip-prefix" + assert_success + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "python3 -m http.server" + + run /bin/bash -c "dokku proxy:route:remove $TEST_APP /api/v0" + assert_success + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + + run curl --connect-to "${TEST_APP}.${DOKKU_DOMAIN}:80:localhost:80" -kSso /tmp/route-removed-body "http://${TEST_APP}.${DOKKU_DOMAIN}:80/api/v0/Procfile" + run /bin/bash -c "grep -c 'python3 -m http.server' /tmp/route-removed-body || true" + assert_output "0" +} + +add_api_process_callback() { + local APP="$1" + local APP_REPO_DIR="$2" + echo "api: python3 -m http.server 5001" >>"$APP_REPO_DIR/Procfile" +} diff --git a/tests/unit/proxy-route.bats b/tests/unit/proxy-route.bats index 0f8c73619..a3f22dfa7 100644 --- a/tests/unit/proxy-route.bats +++ b/tests/unit/proxy-route.bats @@ -218,20 +218,6 @@ teardown() { assert_output "true" } -@test "(proxy:route:set) stores routes under openresty backend even though they are inert" { - dokku proxy:set "$TEST_APP" openresty >/dev/null 2>&1 || skip "openresty proxy not available" - - run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0" - echo "output: $output" - echo "status: $status" - assert_success - - run /bin/bash -c "dokku proxy:route:report $TEST_APP" - echo "output: $output" - echo "status: $status" - assert_output_contains "/api/v0 -> api:5000" -} - @test "(proxy:route:set) stores routes under haproxy backend even though they are inert" { dokku proxy:set "$TEST_APP" haproxy >/dev/null 2>&1 || skip "haproxy proxy not available"