feat: route paths to non-web processes via proxy:route
Adds proxy:route:set/remove/clear/report so users can declare path-prefix routes to non-web Procfile processes without hand-writing nginx.conf.sigil. Routes are stored on the proxy plugin as a PropertyMap of path to process:port:strip, with set semantics so each invocation produces a fully-determined state and re-application is idempotent. A proxy-supports-routes capability trigger gates the command per backend - nginx-vhosts, traefik-vhosts, caddy-vhosts, and scheduler-k3s render routes today; openresty-vhosts and haproxy-vhosts refuse until their sidecar images learn a path-prefix vocabulary (tracked in dokku/openresty-docker-proxy#137 and the haproxy sidecar). Closes #8725.
This commit is contained in:
@@ -2372,6 +2372,45 @@ set -eo pipefail; [[ $DOKKU_TRACE ]] && set -x
|
|||||||
# TODO
|
# 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-supports-routes`
|
||||||
|
|
||||||
|
- Description: Returns `true` if the proxy backend currently selected for the app supports path-based routes (`proxy:route:set`), `false` otherwise. Implemented by each in-tree proxy plugin (nginx, traefik, caddy, and k3s return `true`; openresty and haproxy return `false`). The `proxy:route:set` command calls this trigger and refuses with a "not supported" message when the result is `false`.
|
||||||
|
- Invoked by: `proxy:route:set` in the `proxy` plugin
|
||||||
|
- Arguments: `$APP`
|
||||||
|
- Example:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -eo pipefail; [[ $DOKKU_TRACE ]] && set -x
|
||||||
|
|
||||||
|
APP="$1"
|
||||||
|
|
||||||
|
if [[ "$(plugn trigger proxy-type "$APP")" != "my-proxy" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "true"
|
||||||
|
```
|
||||||
|
|
||||||
### `proxy-type`
|
### `proxy-type`
|
||||||
|
|
||||||
- Description: Returns the proxy type for an app
|
- Description: Returns the proxy type for an app
|
||||||
|
|||||||
@@ -188,6 +188,94 @@ 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.
|
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 supported by the `nginx`, `traefik`, `caddy`, and `k3s` proxy backends. The `openresty` and `haproxy` backends do not currently support it; `proxy:route:set` exits with a "not supported" message under those backends. Support is tracked in [dokku/openresty-docker-proxy#137](https://github.com/dokku/openresty-docker-proxy/issues/137) and the `haproxy-vhosts` sidecar.
|
||||||
|
|
||||||
|
#### 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** and **caddy**: routes are applied via Docker labels on the target containers. Containers must be recreated for new or removed labels to take effect. The command prints a notice instructing you to run `dokku ps:rebuild <app>` to recreate containers.
|
||||||
|
- **k3s**: route changes take effect on the next deploy when the chart is re-rendered.
|
||||||
|
- **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
|
### Container network interface binding
|
||||||
|
|
||||||
> Changed as of 0.11.0
|
> 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 proxy_container_https_port proxy_container_https_port_candidate proxy_host_https_port_candidate
|
||||||
local STDIN=$(cat)
|
local STDIN=$(cat)
|
||||||
|
|
||||||
if [[ "$PROC_TYPE" != "web" ]]; then
|
if [[ "$(plugn trigger proxy-type "$APP")" != "caddy" ]]; then
|
||||||
return
|
return
|
||||||
fi
|
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
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,70 @@ source "$PLUGIN_CORE_AVAILABLE_PATH/common/property-functions"
|
|||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
[[ $DOKKU_TRACE ]] && set -x
|
[[ $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
|
||||||
|
# Joining the same site declared by the web container so this container's
|
||||||
|
# handler directives are merged into the existing site block by
|
||||||
|
# caddy-docker-proxy.
|
||||||
|
output="--label 'caddy=${caddy_domains}'"
|
||||||
|
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}/*'"
|
||||||
|
output="$output --label 'caddy.${route_index}_route=${matcher_name}'"
|
||||||
|
local sub_index=0
|
||||||
|
if [[ "$route_strip" == "1" ]]; then
|
||||||
|
output="$output --label 'caddy.${route_index}_route.${sub_index}_uri=strip_prefix ${route_path}'"
|
||||||
|
sub_index=$((sub_index + 1))
|
||||||
|
fi
|
||||||
|
output="$output --label \"caddy.${route_index}_route.${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() {
|
fn-caddy-logs() {
|
||||||
declare desc="shows the logs for the caddy container"
|
declare desc="shows the logs for the caddy container"
|
||||||
declare TAIL="$1" NUM="$2"
|
declare TAIL="$1" NUM="$2"
|
||||||
|
|||||||
18
plugins/caddy-vhosts/proxy-supports-routes
Executable file
18
plugins/caddy-vhosts/proxy-supports-routes
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eo pipefail
|
||||||
|
[[ $DOKKU_TRACE ]] && set -x
|
||||||
|
source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions"
|
||||||
|
|
||||||
|
trigger-caddy-vhosts-proxy-supports-routes() {
|
||||||
|
declare desc="advertises path-based routing support for caddy"
|
||||||
|
declare trigger="proxy-supports-routes"
|
||||||
|
declare APP="$1"
|
||||||
|
|
||||||
|
if [[ "$(plugn trigger proxy-type "$APP")" != "caddy" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger-caddy-vhosts-proxy-supports-routes "$@"
|
||||||
21
plugins/haproxy-vhosts/proxy-supports-routes
Executable file
21
plugins/haproxy-vhosts/proxy-supports-routes
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eo pipefail
|
||||||
|
[[ $DOKKU_TRACE ]] && set -x
|
||||||
|
source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions"
|
||||||
|
|
||||||
|
trigger-haproxy-vhosts-proxy-supports-routes() {
|
||||||
|
declare desc="advertises path-based routing support for haproxy (currently false)"
|
||||||
|
declare trigger="proxy-supports-routes"
|
||||||
|
declare APP="$1"
|
||||||
|
|
||||||
|
if [[ "$(plugn trigger proxy-type "$APP")" != "haproxy" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The byjg/easy-haproxy sidecar image only supports host-based routing.
|
||||||
|
# Path-prefix routing requires either an upstream change to that sidecar
|
||||||
|
# or a different config-generation strategy.
|
||||||
|
echo "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger-haproxy-vhosts-proxy-supports-routes "$@"
|
||||||
@@ -236,6 +236,33 @@ fn-nginx-vhosts-render-template() {
|
|||||||
PROXY_PORT_MAP="$(echo "$proxy_port_map" | xargs)" # trailing spaces mess up default template
|
PROXY_PORT_MAP="$(echo "$proxy_port_map" | xargs)" # trailing spaces mess up default template
|
||||||
PROXY_UPSTREAM_PORTS="$(echo "$PROXY_UPSTREAM_PORTS" | xargs)"
|
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 SSL_INUSE=
|
||||||
local NONSSL_VHOSTS=$(plugn trigger domains-list "$APP")
|
local NONSSL_VHOSTS=$(plugn trigger domains-list "$APP")
|
||||||
local NOSSL_SERVER_NAME=$(echo "$NONSSL_VHOSTS" | xargs)
|
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"
|
NGINX_PORT="$PROXY_PORT" NGINX_SSL_PORT="$PROXY_SSL_PORT"
|
||||||
PROXY_PORT="$PROXY_PORT" PROXY_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_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_FOR="$PROXY_X_FORWARDED_FOR"
|
||||||
PROXY_X_FORWARDED_PORT="$PROXY_X_FORWARDED_PORT"
|
PROXY_X_FORWARDED_PORT="$PROXY_X_FORWARDED_PORT"
|
||||||
PROXY_X_FORWARDED_PROTO="$PROXY_X_FORWARDED_PROTO"
|
PROXY_X_FORWARDED_PROTO="$PROXY_X_FORWARDED_PROTO"
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ require (
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||||
github.com/ryanuber/columnize v2.1.2+incompatible // indirect
|
github.com/ryanuber/columnize v2.1.2+incompatible // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.58.0 // indirect
|
golang.org/x/crypto v0.58.0 // indirect
|
||||||
golang.org/x/sync v0.24.0 // indirect
|
golang.org/x/sync v0.24.0 // indirect
|
||||||
golang.org/x/sys v0.49.0 // indirect
|
golang.org/x/sys v0.49.0 // indirect
|
||||||
|
|||||||
18
plugins/nginx-vhosts/proxy-supports-routes
Executable file
18
plugins/nginx-vhosts/proxy-supports-routes
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eo pipefail
|
||||||
|
[[ $DOKKU_TRACE ]] && set -x
|
||||||
|
source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions"
|
||||||
|
|
||||||
|
trigger-nginx-vhosts-proxy-supports-routes() {
|
||||||
|
declare desc="advertises path-based routing support for nginx"
|
||||||
|
declare trigger="proxy-supports-routes"
|
||||||
|
declare APP="$1"
|
||||||
|
|
||||||
|
if [[ "$(plugn trigger proxy-type "$APP")" != "nginx" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger-nginx-vhosts-proxy-supports-routes "$@"
|
||||||
@@ -46,6 +46,34 @@ server {
|
|||||||
return 301 https://$host:{{ $.PROXY_SSL_PORT }}$request_uri;
|
return 301 https://$host:{{ $.PROXY_SSL_PORT }}$request_uri;
|
||||||
}
|
}
|
||||||
{{ else }}
|
{{ 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 / {
|
location / {
|
||||||
{{ if $.DOKKU_APP_WEB_LISTENERS }}
|
{{ if $.DOKKU_APP_WEB_LISTENERS }}
|
||||||
|
|
||||||
@@ -158,3 +186,19 @@ upstream {{ $.APP }}-{{ $upstream_port }} {
|
|||||||
server {{ $listener_ip }}:{{ $upstream_port }};{{ end }}
|
server {{ $listener_ip }}:{{ $upstream_port }};{{ end }}
|
||||||
}
|
}
|
||||||
{{ end }}{{ 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 }}
|
||||||
|
|||||||
20
plugins/openresty-vhosts/proxy-supports-routes
Executable file
20
plugins/openresty-vhosts/proxy-supports-routes
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eo pipefail
|
||||||
|
[[ $DOKKU_TRACE ]] && set -x
|
||||||
|
source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions"
|
||||||
|
|
||||||
|
trigger-openresty-vhosts-proxy-supports-routes() {
|
||||||
|
declare desc="advertises path-based routing support for openresty (currently false)"
|
||||||
|
declare trigger="proxy-supports-routes"
|
||||||
|
declare APP="$1"
|
||||||
|
|
||||||
|
if [[ "$(plugn trigger proxy-type "$APP")" != "openresty" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The openresty sidecar image does not yet accept path-prefix routing labels.
|
||||||
|
# Tracked upstream at dokku/openresty-docker-proxy#137.
|
||||||
|
echo "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger-openresty-vhosts-proxy-supports-routes "$@"
|
||||||
@@ -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
|
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-type triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-delete triggers/report
|
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
|
BUILD = commands subcommands triggers
|
||||||
PLUGIN_NAME = proxy
|
PLUGIN_NAME = proxy
|
||||||
|
|
||||||
|
|||||||
212
plugins/proxy/route_subcommands.go
Normal file
212
plugins/proxy/route_subcommands.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
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]")
|
||||||
|
}
|
||||||
|
|
||||||
|
supported, err := SupportsRoutes(appName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !supported {
|
||||||
|
proxyType := getComputedProxyType(appName)
|
||||||
|
return fmt.Errorf("path-based routing is not yet supported with the %s proxy backend", proxyType)
|
||||||
|
}
|
||||||
|
|
||||||
|
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":
|
||||||
|
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
|
||||||
|
}
|
||||||
257
plugins/proxy/routes.go
Normal file
257
plugins/proxy/routes.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportsRoutes returns true if the proxy backend selected for the app
|
||||||
|
// supports path-based routing, as declared by the proxy-supports-routes
|
||||||
|
// trigger. Backends that do not implement the trigger are treated as not
|
||||||
|
// supporting routes (conservative default).
|
||||||
|
func SupportsRoutes(appName string) (bool, error) {
|
||||||
|
result, err := common.CallPlugnTrigger(common.PlugnTriggerInput{
|
||||||
|
Trigger: "proxy-supports-routes",
|
||||||
|
Args: []string{appName},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return result.StdoutContents() == "true", 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:disable <app>, Disable proxy for app
|
||||||
proxy:enable <app>, Enable proxy for app
|
proxy:enable <app>, Enable proxy for app
|
||||||
proxy:report [<app>] [<flag>], Displays a proxy report for one or more apps
|
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`
|
proxy:set <app> <proxy-type>, Set proxy type for app`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,32 @@ func main() {
|
|||||||
value = args.Arg(1)
|
value = args.Arg(1)
|
||||||
}
|
}
|
||||||
err = proxy.CommandSet(appName, property, value)
|
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:
|
default:
|
||||||
err = fmt.Errorf("Invalid plugin subcommand call: %s", subcommand)
|
err = fmt.Errorf("Invalid plugin subcommand call: %s", subcommand)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ func main() {
|
|||||||
case "proxy-type":
|
case "proxy-type":
|
||||||
appName := flag.Arg(0)
|
appName := flag.Arg(0)
|
||||||
err = proxy.TriggerProxyType(appName)
|
err = proxy.TriggerProxyType(appName)
|
||||||
|
case "proxy-routes-list":
|
||||||
|
appName := flag.Arg(0)
|
||||||
|
err = proxy.TriggerProxyRoutesList(appName)
|
||||||
case "post-app-clone-setup":
|
case "post-app-clone-setup":
|
||||||
oldAppName := flag.Arg(0)
|
oldAppName := flag.Arg(0)
|
||||||
newAppName := flag.Arg(1)
|
newAppName := flag.Arg(1)
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ func TriggerProxyType(appName string) error {
|
|||||||
return nil
|
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
|
// TriggerPostAppCloneSetup creates new proxy files
|
||||||
func TriggerPostAppCloneSetup(oldAppName string, newAppName string) error {
|
func TriggerPostAppCloneSetup(oldAppName string, newAppName string) error {
|
||||||
return common.PropertyClone("proxy", oldAppName, newAppName)
|
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))
|
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{
|
values := &AppValues{
|
||||||
Global: GlobalValues{
|
Global: GlobalValues{
|
||||||
Annotations: globalAnnotations,
|
Annotations: globalAnnotations,
|
||||||
@@ -311,6 +317,7 @@ func BuildAppChart(ctx context.Context, appName, imageTag string, opts BuildOpti
|
|||||||
IngressClass: getComputedIngressClass(),
|
IngressClass: getComputedIngressClass(),
|
||||||
PrimaryPort: primaryPort,
|
PrimaryPort: primaryPort,
|
||||||
PrimaryServicePort: primaryServicePort,
|
PrimaryServicePort: primaryServicePort,
|
||||||
|
Routes: appRoutes,
|
||||||
},
|
},
|
||||||
SecurityContext: securityContext,
|
SecurityContext: securityContext,
|
||||||
},
|
},
|
||||||
@@ -508,13 +515,32 @@ func BuildAppChart(ctx context.Context, appName, imageTag string, opts BuildOpti
|
|||||||
Protocol: PortmapProtocol_TCP,
|
Protocol: PortmapProtocol_TCP,
|
||||||
Scheme: "http",
|
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
|
values.Processes[processType] = processValues
|
||||||
|
|
||||||
templateFiles := []string{"deployment", "keda-scaled-object"}
|
templateFiles := []string{"deployment", "keda-scaled-object"}
|
||||||
if processType == "web" {
|
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 {
|
for _, templateName := range templateFiles {
|
||||||
b, err := templates.ReadFile(fmt.Sprintf("templates/chart/%s.yaml", templateName))
|
b, err := templates.ReadFile(fmt.Sprintf("templates/chart/%s.yaml", templateName))
|
||||||
|
|||||||
18
plugins/scheduler-k3s/proxy-supports-routes
Executable file
18
plugins/scheduler-k3s/proxy-supports-routes
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eo pipefail
|
||||||
|
[[ $DOKKU_TRACE ]] && set -x
|
||||||
|
source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions"
|
||||||
|
|
||||||
|
trigger-scheduler-k3s-proxy-supports-routes() {
|
||||||
|
declare desc="advertises path-based routing support for k3s"
|
||||||
|
declare trigger="proxy-supports-routes"
|
||||||
|
declare APP="$1"
|
||||||
|
|
||||||
|
if [[ "$(plugn trigger proxy-type "$APP")" != "k3s" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger-scheduler-k3s-proxy-supports-routes "$@"
|
||||||
96
plugins/scheduler-k3s/routes.go
Normal file
96
plugins/scheduler-k3s/routes.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
port, err := strconv.Atoi(fields[1])
|
||||||
|
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 is the primary service port to use
|
||||||
PrimaryServicePort int32 `yaml:"primary_service_port"`
|
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
|
// GlobalKedaValues contains the global keda configuration
|
||||||
|
|||||||
@@ -38,6 +38,28 @@ spec:
|
|||||||
- {{ $entryPoint }}
|
- {{ $entryPoint }}
|
||||||
routes:
|
routes:
|
||||||
{{- range $ddx, $domain := $config.web.domains }}
|
{{- range $ddx, $domain := $config.web.domains }}
|
||||||
|
{{- range $rdx, $route := $.Values.global.network.routes }}
|
||||||
|
- kind: Rule
|
||||||
|
match: Host(`{{ $domain.name }}`) && PathPrefix(`{{ $route.path }}`)
|
||||||
|
priority: {{ len $route.path }}
|
||||||
|
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
|
- kind: Rule
|
||||||
match: {{ include "traefik.host.match" $domain.name }}
|
match: {{ include "traefik.host.match" $domain.name }}
|
||||||
{{- if hasPrefix "*." $domain.name }}
|
{{- if hasPrefix "*." $domain.name }}
|
||||||
|
|||||||
@@ -65,6 +65,76 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
path: /
|
path: /
|
||||||
{{- end }}
|
{{- 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 }}
|
{{- 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 proxy_container_https_port proxy_container_https_port_candidate proxy_host_https_port_candidate
|
||||||
local STDIN=$(cat)
|
local STDIN=$(cat)
|
||||||
|
|
||||||
if [[ "$PROC_TYPE" != "web" ]]; then
|
if [[ "$(plugn trigger proxy-type "$APP")" != "traefik" ]]; then
|
||||||
return
|
return
|
||||||
fi
|
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
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,87 @@ source "$PLUGIN_CORE_AVAILABLE_PATH/common/property-functions"
|
|||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
[[ $DOKKU_TRACE ]] && set -x
|
[[ $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)"
|
||||||
|
output="$output --label traefik.http.routers.${router_name}.priority=${path_len}"
|
||||||
|
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() {
|
fn-traefik-logs() {
|
||||||
declare desc="shows the logs for the traefik container"
|
declare desc="shows the logs for the traefik container"
|
||||||
declare TAIL="$1" NUM="$2"
|
declare TAIL="$1" NUM="$2"
|
||||||
|
|||||||
18
plugins/traefik-vhosts/proxy-supports-routes
Executable file
18
plugins/traefik-vhosts/proxy-supports-routes
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eo pipefail
|
||||||
|
[[ $DOKKU_TRACE ]] && set -x
|
||||||
|
source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions"
|
||||||
|
|
||||||
|
trigger-traefik-vhosts-proxy-supports-routes() {
|
||||||
|
declare desc="advertises path-based routing support for traefik"
|
||||||
|
declare trigger="proxy-supports-routes"
|
||||||
|
declare APP="$1"
|
||||||
|
|
||||||
|
if [[ "$(plugn trigger proxy-type "$APP")" != "traefik" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger-traefik-vhosts-proxy-supports-routes "$@"
|
||||||
249
tests/unit/proxy-route.bats
Normal file
249
tests/unit/proxy-route.bats
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
#!/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) refuses under openresty backend" {
|
||||||
|
dokku proxy:set "$TEST_APP" openresty >/dev/null 2>&1 || skip "openresty proxy not available"
|
||||||
|
|
||||||
|
run /bin/bash -c "dokku proxy:route:set $TEST_APP api /api/v0"
|
||||||
|
echo "output: $output"
|
||||||
|
echo "status: $status"
|
||||||
|
assert_failure
|
||||||
|
assert_output_contains "not yet supported"
|
||||||
|
|
||||||
|
run /bin/bash -c "dokku proxy:route:report $TEST_APP"
|
||||||
|
echo "output: $output"
|
||||||
|
echo "status: $status"
|
||||||
|
assert_output_contains "(none)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "(proxy:route:set) refuses under haproxy backend" {
|
||||||
|
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_failure
|
||||||
|
assert_output_contains "not yet supported"
|
||||||
|
|
||||||
|
run /bin/bash -c "dokku proxy:route:report $TEST_APP"
|
||||||
|
echo "output: $output"
|
||||||
|
echo "status: $status"
|
||||||
|
assert_output_contains "(none)"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user