diff --git a/docs/appendices/0.38.0-migration-guide.md b/docs/appendices/0.38.0-migration-guide.md index bd1f3f426..cf0978b50 100644 --- a/docs/appendices/0.38.0-migration-guide.md +++ b/docs/appendices/0.38.0-migration-guide.md @@ -21,7 +21,7 @@ - The `docker-local` scheduler now sends `SIGTERM` to old containers immediately after a successful deploy, rather than waiting `wait-to-retire` seconds before signaling. This matches Heroku's graceful-shutdown contract and lets applications begin draining in-flight work as soon as proxy traffic switches. The `wait-to-retire` grace period and `stop-timeout-seconds` hard-stop continue to apply as before. See the [zero downtime deploys documentation](/docs/deployment/zero-downtime-deploys.md#wait-to-retire) for more details. - The `docker-local` scheduler no longer queues an image for retirement when another running container of the same app still uses it. This fixes the case where a `ps:rebuild` against an image-based deploy (`git:from-image`) produced an identical-SHA image and the `dokku-retire` cron timer would log `Image ... has running containers, skipping rm` on every run. Stuck entries from prior versions are pruned automatically on the next `ps:retire` run. - All `:report` subcommands now accept the `--global` flag, which scopes the report to globally-configured properties. The flag composes with `--format json`, so a JSON report of global properties can be obtained via, for example, `dokku scheduler:report --global --format json`. Previously, combining `--global` with `--format json` was rejected with an "info flag" error, and `--global` on its own was treated as an unknown flag. -- Every `:report` key of the form `-global-` now returns the raw stored global value (empty when the property has never been set) instead of the resolved value with the built-in default substituted in. A new `-computed-` key has been added wherever a default existed and returns the effective value (per-app value, falling back to the global value, then to the built-in default). This affects `caddy`, `haproxy`, `traefik`, `app-json`, `builder-dockerfile`, `builder-herokuish`, `builder-lambda`, `builder-nixpacks`, `builder-pack`, `builder-railpack`, `checks`, `git`, `logs`, `network`, `proxy`, and `scheduler`. External tooling that read `-global-` and depended on the default value should switch to `-computed-`. The bare `-` keys for caddy/haproxy/traefik global-only properties (`caddy-image`, `haproxy-log-level`, `traefik-api-enabled`, etc.) have also been replaced by the `global-` and `computed-` pair. +- Every `:report` key of the form `-global-` now returns the raw stored global value (empty when the property has never been set) instead of the resolved value with the built-in default substituted in. A new `-computed-` key has been added wherever a default existed and returns the effective value (per-app value, falling back to the global value, then to the built-in default). This affects `caddy`, `haproxy`, `traefik`, `openresty`, `app-json`, `builder-dockerfile`, `builder-herokuish`, `builder-lambda`, `builder-nixpacks`, `builder-pack`, `builder-railpack`, `checks`, `git`, `logs`, `network`, `proxy`, and `scheduler`. External tooling that read `-global-` and depended on the default value should switch to `-computed-`. The bare `-` keys for caddy/haproxy/traefik/openresty global-only properties (`caddy-image`, `haproxy-log-level`, `traefik-api-enabled`, `openresty-image`, `openresty-letsencrypt-email`, `openresty-letsencrypt-server`, `openresty-allowed-letsencrypt-domains-func-base64`, etc.) have also been replaced by the `global-` and `computed-` pair. - The `scheduler-k3s` plugin now manages env config and the dokku-generated image pull Secret as their own helm releases with stable names (`config-{app}` and `pull-secret-{app}`) rather than bundling them into the app helm chart with a per-deploy timestamp suffix (`env-{app}.{ts}` / `ims-{app}.{ts}`). This fixes two bugs: a helm rollback of the app chart no longer deletes Secrets that older ReplicaSets still reference, and the Deployment's `imagePullSecrets` list no longer accumulates references to nonexistent Secrets across deploys. The next deploy of an app switches the Deployment's `envFrom` and `imagePullSecrets` references to the stable names and prunes any leaked entries; existing live Deployments do not need to be patched manually. App rename now also uninstalls the old `tls-{app}`, `config-{app}`, and `pull-secret-{app}` releases under the previous app name; the new name's releases are recreated on the next deploy or certs sync. - The storage plugin now treats persistent volumes as named, scheduler-aware first-class resources via `storage:create`, `storage:mount`, `storage:set`, and `storage:destroy`. The legacy `storage:mount :` colon form continues to work on docker-local apps but is deprecated; on k3s apps it is rejected. Existing colon-form mounts are migrated automatically the first time the new storage plugin runs (during the install trigger) - they appear as `legacy-` entries in `storage:list-entries`. The migration is idempotent and tied to a per-app flag file at `$DOKKU_LIB_ROOT/config/storage/.migrated/`; deleting that file forces a re-scan on the next install. The `storage:ensure-directory` command keeps working but now emits a deprecation warning - prefer `storage:create []` (the path defaults to the same `$DOKKU_LIB_ROOT/data/storage/` location). Storage entry names must now be DNS-1123 labels of 45 characters or less so they can be used verbatim as Helm release and Kubernetes resource names; underscores and uppercase characters that the older `ensure-directory` validator accepted are rejected for new names. The migration synthesizer always uses lowercase hex hashes so existing data is never locked out. diff --git a/docs/networking/proxies/openresty.md b/docs/networking/proxies/openresty.md index dd79f94e1..ed8caf404 100644 --- a/docs/networking/proxies/openresty.md +++ b/docs/networking/proxies/openresty.md @@ -277,7 +277,7 @@ dokku openresty:report node-js-app You can pass flags which will output only the value of the specific information you want. For example: ```shell -dokku openresty:report node-js-app --openresty-letsencrypt-email +dokku openresty:report node-js-app --openresty-computed-letsencrypt-email ``` ## Properties @@ -286,13 +286,13 @@ dokku openresty:report node-js-app --openresty-letsencrypt-email Five properties (`image`, `log-level`, `letsencrypt-email`, `letsencrypt-server`, `allowed-letsencrypt-domains-func-base64`) are global only. The rest are app only - they cannot be set with `--global`. -`openresty:report` currently exposes a single raw flag per property (no `--global-` / `--computed-` split) except for `hsts`, which has all three flag shapes. +Global-only properties expose two report flags: `--openresty-global-` returns the raw stored value (empty when the property has never been set), while `--openresty-computed-` returns the effective value (the global value if set, otherwise the built-in default). The per-app `hsts` property additionally has a bare `--openresty-hsts` flag for the raw per-app value. | Property | Scope | Default | Report flags | Description | |---|---|---|---|---| | `access-log-format` | app only | none | `--openresty-access-log-format` | Custom nginx `log_format` directive used for the access log | | `access-log-path` | app only | _`/var/log/nginx/{app}-access.log`_ | `--openresty-access-log-path` | Path inside the openresty container where access logs are written | -| `allowed-letsencrypt-domains-func-base64` | global only | _allow-all stub_ | `--openresty-allowed-letsencrypt-domains-func-base64` | Base64-encoded Lua function deciding which domains may request a letsencrypt certificate | +| `allowed-letsencrypt-domains-func-base64` | global only | _allow-all stub_ | `--openresty-global-allowed-letsencrypt-domains-func-base64`, `--openresty-computed-allowed-letsencrypt-domains-func-base64` | Base64-encoded Lua function deciding which domains may request a letsencrypt certificate | | `bind-address-ipv4` | app only | none | `--openresty-bind-address-ipv4` | IPv4 address the openresty server block binds to | | `bind-address-ipv6` | app only | `::` | `--openresty-bind-address-ipv6` | IPv6 address the openresty server block binds to | | `client-body-timeout` | app only | `60s` | `--openresty-client-body-timeout` | Time allowed to read the request body from the client | @@ -303,10 +303,10 @@ Five properties (`image`, `log-level`, `letsencrypt-email`, `letsencrypt-server` | `hsts-include-subdomains` | app only | `true` | `--openresty-hsts-include-subdomains` | Adds the `includeSubDomains` directive to the HSTS header | | `hsts-max-age` | app only | `15724800` | `--openresty-hsts-max-age` | `max-age` value (seconds) in the HSTS header | | `hsts-preload` | app only | `false` | `--openresty-hsts-preload` | Adds the `preload` directive to the HSTS header | -| `image` | global only | _parsed from `plugins/openresty-vhosts/Dockerfile`_ | `--openresty-image` | Docker image used to run the openresty container | +| `image` | global only | _parsed from `plugins/openresty-vhosts/Dockerfile`_ | `--openresty-global-image`, `--openresty-computed-image` | Docker image used to run the openresty container | | `keepalive-timeout` | app only | `75s` | `--openresty-keepalive-timeout` | Time an idle keep-alive connection stays open | -| `letsencrypt-email` | global only | none | `--openresty-letsencrypt-email` | Contact email enabling letsencrypt; empty disables https issuance | -| `letsencrypt-server` | global only | `https://acme-v02.api.letsencrypt.org/directory` | `--openresty-letsencrypt-server` | ACME directory used when requesting certificates | +| `letsencrypt-email` | global only | none | `--openresty-global-letsencrypt-email`, `--openresty-computed-letsencrypt-email` | Contact email enabling letsencrypt; empty disables https issuance | +| `letsencrypt-server` | global only | `https://acme-v02.api.letsencrypt.org/directory` | `--openresty-global-letsencrypt-server`, `--openresty-computed-letsencrypt-server` | ACME directory used when requesting certificates | | `lingering-timeout` | app only | `5s` | `--openresty-lingering-timeout` | Time openresty waits for more client data when closing a connection | | `log-level` | global only | `ERROR` | (not in report) | Openresty log level | | `proxy-buffer-size` | app only | _system pagesize_ | `--openresty-proxy-buffer-size` | Buffer size for reading the first part of the upstream response | diff --git a/plugins/openresty-vhosts/certs-force b/plugins/openresty-vhosts/certs-force index fdaaf7285..e45a7ada9 100755 --- a/plugins/openresty-vhosts/certs-force +++ b/plugins/openresty-vhosts/certs-force @@ -13,7 +13,7 @@ trigger-openresty-vhosts-certs-force() { return fi - if [[ -n "$(fn-openresty-letsencrypt-email)" ]]; then + if [[ -n "$(fn-openresty-computed-letsencrypt-email)" ]]; then echo true fi } diff --git a/plugins/openresty-vhosts/command-functions b/plugins/openresty-vhosts/command-functions index cc31c01f1..89e53ef96 100755 --- a/plugins/openresty-vhosts/command-functions +++ b/plugins/openresty-vhosts/command-functions @@ -70,34 +70,43 @@ cmd-openresty-report-single() { local flag_map=() if [[ "$APP" == "--global" ]]; then flag_map=( - "--openresty-allowed-letsencrypt-domains-func-base64: $(fn-openresty-allowed-letsencrypt-domains-func-base64)" - "--openresty-global-hsts: $(fn-plugin-property-get-default "openresty" "--global" "hsts" "true")" - "--openresty-image: $(fn-openresty-image)" - "--openresty-letsencrypt-email: $(fn-openresty-letsencrypt-email)" - "--openresty-letsencrypt-server: $(fn-openresty-letsencrypt-server)" + "--openresty-global-allowed-letsencrypt-domains-func-base64: $(fn-openresty-global-allowed-letsencrypt-domains-func-base64)" + "--openresty-computed-allowed-letsencrypt-domains-func-base64: $(fn-openresty-computed-allowed-letsencrypt-domains-func-base64)" + "--openresty-global-hsts: $(fn-plugin-property-get-default "openresty" "--global" "hsts" "")" + "--openresty-computed-hsts: $(fn-openresty-hsts-is-enabled "--global")" + "--openresty-global-image: $(fn-openresty-global-image)" + "--openresty-computed-image: $(fn-openresty-computed-image)" + "--openresty-global-letsencrypt-email: $(fn-openresty-global-letsencrypt-email)" + "--openresty-computed-letsencrypt-email: $(fn-openresty-computed-letsencrypt-email)" + "--openresty-global-letsencrypt-server: $(fn-openresty-global-letsencrypt-server)" + "--openresty-computed-letsencrypt-server: $(fn-openresty-computed-letsencrypt-server)" ) else verify_app_name "$APP" flag_map=( "--openresty-access-log-format: $(fn-openresty-access-log-format "$APP")" "--openresty-access-log-path: $(fn-openresty-access-log-path "$APP")" - "--openresty-allowed-letsencrypt-domains-func-base64: $(fn-openresty-allowed-letsencrypt-domains-func-base64)" + "--openresty-global-allowed-letsencrypt-domains-func-base64: $(fn-openresty-global-allowed-letsencrypt-domains-func-base64)" + "--openresty-computed-allowed-letsencrypt-domains-func-base64: $(fn-openresty-computed-allowed-letsencrypt-domains-func-base64)" "--openresty-bind-address-ipv4: $(fn-openresty-bind-address-ipv4 "$APP")" "--openresty-bind-address-ipv6: $(fn-openresty-bind-address-ipv6 "$APP")" "--openresty-client-body-timeout: $(fn-openresty-client-body-timeout "$APP")" "--openresty-client-header-timeout: $(fn-openresty-client-header-timeout "$APP")" "--openresty-client-max-body-size: $(fn-openresty-client-max-body-size "$APP")" "--openresty-error-log-path: $(fn-openresty-error-log-path "$APP")" - "--openresty-global-hsts: $(fn-plugin-property-get-default "openresty" "--global" "hsts" "true")" + "--openresty-global-hsts: $(fn-plugin-property-get-default "openresty" "--global" "hsts" "")" "--openresty-computed-hsts: $(fn-openresty-hsts-is-enabled "$APP")" "--openresty-hsts: $(fn-plugin-property-get-default "openresty" "$APP" "hsts" "")" "--openresty-hsts-include-subdomains: $(fn-openresty-hsts-include-subdomains "$APP")" "--openresty-hsts-max-age: $(fn-openresty-hsts-max-age "$APP")" "--openresty-hsts-preload: $(fn-openresty-hsts-preload "$APP")" - "--openresty-image: $(fn-openresty-image)" + "--openresty-global-image: $(fn-openresty-global-image)" + "--openresty-computed-image: $(fn-openresty-computed-image)" "--openresty-keepalive-timeout: $(fn-openresty-keepalive-timeout "$APP")" - "--openresty-letsencrypt-email: $(fn-openresty-letsencrypt-email)" - "--openresty-letsencrypt-server: $(fn-openresty-letsencrypt-server)" + "--openresty-global-letsencrypt-email: $(fn-openresty-global-letsencrypt-email)" + "--openresty-computed-letsencrypt-email: $(fn-openresty-computed-letsencrypt-email)" + "--openresty-global-letsencrypt-server: $(fn-openresty-global-letsencrypt-server)" + "--openresty-computed-letsencrypt-server: $(fn-openresty-computed-letsencrypt-server)" "--openresty-lingering-timeout: $(fn-openresty-lingering-timeout "$APP")" "--openresty-proxy-buffer-size: $(fn-openresty-proxy-buffer-size "$APP")" "--openresty-proxy-buffering: $(fn-openresty-proxy-buffering "$APP")" diff --git a/plugins/openresty-vhosts/docker-args-process-deploy b/plugins/openresty-vhosts/docker-args-process-deploy index 82289778c..d75256800 100755 --- a/plugins/openresty-vhosts/docker-args-process-deploy +++ b/plugins/openresty-vhosts/docker-args-process-deploy @@ -133,7 +133,7 @@ trigger-openresty-vhosts-docker-args-process-deploy() { fi letsencrypt_value="false" - if [[ -n "$(fn-openresty-letsencrypt-email)" ]]; then + if [[ -n "$(fn-openresty-computed-letsencrypt-email)" ]]; then if [[ -n "$proxy_container_https_port" ]] || [[ -n "$proxy_container_https_port_candidate" ]]; then letsencrypt_value="true" fi diff --git a/plugins/openresty-vhosts/internal-functions b/plugins/openresty-vhosts/internal-functions index 910a517ed..c6ae8cb0b 100755 --- a/plugins/openresty-vhosts/internal-functions +++ b/plugins/openresty-vhosts/internal-functions @@ -26,8 +26,14 @@ fn-openresty-access-log-path() { fn-plugin-property-get-default "openresty" "$APP" "access-log-path" "${OPENRESTY_LOG_ROOT}/${APP}-access.log" } -fn-openresty-allowed-letsencrypt-domains-func-base64() { - declare desc="get the configured allowed domains func base64" +fn-openresty-global-allowed-letsencrypt-domains-func-base64() { + declare desc="get the raw configured allowed domains func base64" + + fn-plugin-property-get-default "openresty" "--global" "allowed-letsencrypt-domains-func-base64" "" +} + +fn-openresty-computed-allowed-letsencrypt-domains-func-base64() { + declare desc="get the effective allowed domains func base64, falling back to the default stub" fn-plugin-property-get-default "openresty" "--global" "allowed-letsencrypt-domains-func-base64" "return true" | base64 -w 0 } @@ -131,8 +137,21 @@ fn-openresty-hsts-is-enabled() { echo "$hsts_is_enabled" } -fn-openresty-image() { - fn-plugin-property-get-default "openresty" "--global" "image" "$(grep "FROM" "$PLUGIN_AVAILABLE_PATH/openresty-vhosts/Dockerfile" | awk '{print $2}')" +fn-openresty-global-image() { + declare desc="get the raw configured image" + + fn-plugin-property-get-default "openresty" "--global" "image" "" +} + +fn-openresty-computed-image() { + declare desc="get the effective image, falling back to the Dockerfile FROM line" + local value + + value="$(fn-openresty-global-image)" + if [[ -z "$value" ]]; then + value="$(grep "FROM" "$PLUGIN_AVAILABLE_PATH/openresty-vhosts/Dockerfile" | awk '{print $2}')" + fi + echo "$value" } fn-openresty-keepalive-timeout() { @@ -142,12 +161,33 @@ fn-openresty-keepalive-timeout() { fn-plugin-property-get-default "openresty" "$APP" "keepalive-timeout" "75s" } -fn-openresty-letsencrypt-email() { +fn-openresty-global-letsencrypt-email() { + declare desc="get the raw configured letsencrypt email" + fn-plugin-property-get-default "openresty" "--global" "letsencrypt-email" "" } -fn-openresty-letsencrypt-server() { - fn-plugin-property-get-default "openresty" "--global" "letsencrypt-server" "https://acme-v02.api.letsencrypt.org/directory" +fn-openresty-computed-letsencrypt-email() { + declare desc="get the effective letsencrypt email" + + fn-openresty-global-letsencrypt-email +} + +fn-openresty-global-letsencrypt-server() { + declare desc="get the raw configured letsencrypt server" + + fn-plugin-property-get-default "openresty" "--global" "letsencrypt-server" "" +} + +fn-openresty-computed-letsencrypt-server() { + declare desc="get the effective letsencrypt server, falling back to the production directory" + local value + + value="$(fn-openresty-global-letsencrypt-server)" + if [[ -z "$value" ]]; then + value="https://acme-v02.api.letsencrypt.org/directory" + fi + echo "$value" } fn-openresty-log-root() { @@ -251,11 +291,11 @@ fn-openresty-template-compose-file() { fi local SIGIL_PARAMS=(OPENRESTY_DATA_DIR="${DOKKU_LIB_ROOT}/data/openresty-vhosts/.docker-letsencrypt" - OPENRESTY_IMAGE="$(fn-openresty-image)" - OPENRESTY_LETSENCRYPT_EMAIL="$(fn-openresty-letsencrypt-email)" - OPENRESTY_LETSENCRYPT_SERVER="$(fn-openresty-letsencrypt-server)") + OPENRESTY_IMAGE="$(fn-openresty-computed-image)" + OPENRESTY_LETSENCRYPT_EMAIL="$(fn-openresty-computed-letsencrypt-email)" + OPENRESTY_LETSENCRYPT_SERVER="$(fn-openresty-computed-letsencrypt-server)") - local ALLOWED_DOMAINS_FUNC_BASE64="$(fn-openresty-allowed-letsencrypt-domains-func-base64)" + local ALLOWED_DOMAINS_FUNC_BASE64="$(fn-openresty-computed-allowed-letsencrypt-domains-func-base64)" if [[ -n "$ALLOWED_DOMAINS_FUNC_BASE64" ]]; then SIGIL_PARAMS+=(ALLOWED_DOMAINS_FUNC_BASE64="$ALLOWED_DOMAINS_FUNC_BASE64") fi diff --git a/tests/unit/openresty.bats b/tests/unit/openresty.bats index 7e6db7f4b..f47cf4704 100644 --- a/tests/unit/openresty.bats +++ b/tests/unit/openresty.bats @@ -18,12 +18,137 @@ teardown() { dokku nginx:start } -@test "(openresty:report) --global --openresty-letsencrypt-server" { - run /bin/bash -c "dokku openresty:report --global --openresty-letsencrypt-server" +@test "(openresty:report) --global --openresty-computed-letsencrypt-server" { + run /bin/bash -c "dokku openresty:report --global --openresty-computed-letsencrypt-server" echo "output: $output" echo "status: $status" assert_success assert_output "https://acme-staging-v02.api.letsencrypt.org/directory" + + run /bin/bash -c "dokku openresty:report --global --openresty-global-letsencrypt-server" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "https://acme-staging-v02.api.letsencrypt.org/directory" +} + +@test "(openresty:report) --global raw and computed keys in --format json" { + run /bin/bash -c "dokku openresty:report --global --format json | jq -r '.\"global-letsencrypt-server\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "https://acme-staging-v02.api.letsencrypt.org/directory" + + run /bin/bash -c "dokku openresty:report --global --format json | jq -r '.\"computed-letsencrypt-server\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "https://acme-staging-v02.api.letsencrypt.org/directory" + + run /bin/bash -c "dokku openresty:report --global --format json | jq -r '.\"global-letsencrypt-email\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "" + + run /bin/bash -c "dokku openresty:report --global --format json | jq -r '.\"computed-letsencrypt-email\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "" + + run /bin/bash -c "dokku openresty:report --global --format json | jq -r '.\"global-image\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "" + + run /bin/bash -c "dokku openresty:report --global --format json | jq -r '.\"computed-image\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_exists + + run /bin/bash -c "dokku openresty:report --global --format json | jq -r '.\"global-allowed-letsencrypt-domains-func-base64\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "" + + run /bin/bash -c "dokku openresty:report --global --format json | jq -r '.\"computed-allowed-letsencrypt-domains-func-base64\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_exists + + run /bin/bash -c "dokku openresty:report --global --format json | jq -r '.\"global-hsts\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "" + + run /bin/bash -c "dokku openresty:report --global --format json | jq -r '.\"computed-hsts\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "true" + + for key in image letsencrypt-email letsencrypt-server allowed-letsencrypt-domains-func-base64; do + run /bin/bash -c "dokku openresty:report --global --format json | jq -e \"has(\\\"$key\\\")\"" + echo "key: $key" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output "false" + done +} + +@test "(openresty:report) --global global vs computed image" { + run /bin/bash -c "dokku openresty:report --global --openresty-global-image" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "" + + run /bin/bash -c "dokku openresty:report --global --openresty-computed-image" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_exists + + run /bin/bash -c "dokku openresty:set --global image dokku/openresty-docker-proxy:0.5.6" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku openresty:report --global --openresty-global-image" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "dokku/openresty-docker-proxy:0.5.6" + + run /bin/bash -c "dokku openresty:report --global --openresty-computed-image" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "dokku/openresty-docker-proxy:0.5.6" + + run /bin/bash -c "dokku openresty:set --global image" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku openresty:report --global --openresty-global-image" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "" + + run /bin/bash -c "dokku openresty:report --global --openresty-computed-image" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_exists } @test "(openresty) global-only keys" {