Merge pull request #8726 from dokku/8725-configuring-path-based-routing-to-specific-procfile-processes

Route paths to non-web processes via proxy:route
This commit is contained in:
Jose Diaz-Gonzalez
2026-11-05 19:42:56 -05:00
committed by GitHub
31 changed files with 2438 additions and 11 deletions

View File

@@ -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

View File

@@ -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 <app>` 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 <app> <process>=1`). `proxy:route:set` warns when this is the case.
### Container network interface binding
> Changed as of 0.11.0

View File

@@ -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 @<slug>` 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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -0,0 +1,203 @@
package proxy
import (
"encoding/json"
"errors"
"fmt"
"sort"
"github.com/dokku/dokku/plugins/common"
)
// CommandRouteSet handles `dokku proxy:route:set <app> <process> <path>
// [--port <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 <app> <process> <path> [--port <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 <app> <path>`.
// 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 <app> <path>")
}
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 <app>`. 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 [<app>] [--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
}

242
plugins/proxy/routes.go Normal file
View File

@@ -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:
// "<process>:<port>:<strip>" where <strip> 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 "<proc>=<count>" 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
}

View File

@@ -23,6 +23,10 @@ Additional commands:`
proxy:disable <app>, Disable proxy for app
proxy:enable <app>, Enable proxy for app
proxy:report [<app>] [<flag>], Displays a proxy report for one or more apps
proxy:route:clear <app>, Removes every path-based route for an app
proxy:route:remove <app> <path>, Removes a path-based route from an app
proxy:route:report [<app>] [--format json], Displays the path-based routes for an app
proxy:route:set <app> <process> <path> [--port port] [--strip-prefix], Adds or updates a path-based route to a non-web process
proxy:set <app> <proxy-type>, Set proxy type for app`
)

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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"

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

233
tests/unit/proxy-route.bats Normal file
View File

@@ -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"
}