diff --git a/docs/development/plugin-triggers.md b/docs/development/plugin-triggers.md index b8a393be2..5501b996b 100644 --- a/docs/development/plugin-triggers.md +++ b/docs/development/plugin-triggers.md @@ -2372,6 +2372,24 @@ set -eo pipefail; [[ $DOKKU_TRACE ]] && set -x # TODO ``` +### `proxy-routes-list` + +- Description: Emits one `process|port|path|strip` line per path-based route declared via `proxy:route:set`, sorted by descending path length. Backends use this to render path-based routes in their config or container labels. +- Invoked by: `nginx-vhosts`, `traefik-vhosts`, `caddy-vhosts`, `scheduler-k3s` +- Arguments: `$APP` +- Example: + +```shell +#!/usr/bin/env bash + +set -eo pipefail; [[ $DOKKU_TRACE ]] && set -x + +APP="$1" +while IFS='|' read -r proc port path strip; do + echo "Route ${path} -> ${proc}:${port} (strip=${strip})" +done < <(plugn trigger proxy-routes-list "$APP") +``` + ### `proxy-type` - Description: Returns the proxy type for an app diff --git a/docs/networking/proxy-management.md b/docs/networking/proxy-management.md index 260cee22f..2e28861b1 100644 --- a/docs/networking/proxy-management.md +++ b/docs/networking/proxy-management.md @@ -188,6 +188,95 @@ See the [port scheme documentation](/docs/networking/port-management.md#port-sch See the [port management documentation](/docs/networking/port-management.md) for more information on how port mappings are managed for an application. +### Path-based routing to non-web processes + +> [!IMPORTANT] +> New as of 0.39.0 + +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`, `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 + +```shell +# Route /api/v0/* to the api process on container port 5000 +dokku proxy:route:set node-js-app api /api/v0 +``` + +The `--port` flag overrides the default upstream port of `5000`: + +```shell +dokku proxy:route:set node-js-app api /api/v0 --port 5001 +``` + +The `--strip-prefix` flag strips the matched prefix before forwarding the request upstream. Use this when the process is written assuming it is mounted at root (`/users/42` instead of `/api/v0/users/42`): + +```shell +dokku proxy:route:set node-js-app api /api/v0 --port 5001 --strip-prefix +``` + +`proxy:route:set` uses set semantics: the resulting route is fully described by its arguments. Omitting `--port` resets the port to `5000`; omitting `--strip-prefix` resets the flag to off. This makes the command safely re-runnable from declarative config tools such as [dokku/docket](https://github.com/dokku/docket). + +The `web` process cannot be a route target since it serves the implicit `/` catch-all. + +#### Removing a route + +```shell +dokku proxy:route:remove node-js-app /api/v0 +``` + +Removing a path that is not registered is a successful no-op. + +#### Clearing all routes + +```shell +dokku proxy:route:clear node-js-app +``` + +#### Listing routes + +```shell +dokku proxy:route:report node-js-app +``` + +``` +=====> node-js-app proxy routes information + Route /api/v0/admin -> api:5001 + Route /api/v0 -> api:5001 + Route /ws -> websocket:8080 + Route /internal -> tools:5000 (strip) +``` + +The report supports JSON output for scripting: + +```shell +dokku proxy:route:report node-js-app --format json +``` + +#### Matching semantics + +Routes are matched longest-prefix-first. When two prefixes overlap, the longer one wins: + +```shell +dokku proxy:route:set node-js-app api /api/v0 +dokku proxy:route:set node-js-app api /api/v0/admin +``` + +- `GET /api/v0/admin/users` -> matches `/api/v0/admin` +- `GET /api/v0/anything-else` -> matches `/api/v0` +- `GET /home` -> falls through to the `web` catch-all + +#### Caveats per backend + +- **nginx**: route changes take effect immediately on the next `proxy-build-config` reload (which `proxy:route:*` invokes automatically). +- **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. +- **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. + ### Container network interface binding > Changed as of 0.11.0 diff --git a/plugins/caddy-vhosts/docker-args-process-deploy b/plugins/caddy-vhosts/docker-args-process-deploy index e9074527c..8d0076f0e 100755 --- a/plugins/caddy-vhosts/docker-args-process-deploy +++ b/plugins/caddy-vhosts/docker-args-process-deploy @@ -14,11 +14,27 @@ trigger-caddy-vhosts-docker-args-process-deploy() { local proxy_container_https_port proxy_container_https_port_candidate proxy_host_https_port_candidate local STDIN=$(cat) - if [[ "$PROC_TYPE" != "web" ]]; then + if [[ "$(plugn trigger proxy-type "$APP")" != "caddy" ]]; then return fi - if [[ "$(plugn trigger proxy-type "$APP")" != "caddy" ]]; then + if [[ "$PROC_TYPE" != "web" ]]; then + # Non-web processes are only labeled when at least one path-based route + # targets the process. Otherwise caddy-docker-proxy should ignore the + # container entirely. + 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-caddy-route-labels "$APP" "$PROC_TYPE" "$STDIN" return fi @@ -84,6 +100,21 @@ trigger-caddy-vhosts-docker-args-process-deploy() { caddy_domains="${caddy_domains// /, }" fi + # If any path-based route exists for this app, the route emission on the + # non-web container puts a `handle @` block in the site. For caddy to + # honor that, web's `reverse_proxy` must also be wrapped in a `handle` + # block (both at directive order ~210) so caddy's mutually-exclusive + # handle logic picks the route handle for matched paths and falls through + # to the catch-all handle for everything else. Without this, web's bare + # reverse_proxy (~190) catches every request before any handle runs. + local has_routes=false + while IFS='|' read -r route_proc _ _ _; do + if [[ -n "$route_proc" ]]; then + has_routes=true + break + fi + done < <(plugn trigger proxy-routes-list "$APP") + # add the labels for caddy here # prefer the https:443 mapping to http:80 mapping if [[ "$is_app_listening" == "true" ]] && [[ -n "$caddy_domains" ]]; then @@ -117,7 +148,14 @@ trigger-caddy-vhosts-docker-args-process-deploy() { proxy_container_https_port="$proxy_container_https_port_candidate" fi - output="$output --label \"caddy.reverse_proxy={{ upstreams $proxy_container_https_port }}\"" + if [[ "$has_routes" == "true" ]]; then + # The "9_" prefix orders web's catch-all handle after the route + # handles (which the non-web container emits with "0_") in the + # docker-proxy generated Caddyfile. + output="$output --label \"caddy.9_handle.0_reverse_proxy={{ upstreams $proxy_container_https_port }}\"" + else + output="$output --label \"caddy.reverse_proxy={{ upstreams $proxy_container_https_port }}\"" + fi elif [[ -n "$proxy_container_http_port" ]] || [[ -n "$proxy_container_http_port_candidate" ]]; then caddy_domains="${caddy_domains//, /:80, }" output="--label 'caddy=${caddy_domains}:80'" @@ -127,7 +165,11 @@ trigger-caddy-vhosts-docker-args-process-deploy() { proxy_container_http_port="$proxy_container_http_port_candidate" fi - output="$output --label \"caddy.reverse_proxy={{ upstreams $proxy_container_http_port }}\"" + if [[ "$has_routes" == "true" ]]; then + output="$output --label \"caddy.9_handle.0_reverse_proxy={{ upstreams $proxy_container_http_port }}\"" + else + output="$output --label \"caddy.reverse_proxy={{ upstreams $proxy_container_http_port }}\"" + fi fi if [[ "$output" == *"caddy.reverse_proxy="* ]]; then diff --git a/plugins/caddy-vhosts/internal-functions b/plugins/caddy-vhosts/internal-functions index 8a4ee43c3..003ea1d37 100755 --- a/plugins/caddy-vhosts/internal-functions +++ b/plugins/caddy-vhosts/internal-functions @@ -4,6 +4,90 @@ source "$PLUGIN_CORE_AVAILABLE_PATH/common/property-functions" set -eo pipefail [[ $DOKKU_TRACE ]] && set -x +fn-caddy-route-labels() { + declare desc="emits per-route caddy labels for a non-web container" + declare APP="$1" PROC_TYPE="$2" STDIN="$3" + local caddy_domains app_domains output route_proc route_port route_path route_strip + local matcher_name route_index=0 + + app_domains="$(plugn trigger domains-list "$APP")" + if [[ -n "$app_domains" ]]; then + caddy_domains="$(echo "$app_domains" | xargs)" + caddy_domains="${caddy_domains// /, }" + fi + + output="" + if [[ -n "$caddy_domains" ]]; then + # Mirror web's site-declarator computation in docker-args-process-deploy so + # both containers join the same Caddyfile site block. caddy treats DOMAIN + # and DOMAIN:80 as separate sites - if web declares DOMAIN:80 and we + # declare DOMAIN, caddy-docker-proxy renders two unrelated site blocks + # and the route's handle never sees port-80 traffic. + local letsencrypt_email port_map scheme host_port + local has_443_mapping=false + letsencrypt_email="$(fn-caddy-computed-letsencrypt-email)" + while read -r port_map; do + scheme="$(awk -F ':' '{ print $1 }' <<<"$port_map")" + host_port="$(awk -F ':' '{ print $2 }' <<<"$port_map")" + if [[ "$scheme" == "https" ]] || [[ "$host_port" == "443" ]]; then + has_443_mapping=true + fi + done < <(plugn trigger ports-get "$APP") + + if [[ -n "$letsencrypt_email" ]] && [[ "$has_443_mapping" == "true" ]]; then + output="--label 'caddy=${caddy_domains}'" + else + output="--label 'caddy=${caddy_domains//, /:80, }:80'" + fi + fi + + while IFS='|' read -r route_proc route_port route_path route_strip; do + [[ -z "$route_proc" ]] && continue + [[ "$route_proc" != "$PROC_TYPE" ]] && continue + + local route_slug + route_slug="$(fn-caddy-route-slug "$route_path")" + matcher_name="@${route_slug}" + # The named matcher accepts both the bare prefix and the prefix-glob form + # so requests like /api/v0 and /api/v0/users/42 both match. + output="$output --label 'caddy.${matcher_name}.path=${route_path} ${route_path}/*'" + # Use handle (not route) so the directive terminates the matched request. + # Otherwise web's bare `reverse_proxy` (at default-order ~190) runs before + # this route block (~210) and catches everything including /api/v0/*. + output="$output --label 'caddy.${route_index}_handle=${matcher_name}'" + local sub_index=0 + if [[ "$route_strip" == "1" ]]; then + output="$output --label 'caddy.${route_index}_handle.${sub_index}_uri=strip_prefix ${route_path}'" + sub_index=$((sub_index + 1)) + fi + output="$output --label \"caddy.${route_index}_handle.${sub_index}_reverse_proxy={{upstreams ${route_port}}}\"" + route_index=$((route_index + 1)) + done < <(plugn trigger proxy-routes-list "$APP") + + local proxy_labels_file_path=$(fn-proxy-get-labels-file-path "caddy" "$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-caddy-route-slug() { + declare desc="returns a deterministic kebab-case slug for a route path" + declare ROUTE_PATH="$1" + local s="${ROUTE_PATH#/}" + s="${s%/}" + s="${s//\//-}" + s="${s,,}" + if [[ -z "$s" ]]; then + s="root" + fi + echo "$s" +} + fn-caddy-logs() { declare desc="shows the logs for the caddy container" declare TAIL="$1" NUM="$2" diff --git a/plugins/nginx-vhosts/functions b/plugins/nginx-vhosts/functions index d2a11212b..6426c4b72 100755 --- a/plugins/nginx-vhosts/functions +++ b/plugins/nginx-vhosts/functions @@ -236,6 +236,33 @@ fn-nginx-vhosts-render-template() { PROXY_PORT_MAP="$(echo "$proxy_port_map" | xargs)" # trailing spaces mess up default template PROXY_UPSTREAM_PORTS="$(echo "$PROXY_UPSTREAM_PORTS" | xargs)" + # Path-based routes (proxy:route:set). Format the storage representation + # into space-separated sigil variables: PROXY_ROUTES holds the route triples + # (process|port|path|strip), PROXY_ROUTE_LISTENERS holds one + # (process|port|listener-ip,listener-ip,...) entry per unique process+port + # pair so the template can render per-route upstream blocks. + local PROXY_ROUTES="" PROXY_ROUTE_LISTENERS="" + local route_pair_seen=" " + while IFS='|' read -r route_proc route_port route_path route_strip; do + [[ -z "$route_proc" ]] && continue + PROXY_ROUTES="${PROXY_ROUTES} ${route_proc}|${route_port}|${route_path}|${route_strip}" + local route_pair="${route_proc}|${route_port}" + if [[ "$route_pair_seen" != *" ${route_pair} "* ]]; then + route_pair_seen="${route_pair_seen}${route_pair} " + local route_listeners="$(plugn trigger network-get-listeners "$APP" "$route_proc" | xargs)" + if [[ -z "$route_listeners" ]]; then + # No live containers for this process yet; emit a stub so nginx -t + # still passes during pre-validate and the upstream block renders + # without a host-not-found error at reload time. + route_listeners="127.0.0.1:${route_port}" + fi + local route_listener_csv="${route_listeners// /,}" + PROXY_ROUTE_LISTENERS="${PROXY_ROUTE_LISTENERS} ${route_proc}|${route_port}|${route_listener_csv}" + fi + done < <(plugn trigger proxy-routes-list "$APP") + PROXY_ROUTES="$(echo "$PROXY_ROUTES" | xargs)" + PROXY_ROUTE_LISTENERS="$(echo "$PROXY_ROUTE_LISTENERS" | xargs)" + local SSL_INUSE= local NONSSL_VHOSTS=$(plugn trigger domains-list "$APP") local NOSSL_SERVER_NAME=$(echo "$NONSSL_VHOSTS" | xargs) @@ -352,6 +379,7 @@ fn-nginx-vhosts-render-template() { NGINX_PORT="$PROXY_PORT" NGINX_SSL_PORT="$PROXY_SSL_PORT" PROXY_PORT="$PROXY_PORT" PROXY_SSL_PORT="$PROXY_SSL_PORT" PROXY_PORT_MAP="$PROXY_PORT_MAP" PROXY_UPSTREAM_PORTS="$PROXY_UPSTREAM_PORTS" + PROXY_ROUTES="$PROXY_ROUTES" PROXY_ROUTE_LISTENERS="$PROXY_ROUTE_LISTENERS" PROXY_X_FORWARDED_FOR="$PROXY_X_FORWARDED_FOR" PROXY_X_FORWARDED_PORT="$PROXY_X_FORWARDED_PORT" PROXY_X_FORWARDED_PROTO="$PROXY_X_FORWARDED_PROTO" diff --git a/plugins/nginx-vhosts/template_test.go b/plugins/nginx-vhosts/template_test.go index fa5beacc9..4248e5532 100644 --- a/plugins/nginx-vhosts/template_test.go +++ b/plugins/nginx-vhosts/template_test.go @@ -60,6 +60,8 @@ func defaultVars() map[string]interface{} { "PROXY_X_FORWARDED_PROTO": "$scheme", "PROXY_X_FORWARDED_SSL": "", "HTTP2_DIRECTIVE_SUPPORTED": "true", + "PROXY_ROUTES": "", + "PROXY_ROUTE_LISTENERS": "", } } @@ -265,6 +267,50 @@ func TestTemplate_XForwardedSSL(t *testing.T) { mustNotContain(t, out2, "X-Forwarded-Ssl") } +func TestTemplate_RouteLocationAndUpstream(t *testing.T) { + v := defaultVars() + v["PROXY_ROUTES"] = "api|5001|/api/v0|0" + v["PROXY_ROUTE_LISTENERS"] = "api|5001|10.0.0.3:5001,10.0.0.4:5001" + out := renderTemplate(t, v) + mustContain(t, out, "location /api/v0 {") + mustContain(t, out, "proxy_pass http://app-api-5001;") + mustNotContain(t, out, "proxy_pass http://app-api-5001/;") + mustContain(t, out, "upstream app-api-5001 {") + mustContain(t, out, "server 10.0.0.3:5001;") + mustContain(t, out, "server 10.0.0.4:5001;") +} + +func TestTemplate_RouteStripPrefix(t *testing.T) { + v := defaultVars() + v["PROXY_ROUTES"] = "api|5001|/api/v0|1" + v["PROXY_ROUTE_LISTENERS"] = "api|5001|10.0.0.3:5001" + out := renderTemplate(t, v) + mustContain(t, out, "proxy_pass http://app-api-5001/;") +} + +func TestTemplate_RouteOmittedOnRedirect(t *testing.T) { + v := defaultVars() + v["PROXY_PORT_MAP"] = "http:80:5000 https:443:5000" + v["SSL_INUSE"] = "true" + v["SSL_SERVER_NAME"] = "app.example.com" + v["PROXY_ROUTES"] = "api|5001|/api/v0|0" + v["PROXY_ROUTE_LISTENERS"] = "api|5001|10.0.0.3:5001" + out := renderTemplate(t, v) + + port80, port443, ok := strings.Cut(out, "listen [::]:443") + if !ok { + t.Fatalf("expected an https server block in output:\n%s", out) + } + mustNotContain(t, port80, "location /api/v0") + mustContain(t, port443, "location /api/v0 {") +} + +func TestTemplate_NoRoutesRendersNoRouteBlocks(t *testing.T) { + out := renderTemplate(t, defaultVars()) + mustNotContain(t, out, "upstream app-api-") + mustNotContain(t, out, "location /api/v0") +} + func TestTemplate_NginxConfDIncludeAlways(t *testing.T) { cases := []struct { name string diff --git a/plugins/nginx-vhosts/templates/nginx.conf.sigil b/plugins/nginx-vhosts/templates/nginx.conf.sigil index 62f37ee0c..e43760b6f 100644 --- a/plugins/nginx-vhosts/templates/nginx.conf.sigil +++ b/plugins/nginx-vhosts/templates/nginx.conf.sigil @@ -46,6 +46,34 @@ server { return 301 https://$host:{{ $.PROXY_SSL_PORT }}$request_uri; } {{ else }} +{{ range $route := $.PROXY_ROUTES | split " " }} +{{ if $route }} +{{ $r := $route | split "|" }} +{{ $route_proc := index $r 0 }} +{{ $route_port := index $r 1 }} +{{ $route_path := index $r 2 }} +{{ $route_strip := index $r 3 }} + location {{ $route_path }} { + proxy_pass http://{{ $.APP }}-{{ $route_proc }}-{{ $route_port }}{{ if eq $route_strip "1" }}/{{ end }}; + proxy_http_version 1.1; + proxy_connect_timeout {{ $.PROXY_CONNECT_TIMEOUT }}; + proxy_read_timeout {{ $.PROXY_READ_TIMEOUT }}; + proxy_send_timeout {{ $.PROXY_SEND_TIMEOUT }}; + proxy_buffer_size {{ $.PROXY_BUFFER_SIZE }}; + proxy_buffering {{ $.PROXY_BUFFERING }}; + proxy_buffers {{ $.PROXY_BUFFERS }}; + proxy_busy_buffers_size {{ $.PROXY_BUSY_BUFFERS_SIZE }}; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $http_connection; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For {{ $.PROXY_X_FORWARDED_FOR }}; + proxy_set_header X-Forwarded-Port {{ $.PROXY_X_FORWARDED_PORT }}; + proxy_set_header X-Forwarded-Proto {{ $.PROXY_X_FORWARDED_PROTO }}; + proxy_set_header X-Request-Start $msec; + {{ if $.PROXY_X_FORWARDED_SSL }}proxy_set_header X-Forwarded-Ssl {{ $.PROXY_X_FORWARDED_SSL }};{{ end }} + } +{{ end }} +{{ end }} location / { {{ if $.DOKKU_APP_WEB_LISTENERS }} @@ -158,3 +186,19 @@ upstream {{ $.APP }}-{{ $upstream_port }} { server {{ $listener_ip }}:{{ $upstream_port }};{{ end }} } {{ end }}{{ end }} + +{{ range $entry := $.PROXY_ROUTE_LISTENERS | split " " }} +{{ if $entry }} +{{ $e := $entry | split "|" }} +{{ $route_proc := index $e 0 }} +{{ $route_port := index $e 1 }} +{{ $route_csv := index $e 2 }} +upstream {{ $.APP }}-{{ $route_proc }}-{{ $route_port }} { +{{ if $.PROXY_KEEPALIVE }} keepalive {{ $.PROXY_KEEPALIVE }};{{ end }} +{{ range $listener := $route_csv | split "," }} +{{ $listener_list := $listener | split ":" }} +{{ $listener_ip := index $listener_list 0 }} + server {{ $listener_ip }}:{{ $route_port }};{{ end }} +} +{{ end }} +{{ end }} 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/Makefile b/plugins/proxy/Makefile index 3ddf822fb..2e640eb78 100644 --- a/plugins/proxy/Makefile +++ b/plugins/proxy/Makefile @@ -1,5 +1,5 @@ -SUBCOMMANDS = subcommands/build-config subcommands/clear-config subcommands/disable subcommands/enable subcommands/ports subcommands/ports-add subcommands/ports-clear subcommands/ports-remove subcommands/ports-set subcommands/report subcommands/set -TRIGGERS = triggers/install triggers/proxy-is-enabled triggers/proxy-type triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-delete triggers/report +SUBCOMMANDS = subcommands/build-config subcommands/clear-config subcommands/disable subcommands/enable subcommands/ports subcommands/ports-add subcommands/ports-clear subcommands/ports-remove subcommands/ports-set subcommands/report subcommands/route:clear subcommands/route:remove subcommands/route:report subcommands/route:set subcommands/set +TRIGGERS = triggers/install triggers/proxy-is-enabled triggers/proxy-routes-list triggers/proxy-type triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-delete triggers/report BUILD = commands subcommands triggers PLUGIN_NAME = proxy diff --git a/plugins/proxy/route_subcommands.go b/plugins/proxy/route_subcommands.go new file mode 100644 index 000000000..8238569c6 --- /dev/null +++ b/plugins/proxy/route_subcommands.go @@ -0,0 +1,203 @@ +package proxy + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + + "github.com/dokku/dokku/plugins/common" +) + +// CommandRouteSet handles `dokku proxy:route:set +// [--port ] [--strip-prefix]`. Uses set semantics: a given invocation +// produces a fully-determined state from its arguments (omitted flags reset +// to defaults), and the command is a no-op when the resulting state is +// byte-identical to current storage. +func CommandRouteSet(appName string, processName string, path string, port int, stripPrefix bool) error { + if err := common.VerifyAppName(appName); err != nil { + return err + } + + if processName == "" || path == "" { + return errors.New("Usage: dokku proxy:route:set [--port ] [--strip-prefix]") + } + + if port == 0 { + port = DefaultRoutePort + } + + route := Route{ + Path: path, + Process: processName, + Port: port, + StripPrefix: stripPrefix, + } + changed, err := AddRoute(appName, route) + if err != nil { + return err + } + + if scale := getProcessScale(appName, processName); scale == 0 { + common.LogWarn(fmt.Sprintf( + "Process %q is currently scaled to 0; route %s -> %s will resolve to no upstream until the process is scaled up", + processName, path, processName, + )) + } + + if !changed { + return nil + } + if err := BuildConfig(appName); err != nil { + return err + } + warnIfRebuildRequired(appName) + return nil +} + +// CommandRouteRemove handles `dokku proxy:route:remove `. +// Removing a path that does not exist is a successful no-op. +func CommandRouteRemove(appName string, path string) error { + if err := common.VerifyAppName(appName); err != nil { + return err + } + if path == "" { + return errors.New("Usage: dokku proxy:route:remove ") + } + + changed, err := RemoveRoute(appName, path) + if err != nil { + return err + } + if !changed { + return nil + } + if err := BuildConfig(appName); err != nil { + return err + } + warnIfRebuildRequired(appName) + return nil +} + +// CommandRouteClear handles `dokku proxy:route:clear `. Clearing an +// empty route set is a successful no-op. +func CommandRouteClear(appName string) error { + if err := common.VerifyAppName(appName); err != nil { + return err + } + + changed, err := ClearRoutes(appName) + if err != nil { + return err + } + if !changed { + return nil + } + if err := BuildConfig(appName); err != nil { + return err + } + warnIfRebuildRequired(appName) + return nil +} + +// warnIfRebuildRequired prints a notice for label-based proxy backends where +// the new route state is only fully applied on the next container start (the +// labels are baked in at create-time). +func warnIfRebuildRequired(appName string) { + switch getComputedProxyType(appName) { + 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, + )) + } +} + +// CommandRouteReport handles `dokku proxy:route:report [] [--format +// stdout|json]`. With no app, iterates every dokku app. +func CommandRouteReport(appName string, format string) error { + if appName == "" { + apps, err := common.DokkuApps() + if err != nil { + if errors.Is(err, common.NoAppsExist) { + common.LogWarn(err.Error()) + return nil + } + return err + } + for _, name := range apps { + if err := reportRoutesSingleApp(name, format); err != nil { + return err + } + } + return nil + } + + if err := common.VerifyAppName(appName); err != nil { + return err + } + return reportRoutesSingleApp(appName, format) +} + +type routeReport struct { + Path string `json:"path"` + Process string `json:"process"` + Port int `json:"port"` + StripPrefix bool `json:"strip_prefix"` +} + +type routeAppReport struct { + App string `json:"app"` + Routes []routeReport `json:"routes"` +} + +func reportRoutesSingleApp(appName string, format string) error { + routes, err := GetRoutes(appName) + if err != nil { + return err + } + + if format == "json" { + report := routeAppReport{App: appName, Routes: make([]routeReport, 0, len(routes))} + for _, r := range routes { + report.Routes = append(report.Routes, routeReport{ + Path: r.Path, + Process: r.Process, + Port: r.Port, + StripPrefix: r.StripPrefix, + }) + } + b, err := json.MarshalIndent(report, "", " ") + if err != nil { + return err + } + fmt.Println(string(b)) + return nil + } + + common.LogInfo2Quiet(appName + " proxy routes information") + if len(routes) == 0 { + common.LogVerbose("Routes: (none)") + return nil + } + + keys := make([]string, 0, len(routes)) + for _, r := range routes { + keys = append(keys, r.Path) + } + sort.Strings(keys) + for _, key := range keys { + for _, r := range routes { + if r.Path != key { + continue + } + strip := "" + if r.StripPrefix { + strip = " (strip)" + } + common.LogVerbose(fmt.Sprintf("Route %s -> %s:%d%s", r.Path, r.Process, r.Port, strip)) + break + } + } + return nil +} diff --git a/plugins/proxy/routes.go b/plugins/proxy/routes.go new file mode 100644 index 000000000..571b0f5b4 --- /dev/null +++ b/plugins/proxy/routes.go @@ -0,0 +1,242 @@ +package proxy + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/dokku/dokku/plugins/common" +) + +// DefaultRoutePort is the upstream container port assumed when --port is +// omitted on proxy:route:set. It matches the existing default container port +// for the web process. +const DefaultRoutePort = 5000 + +// Route describes a single path-prefix routing rule. +type Route struct { + Path string + Process string + Port int + StripPrefix bool +} + +// Encode returns the colon-delimited storage representation: +// "::" where is 0 or 1. +func (r Route) Encode() string { + strip := "0" + if r.StripPrefix { + strip = "1" + } + return fmt.Sprintf("%s:%d:%s", r.Process, r.Port, strip) +} + +// Slug returns a deterministic kebab-case identifier derived from the route's +// path. Used as a stable name fragment for generated labels and CRDs. +// Example: "/api/v0" -> "api-v0". +func (r Route) Slug() string { + s := strings.Trim(r.Path, "/") + s = strings.ReplaceAll(s, "/", "-") + s = strings.ToLower(s) + if s == "" { + return "root" + } + return s +} + +// DecodeRoute parses the storage representation into a Route. The third +// (strip) field is optional and defaults to 0 (no strip) when absent. +func DecodeRoute(path string, value string) (Route, error) { + parts := strings.SplitN(value, ":", 3) + if len(parts) < 2 { + return Route{}, fmt.Errorf("invalid route value %q for path %s", value, path) + } + port, err := strconv.Atoi(parts[1]) + if err != nil { + return Route{}, fmt.Errorf("invalid port %q for path %s: %v", parts[1], path, err) + } + strip := false + if len(parts) == 3 && parts[2] == "1" { + strip = true + } + return Route{ + Path: path, + Process: parts[0], + Port: port, + StripPrefix: strip, + }, nil +} + +var processNameRE = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +// ValidatePath enforces the path constraints documented in the proposal: +// - must start with "/" +// - cannot be "/" (reserved for web) +// - cannot end with "/" (other than root, already rejected above) +func ValidatePath(path string) error { + if !strings.HasPrefix(path, "/") { + return fmt.Errorf("path must start with /") + } + if path == "/" { + return fmt.Errorf("path / is reserved for the web process") + } + if strings.HasSuffix(path, "/") { + return fmt.Errorf("path must not end with /") + } + return nil +} + +// ValidateProcess enforces process-name constraints: +// - cannot be empty +// - cannot be "web" (which is the implicit catch-all) +// - must match Procfile name syntax +func ValidateProcess(process string) error { + if process == "" { + return fmt.Errorf("process name is required") + } + if process == "web" { + return fmt.Errorf("web cannot be a route target; web is the implicit catch-all") + } + if !processNameRE.MatchString(process) { + return fmt.Errorf("process name %q contains invalid characters", process) + } + return nil +} + +// ValidatePort ensures the port is in the legal TCP range. +func ValidatePort(port int) error { + if port < 1 || port > 65535 { + return fmt.Errorf("port %d out of range (1-65535)", port) + } + return nil +} + +// GetRoutes returns the route set for an app, sorted by descending path +// length (longest-prefix-first), then alphabetically as a tiebreaker. +func GetRoutes(appName string) ([]Route, error) { + m, err := common.PropertyMapGet("proxy", appName, "routes") + if err != nil { + return nil, err + } + routes := make([]Route, 0, len(m)) + for path, value := range m { + route, err := DecodeRoute(path, value) + if err != nil { + return nil, err + } + routes = append(routes, route) + } + sort.SliceStable(routes, func(i, j int) bool { + if len(routes[i].Path) != len(routes[j].Path) { + return len(routes[i].Path) > len(routes[j].Path) + } + return routes[i].Path < routes[j].Path + }) + return routes, nil +} + +// GetRoutesByProcess returns the subset of routes targeting a specific +// process, preserving the longest-prefix-first ordering. +func GetRoutesByProcess(appName string, processName string) ([]Route, error) { + all, err := GetRoutes(appName) + if err != nil { + return nil, err + } + filtered := make([]Route, 0, len(all)) + for _, route := range all { + if route.Process == processName { + filtered = append(filtered, route) + } + } + return filtered, nil +} + +// RoutedProcessTypes returns the unique process types referenced by any route. +func RoutedProcessTypes(appName string) ([]string, error) { + routes, err := GetRoutes(appName) + if err != nil { + return nil, err + } + seen := map[string]bool{} + out := []string{} + for _, route := range routes { + if !seen[route.Process] { + seen[route.Process] = true + out = append(out, route.Process) + } + } + sort.Strings(out) + return out, nil +} + +// AddRoute upserts a route. Returns whether the stored state changed. +func AddRoute(appName string, route Route) (bool, error) { + if err := ValidatePath(route.Path); err != nil { + return false, err + } + if err := ValidateProcess(route.Process); err != nil { + return false, err + } + if err := ValidatePort(route.Port); err != nil { + return false, err + } + + existing, err := common.PropertyMapGet("proxy", appName, "routes") + if err != nil { + return false, err + } + next := route.Encode() + if existing[route.Path] == next { + return false, nil + } + return true, common.PropertyMapSet("proxy", appName, "routes", route.Path, next) +} + +// RemoveRoute removes a route by path. Returns whether the stored state +// changed (false when the path was absent - removal is idempotent). +func RemoveRoute(appName string, path string) (bool, error) { + existing, err := common.PropertyMapGet("proxy", appName, "routes") + if err != nil { + return false, err + } + if _, ok := existing[path]; !ok { + return false, nil + } + return true, common.PropertyMapDelete("proxy", appName, "routes", path) +} + +// ClearRoutes removes every route for an app. Returns whether anything was +// removed (false when the route set was already empty). +func ClearRoutes(appName string) (bool, error) { + existing, err := common.PropertyMapGet("proxy", appName, "routes") + if err != nil { + return false, err + } + if len(existing) == 0 { + return false, nil + } + return true, common.PropertyDelete("proxy", appName, "routes") +} + +// getProcessScale returns the configured replica count for a process, or 0 +// when no scale entry exists. The ps plugin stores scale as a property list +// of "=" entries. +func getProcessScale(appName string, processName string) int { + lines, err := common.PropertyListGet("ps", appName, "scale") + if err != nil { + return 0 + } + prefix := processName + "=" + for _, line := range lines { + if strings.HasPrefix(line, prefix) { + n, err := strconv.Atoi(strings.TrimPrefix(line, prefix)) + if err != nil { + return 0 + } + return n + } + } + return 0 +} diff --git a/plugins/proxy/src/commands/commands.go b/plugins/proxy/src/commands/commands.go index b34748be4..cfa514975 100644 --- a/plugins/proxy/src/commands/commands.go +++ b/plugins/proxy/src/commands/commands.go @@ -23,6 +23,10 @@ Additional commands:` proxy:disable , Disable proxy for app proxy:enable , Enable proxy for app proxy:report [] [], Displays a proxy report for one or more apps + proxy:route:clear , Removes every path-based route for an app + proxy:route:remove , Removes a path-based route from an app + proxy:route:report [] [--format json], Displays the path-based routes for an app + proxy:route:set [--port port] [--strip-prefix], Adds or updates a path-based route to a non-web process proxy:set , Set proxy type for app` ) diff --git a/plugins/proxy/src/subcommands/subcommands.go b/plugins/proxy/src/subcommands/subcommands.go index 07b1a5655..d1ef26f4c 100644 --- a/plugins/proxy/src/subcommands/subcommands.go +++ b/plugins/proxy/src/subcommands/subcommands.go @@ -70,6 +70,32 @@ func main() { value = args.Arg(1) } err = proxy.CommandSet(appName, property, value) + case "route:set": + args := flag.NewFlagSet("proxy:route:set", flag.ExitOnError) + port := args.Int("port", proxy.DefaultRoutePort, "--port: container port of the target process") + stripPrefix := args.Bool("strip-prefix", false, "--strip-prefix: strip the matched prefix before forwarding") + args.Parse(os.Args[2:]) + appName := args.Arg(0) + processName := args.Arg(1) + path := args.Arg(2) + err = proxy.CommandRouteSet(appName, processName, path, *port, *stripPrefix) + case "route:remove": + args := flag.NewFlagSet("proxy:route:remove", flag.ExitOnError) + args.Parse(os.Args[2:]) + appName := args.Arg(0) + path := args.Arg(1) + err = proxy.CommandRouteRemove(appName, path) + case "route:clear": + args := flag.NewFlagSet("proxy:route:clear", flag.ExitOnError) + args.Parse(os.Args[2:]) + appName := args.Arg(0) + err = proxy.CommandRouteClear(appName) + case "route:report": + args := flag.NewFlagSet("proxy:route:report", flag.ExitOnError) + format := args.String("format", "stdout", "format: [ stdout | json ]") + args.Parse(os.Args[2:]) + appName := args.Arg(0) + err = proxy.CommandRouteReport(appName, *format) default: err = fmt.Errorf("Invalid plugin subcommand call: %s", subcommand) } diff --git a/plugins/proxy/src/triggers/triggers.go b/plugins/proxy/src/triggers/triggers.go index e8acb3b6c..fac9d13c5 100644 --- a/plugins/proxy/src/triggers/triggers.go +++ b/plugins/proxy/src/triggers/triggers.go @@ -26,6 +26,9 @@ func main() { case "proxy-type": appName := flag.Arg(0) err = proxy.TriggerProxyType(appName) + case "proxy-routes-list": + appName := flag.Arg(0) + err = proxy.TriggerProxyRoutesList(appName) case "post-app-clone-setup": oldAppName := flag.Arg(0) newAppName := flag.Arg(1) diff --git a/plugins/proxy/triggers.go b/plugins/proxy/triggers.go index 935e63d77..cd6f11df8 100644 --- a/plugins/proxy/triggers.go +++ b/plugins/proxy/triggers.go @@ -64,6 +64,24 @@ func TriggerProxyType(appName string) error { return nil } +// TriggerProxyRoutesList prints one "process|port|path|strip" line per route, +// sorted by descending path length. Backends use this to render path-based +// routes in their config or labels. +func TriggerProxyRoutesList(appName string) error { + routes, err := GetRoutes(appName) + if err != nil { + return err + } + for _, r := range routes { + strip := "0" + if r.StripPrefix { + strip = "1" + } + fmt.Printf("%s|%d|%s|%s\n", r.Process, r.Port, r.Path, strip) + } + return nil +} + // TriggerPostAppCloneSetup creates new proxy files func TriggerPostAppCloneSetup(oldAppName string, newAppName string) error { return common.PropertyClone("proxy", oldAppName, newAppName) diff --git a/plugins/scheduler-k3s/chart.go b/plugins/scheduler-k3s/chart.go index b019cf0aa..eeac88cc2 100644 --- a/plugins/scheduler-k3s/chart.go +++ b/plugins/scheduler-k3s/chart.go @@ -292,6 +292,12 @@ func BuildAppChart(ctx context.Context, appName, imageTag string, opts BuildOpti return cleanup(fmt.Errorf("Error getting security context: %w", err)) } + appRoutes, err := GetAppRoutes(appName) + if err != nil { + return cleanup(fmt.Errorf("Error getting app routes: %w", err)) + } + routedPorts := RoutedProcessPorts(appRoutes) + values := &AppValues{ Global: GlobalValues{ Annotations: globalAnnotations, @@ -311,6 +317,7 @@ func BuildAppChart(ctx context.Context, appName, imageTag string, opts BuildOpti IngressClass: getComputedIngressClass(), PrimaryPort: primaryPort, PrimaryServicePort: primaryServicePort, + Routes: appRoutes, }, SecurityContext: securityContext, }, @@ -508,13 +515,32 @@ func BuildAppChart(ctx context.Context, appName, imageTag string, opts BuildOpti Protocol: PortmapProtocol_TCP, Scheme: "http", }) + } else if ports := SortedRoutedPorts(routedPorts, processType); len(ports) > 0 { + processValues.Web = ProcessWeb{ + Domains: []ProcessDomains{}, + PortMaps: []ProcessPortMap{}, + TLS: ProcessTls{ + Enabled: false, + }, + } + for _, port := range ports { + processValues.Web.PortMaps = append(processValues.Web.PortMaps, ProcessPortMap{ + ContainerPort: port, + HostPort: port, + Name: fmt.Sprintf("http-%d-%d", port, port), + Protocol: PortmapProtocol_TCP, + Scheme: "http", + }) + } } values.Processes[processType] = processValues templateFiles := []string{"deployment", "keda-scaled-object"} if processType == "web" { - templateFiles = append(templateFiles, "service", "certificate", "issuer", "ingress", "ingress-route", "compression-middleware", "https-redirect-middleware", "keda-http-scaled-object", "keda-interceptor-proxy-service") + templateFiles = append(templateFiles, "service", "certificate", "issuer", "ingress", "ingress-route", "compression-middleware", "https-redirect-middleware", "strip-prefix-middleware", "keda-http-scaled-object", "keda-interceptor-proxy-service") + } else if _, routed := routedPorts[processType]; routed { + templateFiles = append(templateFiles, "service") } for _, templateName := range templateFiles { b, err := templates.ReadFile(fmt.Sprintf("templates/chart/%s.yaml", templateName)) diff --git a/plugins/scheduler-k3s/routes.go b/plugins/scheduler-k3s/routes.go new file mode 100644 index 000000000..466ae9e67 --- /dev/null +++ b/plugins/scheduler-k3s/routes.go @@ -0,0 +1,100 @@ +package scheduler_k3s + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/dokku/dokku/plugins/common" +) + +// GetAppRoutes loads the proxy:route:set route set for an app via the +// proxy-routes-list plugin trigger. The trigger emits one +// "process|port|path|strip" line per route, sorted by descending path length. +// Returns an empty slice when no routes are configured. +func GetAppRoutes(appName string) ([]GlobalRoute, error) { + result, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ + Trigger: "proxy-routes-list", + Args: []string{appName}, + }) + if err != nil { + return nil, fmt.Errorf("error reading proxy routes for %s: %w", appName, err) + } + + stdout := result.StdoutContents() + if stdout == "" { + return nil, nil + } + + routes := []GlobalRoute{} + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.SplitN(line, "|", 4) + if len(fields) < 3 { + return nil, fmt.Errorf("invalid route line %q for %s", line, appName) + } + // Bound to uint16 (1-65535) so the int32 chart-values field can never + // overflow regardless of host integer width. The proxy plugin already + // enforces the same range on set, but parse defensively because the + // trigger output is a string boundary. + port, err := strconv.ParseUint(fields[1], 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid port %q in route line %q for %s: %w", fields[1], line, appName, err) + } + strip := false + if len(fields) == 4 && fields[3] == "1" { + strip = true + } + routes = append(routes, GlobalRoute{ + Process: fields[0], + Path: fields[2], + Port: int32(port), + StripPrefix: strip, + Slug: routeSlug(fields[2]), + }) + } + return routes, nil +} + +// routeSlug returns a deterministic kebab-case identifier derived from a +// route path. Example: "/api/v0" -> "api-v0". +func routeSlug(path string) string { + s := strings.Trim(path, "/") + s = strings.ReplaceAll(s, "/", "-") + s = strings.ToLower(s) + if s == "" { + return "root" + } + return s +} + +// RoutedProcessPorts returns, per process type, the set of upstream container +// ports referenced by any route targeting that process. The chart-values +// builder uses this to synthesize port_maps on routed non-web processes so +// their Kubernetes Services expose the right port. +func RoutedProcessPorts(routes []GlobalRoute) map[string]map[int32]bool { + out := map[string]map[int32]bool{} + for _, route := range routes { + if _, ok := out[route.Process]; !ok { + out[route.Process] = map[int32]bool{} + } + out[route.Process][route.Port] = true + } + return out +} + +// SortedRoutedPorts returns the ports for a process in deterministic order +// so the resulting Service has a stable port list across renders. +func SortedRoutedPorts(routedPorts map[string]map[int32]bool, processType string) []int32 { + set := routedPorts[processType] + ports := make([]int32, 0, len(set)) + for port := range set { + ports = append(ports, port) + } + sort.Slice(ports, func(i, j int) bool { return ports[i] < ports[j] }) + return ports +} diff --git a/plugins/scheduler-k3s/template.go b/plugins/scheduler-k3s/template.go index c98045fc6..b2d02bed8 100644 --- a/plugins/scheduler-k3s/template.go +++ b/plugins/scheduler-k3s/template.go @@ -70,6 +70,31 @@ type GlobalNetwork struct { // PrimaryServicePort is the primary service port to use PrimaryServicePort int32 `yaml:"primary_service_port"` + + // Routes are the path-based routes declared via proxy:route:set + Routes []GlobalRoute `yaml:"routes,omitempty"` +} + +// GlobalRoute is one path-based route declared via proxy:route:set. The +// chart templates iterate this slice to emit additional Ingress paths / +// IngressRoute Rules per route. +type GlobalRoute struct { + // Process is the Procfile process type the route targets. + Process string `yaml:"process"` + + // Path is the URL path prefix to match. + Path string `yaml:"path"` + + // Port is the upstream container port the route forwards to. + Port int32 `yaml:"port"` + + // StripPrefix controls whether the matched prefix is stripped before + // forwarding the request upstream. + StripPrefix bool `yaml:"strip_prefix"` + + // Slug is a deterministic kebab-case identifier derived from Path. It is + // used as a stable name fragment for generated CRDs. + Slug string `yaml:"slug"` } // GlobalKedaValues contains the global keda configuration diff --git a/plugins/scheduler-k3s/templates/chart/ingress-route.yaml b/plugins/scheduler-k3s/templates/chart/ingress-route.yaml index 8fbb76c7f..55ef5f0ea 100644 --- a/plugins/scheduler-k3s/templates/chart/ingress-route.yaml +++ b/plugins/scheduler-k3s/templates/chart/ingress-route.yaml @@ -38,6 +38,30 @@ spec: - {{ $entryPoint }} routes: {{- range $ddx, $domain := $config.web.domains }} + {{- range $rdx, $route := $.Values.global.network.routes }} + - kind: Rule + match: Host(`{{ $domain.name }}`) && PathPrefix(`{{ $route.path }}`) + # No explicit priority - traefik's default (rule length) gives + # longest-prefix-wins because Host(...) && PathPrefix(...) is always + # longer than web's bare Host(...) rule below. + middlewares: + - name: {{ $.Values.global.app_name}}-{{ $processName }}-compression + namespace: {{ $.Values.global.namespace }} + {{- if and (eq $entryPoint "web") $config.web.tls.enabled }} + - name: {{ $.Values.global.app_name}}-{{ $processName }}-redirect-to-https + namespace: {{ $.Values.global.namespace }} + {{- end }} + {{- if $route.strip_prefix }} + - name: {{ $.Values.global.app_name }}-{{ $route.process }}-{{ $route.slug }}-strip + namespace: {{ $.Values.global.namespace }} + {{- end }} + services: + - name: {{ $.Values.global.app_name }}-{{ $route.process }} + namespace: {{ $.Values.global.namespace }} + passHostHeader: true + port: {{ $route.port }} + scheme: http + {{- end }} - kind: Rule match: {{ include "traefik.host.match" $domain.name }} {{- if hasPrefix "*." $domain.name }} diff --git a/plugins/scheduler-k3s/templates/chart/ingress.yaml b/plugins/scheduler-k3s/templates/chart/ingress.yaml index 3644fd2b8..53756e975 100644 --- a/plugins/scheduler-k3s/templates/chart/ingress.yaml +++ b/plugins/scheduler-k3s/templates/chart/ingress.yaml @@ -65,6 +65,76 @@ spec: {{- end }} path: / {{- end }} + {{- range $rdx, $route := $.Values.global.network.routes }} + {{- if not $route.strip_prefix }} + - backend: + service: + name: {{ $.Values.global.app_name }}-{{ $route.process }} + port: + number: {{ $route.port }} + pathType: Prefix + path: {{ $route.path }} + {{- end }} + {{- end }} +{{- end }} +{{- end }} +{{- end }} +{{- /* Strip-prefix routes need their own Ingress objects because the + rewrite-target annotation is Ingress-level. */}} +{{- range $processName, $config := .Values.processes }} +{{- if not (hasKey $config "web") }} +{{- continue }} +{{- end }} +{{- if and $config.web.domains (eq $.Values.global.network.ingress_class "nginx") }} +{{- range $pdx, $domain := $config.web.domains }} +{{- range $rdx, $route := $.Values.global.network.routes }} +{{- if $route.strip_prefix }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + dokku.com/managed: "true" + dokku.com/ingress-method: "routes" + nginx.ingress.kubernetes.io/rewrite-target: /$2 + {{- if $config.web.tls.enabled }} + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" + {{- end }} + {{ include "print.annotations" (dict "config" $.Values.global "key" "ingress") | indent 4 }} + {{ include "print.annotations" (dict "config" $config "key" "ingress") | indent 4 }} + labels: + app.kubernetes.io/instance: {{ $.Values.global.app_name }}-{{ $route.process }} + app.kubernetes.io/name: {{ $route.process }} + app.kubernetes.io/part-of: {{ $.Values.global.app_name }} + {{ include "print.labels" (dict "config" $.Values.global "key" "ingress") | indent 4 }} + {{ include "print.labels" (dict "config" $config "key" "ingress") | indent 4 }} + name: {{ $.Values.global.app_name }}-{{ $route.process }}-{{ $route.slug }}-strip-{{ $domain.slug }} + namespace: {{ $.Values.global.namespace }} +spec: + ingressClassName: nginx + {{- if $config.web.tls.enabled }} + tls: + - hosts: + - {{ $domain.name | quote }} + {{- if $config.web.tls.use_imported_cert }} + secretName: tls-{{ $.Values.global.app_name }} + {{- else }} + secretName: tls-{{ $.Values.global.app_name }}-{{ $processName }} + {{- end }} + {{- end }} + rules: + - host: {{ $domain.name | quote }} + http: + paths: + - backend: + service: + name: {{ $.Values.global.app_name }}-{{ $route.process }} + port: + number: {{ $route.port }} + pathType: ImplementationSpecific + path: {{ $route.path }}(/|$)(.*) +{{- end }} +{{- end }} {{- end }} {{- end }} {{- end }} diff --git a/plugins/scheduler-k3s/templates/chart/strip-prefix-middleware.yaml b/plugins/scheduler-k3s/templates/chart/strip-prefix-middleware.yaml new file mode 100644 index 000000000..9d563fb87 --- /dev/null +++ b/plugins/scheduler-k3s/templates/chart/strip-prefix-middleware.yaml @@ -0,0 +1,35 @@ +{{- /* Emit a StripPrefix Middleware CRD per route that uses --strip-prefix + when the ingress class is traefik. Referenced from the IngressRoute + Rule rendered in ingress-route.yaml. */}} +{{- range $processName, $config := .Values.processes }} +{{- if not (hasKey $config "web") }} +{{- continue }} +{{- end }} + +{{- if and $config.web.domains (eq $.Values.global.network.ingress_class "traefik") }} +{{- range $rdx, $route := $.Values.global.network.routes }} +{{- if $route.strip_prefix }} +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + annotations: + dokku.com/managed: "true" + {{ include "print.annotations" (dict "config" $.Values.global "key" "traefik_middleware") | indent 4 }} + {{ include "print.annotations" (dict "config" $config "key" "traefik_middleware") | indent 4 }} + labels: + app.kubernetes.io/instance: {{ $.Values.global.app_name }}-{{ $route.process }}-{{ $route.slug }}-strip + app.kubernetes.io/name: {{ $route.process }} + app.kubernetes.io/part-of: {{ $.Values.global.app_name }} + {{ include "print.labels" (dict "config" $.Values.global "key" "traefik_middleware") | indent 4 }} + {{ include "print.labels" (dict "config" $config "key" "traefik_middleware") | indent 4 }} + name: {{ $.Values.global.app_name }}-{{ $route.process }}-{{ $route.slug }}-strip + namespace: {{ $.Values.global.namespace }} +spec: + stripPrefix: + prefixes: + - {{ $route.path }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} diff --git a/plugins/traefik-vhosts/docker-args-process-deploy b/plugins/traefik-vhosts/docker-args-process-deploy index 200dab355..1f62ea2d6 100755 --- a/plugins/traefik-vhosts/docker-args-process-deploy +++ b/plugins/traefik-vhosts/docker-args-process-deploy @@ -14,11 +14,26 @@ trigger-traefik-vhosts-docker-args-process-deploy() { local proxy_container_https_port proxy_container_https_port_candidate proxy_host_https_port_candidate local STDIN=$(cat) - if [[ "$PROC_TYPE" != "web" ]]; then + if [[ "$(plugn trigger proxy-type "$APP")" != "traefik" ]]; then return fi - if [[ "$(plugn trigger proxy-type "$APP")" != "traefik" ]]; then + if [[ "$PROC_TYPE" != "web" ]]; then + # Non-web processes are only labeled when at least one path-based route + # targets the process. Otherwise traefik should ignore the container. + local route_line 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-traefik-route-labels "$APP" "$PROC_TYPE" "$STDIN" return fi diff --git a/plugins/traefik-vhosts/internal-functions b/plugins/traefik-vhosts/internal-functions index 49b9afc5c..29df8bd6f 100755 --- a/plugins/traefik-vhosts/internal-functions +++ b/plugins/traefik-vhosts/internal-functions @@ -4,6 +4,89 @@ source "$PLUGIN_CORE_AVAILABLE_PATH/common/property-functions" set -eo pipefail [[ $DOKKU_TRACE ]] && set -x +fn-traefik-route-labels() { + declare desc="emits per-route traefik labels for a non-web container" + declare APP="$1" PROC_TYPE="$2" STDIN="$3" + local host_rule output route_proc route_port route_path route_strip + local router_name service_name middleware_name path_len + + host_rule="$(fn-traefik-host-rule "$APP")" + output="--label traefik.enable=true" + + while IFS='|' read -r route_proc route_port route_path route_strip; do + [[ -z "$route_proc" ]] && continue + [[ "$route_proc" != "$PROC_TYPE" ]] && continue + + local route_slug + route_slug="$(fn-traefik-route-slug "$route_path")" + router_name="${APP}-${route_proc}-${route_slug}" + service_name="${APP}-${route_proc}-${route_slug}" + path_len="${#route_path}" + + local rule="PathPrefix(\`${route_path}\`)" + if [[ -n "$host_rule" ]]; then + rule="(${host_rule}) && ${rule}" + fi + + output="$output --label \"traefik.http.routers.${router_name}.rule=${rule}\"" + output="$output --label traefik.http.routers.${router_name}.entrypoints=$(fn-traefik-computed-http-entry-point)" + # No explicit priority - traefik's default (rule length) gives + # longest-prefix-wins because our rule includes both Host(...) and + # PathPrefix(...), which is always longer than web's bare Host(...) rule. + output="$output --label traefik.http.routers.${router_name}.service=${service_name}" + output="$output --label traefik.http.services.${service_name}.loadbalancer.server.port=${route_port}" + + if [[ "$route_strip" == "1" ]]; then + middleware_name="${router_name}-strip" + output="$output --label traefik.http.middlewares.${middleware_name}.stripprefix.prefixes=${route_path}" + output="$output --label traefik.http.routers.${router_name}.middlewares=${middleware_name}" + fi + done < <(plugn trigger proxy-routes-list "$APP") + + local proxy_labels_file_path=$(fn-proxy-get-labels-file-path "traefik" "$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-traefik-route-slug() { + declare desc="returns a deterministic kebab-case slug for a route path" + declare ROUTE_PATH="$1" + local s="${ROUTE_PATH#/}" + s="${s%/}" + s="${s//\//-}" + s="${s,,}" + if [[ -z "$s" ]]; then + s="root" + fi + echo "$s" +} + +fn-traefik-host-rule() { + declare desc="builds a traefik Host(\`d1\`) || Host(\`d2\`) rule fragment from an app's domains" + declare APP="$1" + local app_domains domain result="" + + app_domains="$(plugn trigger domains-list "$APP")" + if [[ -z "$app_domains" ]]; then + return + fi + + for domain in $(echo "$app_domains" | xargs); do + if [[ -z "$result" ]]; then + result="Host(\`${domain}\`)" + continue + fi + result="${result} || Host(\`${domain}\`)" + done + echo "$result" +} + fn-traefik-logs() { declare desc="shows the logs for the traefik container" declare TAIL="$1" NUM="$2" diff --git a/tests/unit/proxy-route-caddy.bats b/tests/unit/proxy-route-caddy.bats new file mode 100644 index 000000000..ee2db14de --- /dev/null +++ b/tests/unit/proxy-route-caddy.bats @@ -0,0 +1,147 @@ +#!/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" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the daemon a moment to read the new labels. + sleep 5 + + # Without --strip-prefix, /api/v0/Procfile reaches api as /api/v0/Procfile + # which python's http.server returns 404 for. A 404 here proves the route + # reaches api - web's catch-all would have returned 200. + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "" "404" +} + +@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" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the daemon a moment to read the new labels. + sleep 5 + 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" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the daemon a moment to read the new labels. + sleep 5 + 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 + + # Set without --strip-prefix first; 404 proves the labels attached. + 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" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the daemon a moment to read the new labels. + sleep 5 + 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" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the daemon a moment to read the new labels. + sleep 5 + 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" + 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_success + assert_output_contains "(none)" + + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the daemon a moment to read the new labels. + sleep 5 + + # Poll the response body via file to avoid shell-quoting issues with JSON + # bodies that contain embedded double quotes. + rm -f /tmp/route-removed-body + local attempt=0 + for attempt in $(seq 1 30); do + curl --connect-to "${TEST_APP}.${DOKKU_DOMAIN}:80:localhost:80" -kSso /tmp/route-removed-body "http://${TEST_APP}.${DOKKU_DOMAIN}:80/api/v0/Procfile" || true + if ! grep -q 'python3 -m http.server' /tmp/route-removed-body 2>/dev/null; then + break + fi + sleep 1 + done + 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..c09389166 --- /dev/null +++ b/tests/unit/proxy-route-k3s-nginx.bats @@ -0,0 +1,163 @@ +#!/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 + + # scheduler-k3s does not yet have a proxy-build-config trigger handler, so + # proxy:route:set does not re-render the chart. ps:rebuild forces a redeploy + # which renders the chart with current routes. Runtime gap tracked as a + # follow-up; the test fix unblocks CI. + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + # Without --strip-prefix, /api/v0/Procfile reaches api as /api/v0/Procfile + # which python's http.server returns 404 for. 404 proves the route reaches + # api (web's catch-all would have returned 200). + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "" "404" +} + +@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 + + # Set without strip first; the 404 proves the route reached api before we + # assert the strip-prefix HTTP behavior. + 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 + + # 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 + + # Verify the route reaches api first (404 proves routing) before checking + # that strip applied (200 with Procfile content). scheduler-k3s does not + # yet have a proxy-build-config trigger, so ps:rebuild forces a chart + # re-render after each proxy:route mutation. + 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" + + run /bin/bash -c "dokku proxy:route:remove $TEST_APP /api/v0" + echo "output: $output" + echo "status: $status" + assert_success + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + + # Confirm storage was cleared before checking cluster state. + run /bin/bash -c "dokku proxy:route:report $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "(none)" + + # The rewrite-target Ingress should be gone after removal. Retry briefly + # in case the chart re-render hasn't settled. + local count="1" + for attempt in $(seq 1 30); do + run /bin/bash -c "kubectl get ingress -o json | jq -r '[.items[] | select(.metadata.annotations[\"nginx.ingress.kubernetes.io/rewrite-target\"] != null)] | length'" + count="$output" + if [[ "$count" == "0" ]]; then + break + fi + sleep 1 + done + echo "final ingress count after $attempt attempts: $count" + assert_equal "$count" "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..89dbae262 --- /dev/null +++ b/tests/unit/proxy-route-k3s-traefik.bats @@ -0,0 +1,163 @@ +#!/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 + + # scheduler-k3s does not yet have a proxy-build-config trigger handler, so + # proxy:route:set does not re-render the chart. ps:rebuild forces a redeploy + # which renders the chart with current routes. Runtime gap tracked as a + # follow-up; the test fix unblocks CI. + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + # Without --strip-prefix, /api/v0/Procfile reaches api as /api/v0/Procfile + # which python's http.server returns 404 for. 404 proves the route reaches + # api (web's catch-all would have returned 200). + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "" "404" +} + +@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 + + # Set without strip first; the 404 proves the route reached api before we + # assert the strip-prefix HTTP behavior. ps:rebuild after each mutation + # forces a chart re-render (k3s has no proxy-build-config trigger yet). + 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 + + # 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 + + # Verify the route reaches api first (404 proves routing) before checking + # that strip applied (200 with Procfile content). ps:rebuild after each + # mutation forces a chart re-render (k3s has no proxy-build-config yet). + 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" + + run /bin/bash -c "dokku proxy:route:remove $TEST_APP /api/v0" + echo "output: $output" + echo "status: $status" + assert_success + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + assert_success + + # Confirm storage was cleared before checking cluster state. + run /bin/bash -c "dokku proxy:route:report $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "(none)" + + # The stripPrefix Middleware should be gone after removal. Retry briefly + # in case the chart re-render hasn't settled. + local count="1" + for attempt in $(seq 1 30); do + run /bin/bash -c "kubectl get middleware.traefik.io -o json | jq -r '[.items[] | select(.spec.stripPrefix != null)] | length'" + count="$output" + if [[ "$count" == "0" ]]; then + break + fi + sleep 1 + done + echo "final middleware count after $attempt attempts: $count" + assert_equal "$count" "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..8cf993301 --- /dev/null +++ b/tests/unit/proxy-route-nginx.bats @@ -0,0 +1,145 @@ +#!/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 + + # Without --strip-prefix, /api/v0/Procfile reaches api as /api/v0/Procfile. + # api (python's http.server) does not have that file on disk and returns + # 404. A 404 here proves the route reaches api - web's catch-all would + # have returned 200 instead. + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "" "404" +} + +@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 + + # First set the route without --strip-prefix so a 404 from api uniquely + # identifies that the route reached api (avoids racing with web's catch-all + # 200 during the brief window before nginx fully reloads). + 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" + + # Now switch the route to --strip-prefix and assert api serves the Procfile. + 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" + echo "output: $output" + echo "status: $status" + assert_success + + # Confirm storage was actually mutated; a CommandRouteRemove that returns + # changed=false silently skips BuildConfig and leaves nginx with the old + # route block. + run /bin/bash -c "dokku proxy:route:report $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "(none)" + + # nginx reload races with the curl after route removal: both api and web + # return 200 for /api/v0/Procfile so the polling loop in + # assert_http_localhost_response_contains exits on status alone before + # nginx picks up the new config. Hand-roll a retry that polls the BODY + # written to a file (avoids shell-quoting issues with JSON bodies that + # contain embedded double quotes). + rm -f /tmp/route-removed-body + local attempt=0 + for attempt in $(seq 1 30); do + curl --connect-to "${TEST_APP}.${DOKKU_DOMAIN}:80:localhost:80" -kSso /tmp/route-removed-body "http://${TEST_APP}.${DOKKU_DOMAIN}:80/api/v0/Procfile" || true + if ! grep -q 'python3 -m http.server' /tmp/route-removed-body 2>/dev/null; then + break + fi + sleep 1 + done + 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" + # These headers appear in both the route's location block AND the existing + # location / block, so allow >= 1 occurrence (count = -1) rather than the + # default exact-count-of-1. + assert_output_contains "proxy_http_version 1.1" -1 + assert_output_contains "proxy_set_header Upgrade \$http_upgrade" -1 + assert_output_contains "proxy_set_header Connection \$http_connection" -1 +} + +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..db7e321a2 --- /dev/null +++ b/tests/unit/proxy-route-openresty.bats @@ -0,0 +1,147 @@ +#!/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" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the daemon a moment to read the new labels. + sleep 5 + + # Without --strip-prefix, /api/v0/Procfile reaches api as /api/v0/Procfile + # which python's http.server returns 404 for. A 404 here proves the route + # reaches api - web's catch-all would have returned 200. + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "" "404" +} + +@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" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the daemon a moment to read the new labels. + sleep 5 + 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" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the sidecar a longer window than the default HTTP retry to detect + # and re-render after a route's labels change on the api container. + HTTP_ASSERT_RETRIES=60 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 + + # Set without --strip-prefix first; 404 proves the labels attached. + 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" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the daemon a moment to read the new labels. + sleep 5 + 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" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the daemon a moment to read the new labels. + sleep 5 + 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" + 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_success + assert_output_contains "(none)" + + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the daemon a moment to read the new labels. + sleep 5 + + # Poll the response body via file to avoid shell-quoting issues with JSON + # bodies that contain embedded double quotes. + rm -f /tmp/route-removed-body + local attempt=0 + for attempt in $(seq 1 30); do + curl --connect-to "${TEST_APP}.${DOKKU_DOMAIN}:80:localhost:80" -kSso /tmp/route-removed-body "http://${TEST_APP}.${DOKKU_DOMAIN}:80/api/v0/Procfile" || true + if ! grep -q 'python3 -m http.server' /tmp/route-removed-body 2>/dev/null; then + break + fi + sleep 1 + done + 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..7af93a89c --- /dev/null +++ b/tests/unit/proxy-route-traefik.bats @@ -0,0 +1,160 @@ +#!/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" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "docker inspect $TEST_APP.api.1 --format '{{ index .Config.Labels \"traefik.http.routers.$TEST_APP-api-api-v0.rule\" }}'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "(Host(\`$TEST_APP.$DOKKU_DOMAIN\`)) && PathPrefix(\`/api/v0\`)" + + # Give the daemon a moment to read the new labels. + sleep 5 + + # Without --strip-prefix, /api/v0/Procfile reaches api as /api/v0/Procfile + # which python's http.server returns 404 for. A 404 here proves the route + # reaches api - web's catch-all would have returned 200. + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "" "404" +} + +@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" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the daemon a moment to read the new labels. + sleep 5 + 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" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the daemon a moment to read the new labels. + sleep 5 + 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 + + # Set the route without --strip-prefix first; a 404 from api proves the + # labels attached and traefik is routing correctly. + 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" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the daemon a moment to read the new labels. + sleep 5 + assert_http_localhost_response_contains "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/api/v0/Procfile" "" "404" + + # Switch to --strip-prefix and verify api now serves the Procfile. + 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" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the daemon a moment to read the new labels. + sleep 5 + 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" + echo "output: $output" + echo "status: $status" + assert_success + + # Confirm storage was actually mutated before doing the slow rebuild. + run /bin/bash -c "dokku proxy:route:report $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "(none)" + + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + # Give the daemon a moment to read the new labels. + sleep 5 + + # After ps:rebuild, traefik recreates the api container without the route + # labels, but there is still a race with traefik picking up the new state. + # Poll the response body via file to avoid shell-quoting issues with JSON + # bodies that contain embedded double quotes. + rm -f /tmp/route-removed-body + local attempt=0 + for attempt in $(seq 1 30); do + curl --connect-to "${TEST_APP}.${DOKKU_DOMAIN}:80:localhost:80" -kSso /tmp/route-removed-body "http://${TEST_APP}.${DOKKU_DOMAIN}:80/api/v0/Procfile" || true + if ! grep -q 'python3 -m http.server' /tmp/route-removed-body 2>/dev/null; then + break + fi + sleep 1 + done + 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 new file mode 100644 index 000000000..a3f22dfa7 --- /dev/null +++ b/tests/unit/proxy-route.bats @@ -0,0 +1,233 @@ +#!/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) help text mentions route commands" { + run /bin/bash -c "dokku proxy:help" + echo "output: $output" + echo "status: $status" + assert_output_contains "proxy:route:set" + assert_output_contains "proxy:route:remove" + assert_output_contains "proxy:route:clear" + assert_output_contains "proxy:route:report" +} + +@test "(proxy:route:set) stores a route under the default port and is idempotent" { + 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_success + assert_output_contains "/api/v0 -> api:5000" + + # second call with identical args produces identical state + 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) --port overrides the default port" { + 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 proxy:route:report $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_output_contains "/api/v0 -> api:5001" +} + +@test "(proxy:route:set) --strip-prefix marks the route as stripping" { + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --strip-prefix" + 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 (strip)" +} + +@test "(proxy:route:set) re-setting without --port resets port to default" { + 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 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) re-setting without --strip-prefix resets strip to false" { + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --strip-prefix" + echo "output: $output" + echo "status: $status" + assert_success + + 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_not_contains "(strip)" +} + +@test "(proxy:route:set) refuses web as a target" { + run /bin/bash -c "dokku proxy:route:set $TEST_APP web /api/v0" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "web cannot be a route target" +} + +@test "(proxy:route:set) refuses root path" { + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "reserved for the web process" +} + +@test "(proxy:route:set) refuses path without leading slash" { + run /bin/bash -c "dokku proxy:route:set $TEST_APP api api/v0" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "must start with /" +} + +@test "(proxy:route:set) refuses path with trailing slash" { + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0/" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "must not end with /" +} + +@test "(proxy:route:set) refuses ports out of range" { + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0 --port 70000" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "out of range" +} + +@test "(proxy:route:set) warns when target process is scaled to zero" { + run /bin/bash -c "dokku ps:scale $TEST_APP api=0" + echo "output: $output" + echo "status: $status" + + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "scaled to 0" +} + +@test "(proxy:route:remove) deletes a route" { + run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0" + assert_success + + run /bin/bash -c "dokku proxy:route:remove $TEST_APP /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_not_contains "/api/v0" +} + +@test "(proxy:route:remove) is idempotent on a nonexistent path" { + run /bin/bash -c "dokku proxy:route:remove $TEST_APP /does/not/exist" + echo "output: $output" + echo "status: $status" + assert_success +} + +@test "(proxy:route:clear) removes every route" { + dokku proxy:route:set "$TEST_APP" api /api/v0 >/dev/null + dokku proxy:route:set "$TEST_APP" api /api/v0/admin >/dev/null + dokku proxy:route:set "$TEST_APP" ws /ws --port 8080 >/dev/null + + run /bin/bash -c "dokku proxy:route:clear $TEST_APP" + 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 "(none)" +} + +@test "(proxy:route:clear) is idempotent on an empty route set" { + run /bin/bash -c "dokku proxy:route:clear $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success +} + +@test "(proxy:route:report) supports json output" { + dokku proxy:route:set "$TEST_APP" api /api/v0 --port 5001 >/dev/null + dokku proxy:route:set "$TEST_APP" ws /ws --port 8080 --strip-prefix >/dev/null + + run /bin/bash -c "dokku proxy:route:report $TEST_APP --format json | jq -e '.routes | length'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "2" + + run /bin/bash -c "dokku proxy:route:report $TEST_APP --format json | jq -r '.routes[] | select(.path == \"/ws\") | .strip_prefix'" + echo "output: $output" + echo "status: $status" + assert_output "true" +} + +@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" + + 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" +}