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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
203
plugins/proxy/route_subcommands.go
Normal file
203
plugins/proxy/route_subcommands.go
Normal 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
242
plugins/proxy/routes.go
Normal 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
|
||||
}
|
||||
@@ -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`
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
100
plugins/scheduler-k3s/routes.go
Normal file
100
plugins/scheduler-k3s/routes.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
147
tests/unit/proxy-route-caddy.bats
Normal file
147
tests/unit/proxy-route-caddy.bats
Normal 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"
|
||||
}
|
||||
163
tests/unit/proxy-route-k3s-nginx.bats
Normal file
163
tests/unit/proxy-route-k3s-nginx.bats
Normal 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"
|
||||
}
|
||||
163
tests/unit/proxy-route-k3s-traefik.bats
Normal file
163
tests/unit/proxy-route-k3s-traefik.bats
Normal 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"
|
||||
}
|
||||
145
tests/unit/proxy-route-nginx.bats
Normal file
145
tests/unit/proxy-route-nginx.bats
Normal 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"
|
||||
}
|
||||
147
tests/unit/proxy-route-openresty.bats
Normal file
147
tests/unit/proxy-route-openresty.bats
Normal 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"
|
||||
}
|
||||
160
tests/unit/proxy-route-traefik.bats
Normal file
160
tests/unit/proxy-route-traefik.bats
Normal 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
233
tests/unit/proxy-route.bats
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user