Merge pull request #8501 from dokku/0.38-release

Release 0.38.0
This commit is contained in:
Jose Diaz-Gonzalez
2026-04-30 13:36:29 -04:00
committed by GitHub
321 changed files with 15494 additions and 2210 deletions

110
.github/workflows/dependency-updates.yml vendored Normal file
View File

@@ -0,0 +1,110 @@
---
name: 'dependency-updates'
# yamllint disable-line rule:truthy
on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
prepare:
name: prepare
runs-on: ubuntu-24.04
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
steps:
- name: Clone
uses: actions/checkout@v6
- name: Build dependency matrix
id: matrix
run: |
matrix=$(jq -c '
[
(.dependencies[] | {section: "dependencies", name, version, url: .urls.amd64}),
(.predependencies[] | {section: "predependencies", name, version, url: .urls.amd64}),
(.recommendations[] | {section: "recommendations", name, version, url: .urls.amd64})
]
| map(. + {repo: (.url | capture("https://github.com/(?<r>[^/]+/[^/]+)/releases").r)})
| map(del(.url))
| {include: .}
' contrib/dependencies.json)
echo "matrix=${matrix}" >> "$GITHUB_OUTPUT"
update:
name: 'update / ${{ matrix.name }}'
runs-on: ubuntu-24.04
needs: prepare
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
concurrency:
group: dependency-updates-${{ matrix.name }}
cancel-in-progress: false
steps:
- name: Clone
uses: actions/checkout@v6
- name: Resolve latest release
id: latest
env:
GH_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }}
REPO: ${{ matrix.repo }}
CURRENT: ${{ matrix.version }}
run: |
tag=$(gh release view --repo "$REPO" --json tagName -q .tagName)
latest="${tag#v}"
echo "latest=${latest}" >> "$GITHUB_OUTPUT"
if dpkg --compare-versions "$latest" gt "$CURRENT"; then
echo "outdated=true" >> "$GITHUB_OUTPUT"
else
echo "outdated=false" >> "$GITHUB_OUTPUT"
fi
- name: Bump dependency entry
if: steps.latest.outputs.outdated == 'true'
env:
SECTION: ${{ matrix.section }}
NAME: ${{ matrix.name }}
OLD: ${{ matrix.version }}
NEW: ${{ steps.latest.outputs.latest }}
run: |
tmp=$(mktemp)
jq --arg section "$SECTION" \
--arg name "$NAME" \
--arg old "$OLD" \
--arg new "$NEW" '
($old) as $oldv |
($new) as $newv |
(.[$section]) |= map(
if .name == $name then
.version = $newv
| .urls.amd64 |= (gsub("v" + $oldv; "v" + $newv) | gsub($oldv; $newv))
| .urls.arm64 |= (gsub("v" + $oldv; "v" + $newv) | gsub($oldv; $newv))
else . end
)
' contrib/dependencies.json > "$tmp"
mv "$tmp" contrib/dependencies.json
- name: Open pull request
if: steps.latest.outputs.outdated == 'true'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }}
branch: chore/bump-${{ matrix.name }}-${{ steps.latest.outputs.latest }}
base: ${{ github.ref_name }}
delete-branch: true
committer: 'Dokku Bot <no-reply@dokku.com>'
author: 'Dokku Bot <no-reply@dokku.com>'
commit-message: 'chore: bump ${{ matrix.name }} to ${{ steps.latest.outputs.latest }}'
title: 'chore: bump ${{ matrix.name }} to ${{ steps.latest.outputs.latest }}'
body: |
Bumps `${{ matrix.name }}` from `${{ matrix.version }}` to `${{ steps.latest.outputs.latest }}`.
Release notes: https://github.com/${{ matrix.repo }}/releases/tag/v${{ steps.latest.outputs.latest }}
labels: dependencies

View File

@@ -1,7 +1,7 @@
GO_ARGS ?=
GO_PLUGIN_MAKE_TARGET ?= build
GO_REPO_ROOT := /go/src/github.com/dokku/dokku
BUILD_IMAGE := golang:1.25.5
BUILD_IMAGE := golang:1.26.2
GO_BUILD_CACHE ?= /tmp/dokku-go-build-cache
GO_MOD_CACHE ?= /tmp/dokku-go-mod-mod
GO_ROOT_MOUNT ?= $$PWD/../..:$(GO_REPO_ROOT)

View File

@@ -2,94 +2,94 @@
"dependencies": [
{
"name": "docker-container-healthchecker",
"version": "0.14.0",
"version": "0.15.0",
"urls": {
"amd64": "https://github.com/dokku/docker-container-healthchecker/releases/download/v0.14.0/docker-container-healthchecker-linux-amd64",
"arm64": "https://github.com/dokku/docker-container-healthchecker/releases/download/v0.14.0/docker-container-healthchecker-linux-arm64"
"amd64": "https://github.com/dokku/docker-container-healthchecker/releases/download/v0.15.0/docker-container-healthchecker-linux-amd64",
"arm64": "https://github.com/dokku/docker-container-healthchecker/releases/download/v0.15.0/docker-container-healthchecker-linux-arm64"
}
},
{
"name": "docker-image-labeler",
"version": "0.8.1",
"version": "0.9.0",
"urls": {
"amd64": "https://github.com/dokku/docker-image-labeler/releases/download/v0.8.1/docker-image-labeler-linux-amd64",
"arm64": "https://github.com/dokku/docker-image-labeler/releases/download/v0.8.1/docker-image-labeler-linux-arm64"
"amd64": "https://github.com/dokku/docker-image-labeler/releases/download/v0.9.0/docker-image-labeler-linux-amd64",
"arm64": "https://github.com/dokku/docker-image-labeler/releases/download/v0.9.0/docker-image-labeler-linux-arm64"
}
},
{
"name": "lambda-builder",
"version": "0.9.1",
"version": "0.9.2",
"urls": {
"amd64": "https://github.com/dokku/lambda-builder/releases/download/v0.9.1/lambda-builder-linux-amd64",
"arm64": "https://github.com/dokku/lambda-builder/releases/download/v0.9.1/lambda-builder-linux-arm64"
"amd64": "https://github.com/dokku/lambda-builder/releases/download/v0.9.2/lambda-builder-linux-amd64",
"arm64": "https://github.com/dokku/lambda-builder/releases/download/v0.9.2/lambda-builder-linux-arm64"
}
},
{
"name": "netrc",
"version": "0.10.2",
"version": "0.11.0",
"urls": {
"amd64": "https://github.com/dokku/netrc/releases/download/v0.10.2/netrc-linux-amd64",
"arm64": "https://github.com/dokku/netrc/releases/download/v0.10.2/netrc-linux-arm64"
"amd64": "https://github.com/dokku/netrc/releases/download/v0.11.0/netrc-linux-amd64",
"arm64": "https://github.com/dokku/netrc/releases/download/v0.11.0/netrc-linux-arm64"
}
},
{
"name": "procfile-util",
"version": "0.20.3",
"version": "0.20.4",
"urls": {
"amd64": "https://github.com/dokku/procfile-util/releases/download/v0.20.3/procfile-util-linux-amd64",
"arm64": "https://github.com/dokku/procfile-util/releases/download/v0.20.3/procfile-util-linux-arm64"
"amd64": "https://github.com/dokku/procfile-util/releases/download/v0.20.4/procfile-util-linux-amd64",
"arm64": "https://github.com/dokku/procfile-util/releases/download/v0.20.4/procfile-util-linux-arm64"
}
},
{
"name": "sshcommand",
"version": "0.20.0",
"version": "0.20.1",
"urls": {
"amd64": "https://github.com/dokku/sshcommand/releases/download/v0.20.0/sshcommand",
"arm64": "https://github.com/dokku/sshcommand/releases/download/v0.20.0/sshcommand"
"amd64": "https://github.com/dokku/sshcommand/releases/download/v0.20.1/sshcommand",
"arm64": "https://github.com/dokku/sshcommand/releases/download/v0.20.1/sshcommand"
}
}
],
"predependencies": [
{
"name": "gliderlabs-sigil",
"version": "0.11.4",
"version": "0.12.0",
"urls": {
"amd64": "https://github.com/gliderlabs/sigil/releases/download/v0.11.4/sigil-linux-amd64",
"arm64": "https://github.com/gliderlabs/sigil/releases/download/v0.11.4/sigil-linux-arm64"
"amd64": "https://github.com/gliderlabs/sigil/releases/download/v0.12.0/sigil-linux-amd64",
"arm64": "https://github.com/gliderlabs/sigil/releases/download/v0.12.0/sigil-linux-arm64"
}
},
{
"name": "plugn",
"version": "0.16.0",
"version": "0.17.0",
"urls": {
"amd64": "https://github.com/dokku/plugn/releases/download/v0.16.0/plugn-linux-amd64",
"arm64": "https://github.com/dokku/plugn/releases/download/v0.16.0/plugn-linux-arm64"
"amd64": "https://github.com/dokku/plugn/releases/download/v0.17.0/plugn-linux-amd64",
"arm64": "https://github.com/dokku/plugn/releases/download/v0.17.0/plugn-linux-arm64"
}
}
],
"recommendations": [
{
"name": "dokku-event-listener",
"version": "0.17.2",
"version": "0.19.0",
"urls": {
"amd64": "https://github.com/dokku/dokku-event-listener/releases/download/v0.17.2/dokku-event-listener-linux-amd64",
"arm64": "https://github.com/dokku/dokku-event-listener/releases/download/v0.17.2/dokku-event-listener-linux-arm64"
"amd64": "https://github.com/dokku/dokku-event-listener/releases/download/v0.19.0/dokku-event-listener-linux-amd64",
"arm64": "https://github.com/dokku/dokku-event-listener/releases/download/v0.19.0/dokku-event-listener-linux-arm64"
}
},
{
"name": "dokku-update",
"version": "0.9.6",
"version": "0.9.8",
"urls": {
"amd64": "https://github.com/dokku/dokku-update/releases/download/v0.9.6/dokku-update",
"arm64": "https://github.com/dokku/dokku-update/releases/download/v0.9.6/dokku-update"
"amd64": "https://github.com/dokku/dokku-update/releases/download/v0.9.8/dokku-update",
"arm64": "https://github.com/dokku/dokku-update/releases/download/v0.9.8/dokku-update"
}
},
{
"name": "herokuish",
"version": "0.11.8",
"version": "0.11.11",
"urls": {
"amd64": "https://github.com/gliderlabs/herokuish/releases/download/v0.11.8/herokuish_0.11.8_linux_x86_64.tgz",
"arm64": "https://github.com/gliderlabs/herokuish/releases/download/v0.11.8/herokuish_0.11.8_linux_x86_64.tgz"
"amd64": "https://github.com/gliderlabs/herokuish/releases/download/v0.11.11/herokuish_0.11.11_linux_x86_64.tgz",
"arm64": "https://github.com/gliderlabs/herokuish/releases/download/v0.11.11/herokuish_0.11.11_linux_x86_64.tgz"
}
}
]

1
debian/config vendored
View File

@@ -14,5 +14,6 @@ db_input "high" "dokku/hostname" || true
db_input "high" "dokku/vhost_enable" || true
if [ "$ACTION" != "reconfigure" ]; then
db_input "high" "dokku/key_file" || true
db_input "high" "dokku/install_default_site" || true
fi
db_go || true

62
debian/postinst vendored
View File

@@ -135,6 +135,65 @@ setup-sshcommand() {
fi
}
disable-default-vhost-path() {
local path="$1"
local backup_path="${path}.dokku-disabled"
if [ ! -e "$path" ] && [ ! -L "$path" ]; then
return
fi
if [ -e "$backup_path" ] || [ -L "$backup_path" ]; then
return
fi
case "$path" in
/etc/nginx/sites-enabled/*)
rm -f "$path"
echo "Removed conflicting nginx default at $path" 1>&2
return
;;
esac
if [ -L "$path" ]; then
rm -f "$path"
echo "Removed conflicting nginx default symlink at $path" 1>&2
return
fi
mv -n "$path" "$backup_path"
echo "Disabled $path (renamed to $backup_path)" 1>&2
}
setup-default-site() {
db_get "dokku/install_default_site"
if [ "$RET" != "true" ]; then
return
fi
local default_vhost_target="/etc/nginx/conf.d/00-default-vhost.conf"
local default_vhost_source="${DOKKU_LIB_ROOT}/core-plugins/available/nginx-vhosts/templates/default-site.conf"
if [ -e "$default_vhost_target" ]; then
return
fi
if [ ! -f "$default_vhost_source" ]; then
return
fi
for path in /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default /etc/nginx/conf.d/default.conf; do
disable-default-vhost-path "$path"
done
echo "Installing nginx default catch-all site at $default_vhost_target"
install -m 0644 -o root -g root "$default_vhost_source" "$default_vhost_target"
if command -v dokku &>/dev/null; then
dokku nginx:reload || true
fi
}
dpkg-handling() {
if [ -f "${DOKKU_ROOT}/VHOST" ]; then
echo "VHOST file detected, skipping modification"
@@ -170,6 +229,9 @@ case "$1" in
setup-plugins
setup-sshcommand
setup-docker-live-restore
if [ -z "$2" ]; then
setup-default-site
fi
;;
*)

5
debian/templates vendored
View File

@@ -22,3 +22,8 @@ Template: dokku/nginx_enable
Description: Enable nginx-vhosts plugin?
Type: boolean
Default: true
Template: dokku/install_default_site
Description: Install a catch-all nginx default site on fresh install?
Type: boolean
Default: true

View File

@@ -96,6 +96,7 @@ This documentation covers the installation, configuration, and usage of Dokku -
- [Plugin Management](advanced-usage/plugin-management.md) - Install and manage plugins
- [Registry Management](advanced-usage/registry-management.md) - Docker registry integration
- [Repository Management](advanced-usage/repository-management.md) - Git repository settings
- [Build Tracking](advanced-usage/builds.md) - Inspect, cancel, and manage builds
- [Deployment Tasks](advanced-usage/deployment-tasks.md) - Pre/post deploy hooks
- [Event Logs](advanced-usage/event-logs.md) - Dokku event history
- [Backup and Recovery](advanced-usage/backup-recovery.md) - Data backup strategies
@@ -121,6 +122,7 @@ This documentation covers the installation, configuration, and usage of Dokku -
### Migration Guides
- [0.38.0](appendices/0.38.0-migration-guide.md)
- [0.37.0](appendices/0.37.0-migration-guide.md)
- [0.36.0](appendices/0.36.0-migration-guide.md)
- [0.35.0](appendices/0.35.0-migration-guide.md)

View File

@@ -10,7 +10,7 @@ mkdocs==1.6.1
mkdocs-exclude==1.0.2
mkdocs-material==9.7.6
mkdocs-material-extensions==1.3.1
packaging==26.1
packaging==26.2
Pygments==2.20.0
pymdown-extensions==10.21.2
pyparsing==3.3.2

View File

@@ -0,0 +1,171 @@
# Build Tracking
> [!IMPORTANT]
> New as of 0.34.0
Every deploy that flows through Dokku - whether triggered by `git push`, `ps:rebuild`, `ps:restart`, `config:set`, or `git:from-archive` / `git:from-image` / `git:sync` / `git:load-image` - is recorded as a structured build record on disk. The `builds` plugin lets operators inspect what is currently deploying, look up the result of an old deploy, and stream the captured build log without depending on `journalctl`.
```
builds:cancel <app> # Cancel a running build for an app
builds:info <app> <build-id> [--format json] # Show details for a single build
builds:list [<app>] [--format json] [--kind ...] [--status ...]
# List builds (running across all apps, or running + history for one)
builds:output <app> [<build-id>|current] # Show build output (tail for live, cat for finished)
builds:prune <app> [--all-apps] # Reap abandoned records and apply retention
builds:report [<app>] [<flag>] # Display a build report
builds:set [--global|<app>] <key> [<value>] # Set or clear a builds property
```
## Build records
Every build is identified by a sortable base36 ULID-style id (`DOKKU_BUILD_ID`) generated at deploy start. For each build, Dokku writes:
- `$DOKKU_LIB_ROOT/data/builds/<app>/<build-id>.json` - the structured record
- `$DOKKU_LIB_ROOT/data/builds/<app>/<build-id>.log` - the captured stdout/stderr of the deploy
Output is also tagged into syslog as `dokku-<build-id>` so `journalctl -t dokku-<build-id>` continues to work. The on-disk log file is the durable source of truth and is read for `builds:output` even when journald has rotated old entries away.
### Record schema
```json
{
"id": "01j8c4xv7bk5w3",
"app": "myapp",
"kind": "build",
"pid": 12345,
"started_at": "2026-04-30T13:50:00Z",
"finished_at": "2026-04-30T13:51:14Z",
"status": "succeeded",
"source": "git-hook",
"exit_code": 0
}
```
- **kind** - `build` for paths that produce a new image (`git push`, `git:*`, `ps:rebuild`); `deploy` for paths that re-deploy an existing image (`ps:restart`, `ps:start`, `dokku deploy`, `config:set`).
- **status** - on-disk values are `running | succeeded | failed | canceled`. `abandoned` is a fifth display-only value computed at read time for `running` records whose PID is no longer alive; it is never persisted to the record.
- **source** - the user-typed command that originated the deploy (e.g. `git-hook`, `ps:restart`, `config-redeploy`, `git:sync`).
## Listing builds
Without an app argument, `builds:list` shows currently-running builds across every app on the host:
```shell
dokku builds:list
```
```
=====> Currently running builds
App Build ID Kind PID Source Started
myapp 01j8c4xv7bk5w3 build 12345 git-hook 2026-04-30T13:50:00Z
```
With an app, `builds:list` returns running builds plus the most recent finalized records up to the configured retention:
```shell
dokku builds:list myapp
```
Filter by kind or status:
```shell
dokku builds:list myapp --kind build
dokku builds:list myapp --status running
dokku builds:list --format json
```
## Inspecting a single build
```shell
dokku builds:info myapp 01j8c4xv7bk5w3
```
```
=====> Build 01j8c4xv7bk5w3
Build ID: 01j8c4xv7bk5w3
App: myapp
Kind: build
Status: succeeded
PID: 12345
Source: git-hook
Started: 2026-04-30T13:50:00Z
Finished: 2026-04-30T13:51:14Z
Duration: 1m14s
Exit Code: 0
Log: /var/lib/dokku/data/builds/myapp/01j8c4xv7bk5w3.log
```
JSON output includes the same fields plus a computed `log_path`:
```shell
dokku builds:info myapp 01j8c4xv7bk5w3 --format json | jq .
```
## Streaming build output
```shell
# tail -f the live build, or cat the log for a finished one
dokku builds:output myapp 01j8c4xv7bk5w3
# resolve "current" from the in-flight deploy
dokku builds:output myapp current
```
If the on-disk log file is missing (for example, a build from before this plugin was installed), `builds:output` falls back to `journalctl -t dokku-<build-id>`.
## Cancelling a build
`builds:cancel` reads the active `.deploy.lock` for an app, looks up the matching build record, and sends `SIGQUIT` to the deploy's process group. The record is finalized as `canceled` (or `failed` if the process had already exited without finalizing - in that case Dokku marks the record so it doesn't sit in `running` forever).
```shell
dokku builds:cancel myapp
```
If the record is already finalized when cancel runs, no signal is sent and the record is left untouched.
## Retention
Build records are pruned by count, not by age. The default retention is **20** records per app. Live in-flight deploys are never pruned regardless of count.
Set a per-app retention:
```shell
dokku builds:set myapp retention 50
```
Set the global default:
```shell
dokku builds:set --global retention 10
```
Clear an override (falls back to the global value, then the default):
```shell
dokku builds:set myapp retention
```
## Manual pruning
`builds:prune` invokes the same logic that runs at the end of every deploy - it reaps abandoned records (status=running with a dead PID, finalized as `failed`) and trims the directory to the configured retention. Useful after lowering retention via `builds:set` or to clean up after a host reboot:
```shell
dokku builds:prune myapp
dokku builds:prune --all-apps
```
## Reports
```shell
dokku builds:report
dokku builds:report myapp
dokku builds:report myapp --build-status
```
Available flags:
- `--build-id`, `--build-kind`, `--build-status`, `--build-pid`, `--build-source`, `--build-started-at`, `--build-finished-at`, `--build-exit-code`: details of the most recent build for the app
- `--builds-retention`: per-app retention override (empty if none)
- `--builds-global-retention`: global retention override (empty if none)
- `--builds-computed-retention`: the resolved retention applied to this app
`--build-status` returns the **display** status, so an abandoned in-flight build shows `abandoned` rather than `running`. The raw on-disk status is only visible by reading the JSON record directly.

View File

@@ -4,10 +4,11 @@
> New as of 0.3.17
```
docker-options:add <app> <phase(s)> OPTION # Add Docker option to app for phase (comma-separated phase list)
docker-options:clear <app> [<phase(s)>...] # Clear a docker options from app
docker-options:remove <app> <phase(s)> OPTION # Remove Docker option from app for phase (comma-separated phase list)
docker-options:report [<app>] [<flag>] # Displays a docker options report for one or more apps
docker-options:add [--process PROC...] <app> <phase(s)> OPTION # Add Docker option to app for phase
docker-options:clear [--process PROC...] <app> [<phase(s)>...] # Clear docker options from app
docker-options:list <app> [--process PROC] --phase PHASE # List docker options for one process+phase pair
docker-options:remove [--process PROC...] <app> <phase(s)> OPTION # Remove Docker option from app for phase
docker-options:report [<app>] [<flag>] [--format json|stdout] # Displays a docker options report for one or more apps
```
The `docker-options` plugin allows users to specify custom [container options](https://docs.docker.com/engine/reference/run/) for containers created by Dokku at various phases.
@@ -57,11 +58,17 @@ Multiple phases can be specified by using a comma when specifying phases:
dokku docker-options:add node-js-app deploy,run "--ulimit nofile=12"
```
The `docker-options:add` does not support setting multiple options in a single call. To specify multiple options, call `docker-options:add` multiple times.
Multiple docker options can also be specified in a single call. Each `--flag [value]` group is detected on flag boundaries, shell-tokenized for quoting safety, and stored as its own entry so it round-trips through `docker-options:report` and `docker-options:list`:
```shell
dokku docker-options:add node-js-app deploy "--ulimit nofile=12"
dokku docker-options:add node-js-app deploy "--shm-size 256m"
dokku docker-options:add node-js-app deploy "--ulimit nofile=12" "--shm-size 256m"
```
A misplaced `--process PROC` (i.e. one specified after the app name instead of before it) is honored as a subcommand flag rather than stored as a docker option, so the example above and the equivalent process-scoped form below behave identically:
```shell
dokku docker-options:add --process web node-js-app deploy "--ulimit nofile=12" "--shm-size 256m"
dokku docker-options:add node-js-app deploy "--ulimit nofile=12" "--shm-size 256m" --process web
```
#### Remove a Docker option
@@ -78,11 +85,10 @@ Multiple phases can be specified by using a comma when specifying phases:
dokku docker-options:remove node-js-app deploy,run "--ulimit nofile=12"
```
The `docker-options:remove` does not support setting multiple options in a single call. To specify multiple options, call `docker-options:remove` multiple times.
Multiple docker options can also be removed in a single call, mirroring the splitting that `docker-options:add` performs:
```shell
dokku docker-options:remove node-js-app deploy "--ulimit nofile=12"
dokku docker-options:remove node-js-app deploy "--shm-size 256m"
dokku docker-options:remove node-js-app deploy "--ulimit nofile=12" "--shm-size 256m"
```
#### Clear all Docker options for an app
@@ -160,3 +166,67 @@ You can pass flags which will output only the value of the specific information
```shell
dokku docker-options:report node-js-app --docker-options-build
```
When process-specific options are configured (see below), the report exposes one additional dynamic flag per configured `process.deploy` pair, named `--docker-options-deploy.<process>`:
```shell
dokku docker-options:report node-js-app --docker-options-deploy.web
```
A machine-readable JSON view is available via `--format json`:
```shell
dokku docker-options:report node-js-app --format json
```
### Process-Specific Options
> [!IMPORTANT]
> New as of 0.38.0
Docker options can be scoped to specific process types declared in the app's `Procfile` by passing one or more `--process` flags. This is useful when a deploy-phase option (for example a port mapping) makes sense for one process type but would conflict with another - the canonical case being a `web` process that needs `-p 6789:5000` published while the `worker` process must not bind that port.
Process scoping is supported only for the `deploy` phase. The `build` phase runs once per app and the `run` phase covers ad-hoc commands and cron tasks where no Procfile process type is in play; both reject `--process`.
There is no `--global` flag. Omitting `--process` keeps the historical behavior of applying the option to every container in the app. Avoiding a `--global` flag here is intentional: elsewhere in Dokku `--global` means "across all apps" (e.g. `dokku config:set --global`), which would be misleading in this plugin where the scope is always one app.
#### Setting process-specific options
```shell
# Add a port mapping only to the web process
dokku docker-options:add --process web node-js-app deploy "-p 6789:5000"
# Add a GPU mount only to the worker process
dokku docker-options:add --process worker node-js-app deploy "--gpus all"
```
Multiple `--process` flags can be combined to apply the same option to several process types in one call:
```shell
dokku docker-options:add --process web --process api node-js-app deploy "-v /shared:/shared"
```
If `--process` names a process type that is not currently declared in the app's `Procfile`, the command succeeds but emits a warning. This allows configuring options ahead of a deploy that adds the new process type.
The `_default_` value is reserved internally and cannot be passed to `--process`.
#### Removing and clearing process-specific options
```shell
# Remove a single option from one process
dokku docker-options:remove --process web node-js-app deploy "-p 6789:5000"
# Clear every option for a process+phase
dokku docker-options:clear --process worker node-js-app deploy
```
Without `--process`, `:remove` and `:clear` operate on the default scope only - per-process lists are left untouched.
#### Listing options for a process and phase
The `docker-options:list` command prints the options stored for a single process+phase pair, one option per line. Omitting `--process` lists the default scope.
```shell
dokku docker-options:list node-js-app --process web --phase deploy
dokku docker-options:list node-js-app --phase deploy
```

View File

@@ -1,24 +1,37 @@
# Persistent Storage
> [!IMPORTANT]
> New as of 0.5.0
> New as of 0.5.0. Named storage entries new as of 0.38.0.
The preferred method to mount external containers to a Dokku managed container, is to use the Dokku storage plugin.
The preferred method to attach persistent storage to a Dokku-managed container is the Dokku storage plugin.
```
storage:ensure-directory [--chown option] <directory> # Creates a persistent storage directory in the recommended storage path
storage:list <app> [--format text|json] # List bind mounts for app's container(s) (host:container)
storage:mount <app> <host-dir:container-dir> # Create a new bind mount
storage:report [<app>] [<flag>] # Displays a checks report for one or more apps
storage:unmount <app> <host-dir:container-dir> # Remove an existing bind mount
storage:create <name> [<path>] [flags] # Register a named storage entry
storage:destroy <name> # Remove a named storage entry (must be unmounted from every app first)
storage:ensure-directory [--chown option] <directory> # [DEPRECATED] use storage:create instead
storage:exec <name> [-- <cmd>...] # Run a command (or shell) in a temporary container that mounts the entry
storage:info <name> [--format text|json] # Show details for one storage entry
storage:list <app> [--format text|json] # List bind mounts for an app's container(s) (legacy host:container view)
storage:list-entries [--scheduler s] [--format text|json] # List registered storage entries
storage:mount <app> <name> --container-dir <path> [flags] # Mount a named entry into an app
storage:mount <app> <host-dir:container-dir> # [LEGACY] colon-form mount, docker-local only
storage:report [<app>] [<flag>] # Display a storage report for one or more apps
storage:report --global # Display a cluster-wide entry inventory
storage:set <name> [flags] # Update a storage entry in place
storage:unmount <app> <name> [--container-dir <path>] # Remove an attachment
storage:wait <name> # Block until a k3s entry's PVC is bound
```
> The storage plugin is compatible with storage mounts created with the docker-options. The storage plugin will only list mounts from the deploy/run phase.
A storage entry is the source of truth for the underlying volume - a host directory on docker-local, or a PersistentVolumeClaim on k3s. Multiple apps can mount the same entry, and an attachment carries the per-app details (container path, phases, subpath, readonly, process type). Names are globally unique across the install and must be DNS-1123 labels (lowercase letters, digits, dashes) of 45 characters or less so they can be used verbatim as Helm release and PVC names.
The legacy `storage:mount <app> <host>:<container>` form continues to work on docker-local. On a k3s app it is rejected; create a named entry with `storage:create --scheduler k3s` and mount it instead. Existing colon-form mounts are migrated automatically the first time the new code runs - they show up as `legacy-<hash>` entries in `storage:list-entries`.
The storage plugin supports the following mount points:
- explicit paths that exist on the host
- docker volumes
- explicit paths that exist on the host (docker-local)
- docker volumes (docker-local)
- PersistentVolumeClaims provisioned via a StorageClass (k3s)
- hostPath-backed PVs (k3s)
## Usage

View File

@@ -32,7 +32,7 @@ Valid resource options include:
See the [Supported Resource Management Properties](/docs/deployment/schedulers/docker-local.md#supported-resource-management-properties) section of the docker local scheduler documentation for more information on how each resource limit maps to Docker.
Resource limits and reservations are applied only during the `run` and `deploy` phases of an application, and will not impact the `build` phase of an application.
Resource reservations are applied only during the `run` and `deploy` phases of an application. Resource limits also apply during the `build` phase when explicitly configured against the `build` process type - see [Build-time Resource Limits](#build-time-resource-limits) below.
### Resource Limits
@@ -120,6 +120,40 @@ dokku resource:limit --process-type web node-js-app
nvidia-gpu:
```
#### Build-time Resource Limits
> [!IMPORTANT]
> New as of 0.38.0
Resource limits may be applied to the build container by using the special `build` process type. This allows constraining memory and CPU usage during the `build` phase, which is otherwise unconstrained.
```shell
dokku resource:limit --memory 4g --process-type build node-js-app
```
```
=====> Setting resource limits for node-js-app (build)
memory: 4g
```
Build-time limits are applied via the `docker-args-process-build` plugin trigger. They do not inherit from default (`_default_`) limits - only limits explicitly set against the `build` process type are applied at build time. This is intentional: build phases often require more memory than the runtime process, and silently inheriting a small runtime limit would cause confusing OOM failures.
> [!WARNING]
> Do not use `build` as a runtime process type in your `Procfile`. The `build` name is reserved by the resource plugin to scope limits to the build container, and using it as a Procfile entry will cause those limits to be applied to that runtime container as well.
Supported limits per builder:
| Builder | `--cpu` | `--memory` | `--memory-swap` | `--nvidia-gpu` |
|------------|---------|------------|-----------------|----------------|
| herokuish | yes | yes | yes | yes |
| dockerfile | no | yes | yes | no |
| pack | no | no | no | no |
| nixpacks | no | no | no | no |
| railpack | no | no | no | no |
| lambda | no | no | no | no |
Resource keys outside the supported set for a builder are silently ignored. Reservations (`resource:reserve`) are never applied at build time - only limits.
#### Clearing Resource Limits
In cases where the values are incorrect - or there is no desire to limit resources - resource limits may be cleared using the `resource:limit-clear` command.

View File

@@ -0,0 +1,29 @@
# 0.38.0 Migration Guide
## Changes
- Dokku now generates a minimal nginx configuration for apps without running `web` processes (undeployed apps, apps with no `web` process type, or apps with stopped web processes). This configuration returns `502 Bad Gateway` responses, ensuring the app's domain resolves and monitoring tools can detect non-200 status codes. The configuration is automatically replaced with the full proxy configuration once the app is deployed with running `web` processes. See the [nginx documentation](/docs/networking/proxies/nginx.md#nginx-configuration-for-undeployed-apps) for more details.
- Users with custom `nginx.conf.sigil` templates that reference `DOKKU_APP_WEB_LISTENERS` should be aware that this variable may now be empty when the template is rendered for apps without running web processes. Custom templates should handle this case gracefully, for example by using a conditional to serve an error page instead of proxying:
```
location / {
{{ if $.DOKKU_APP_WEB_LISTENERS }}
proxy_pass http://{{ $.APP }}-{{ $upstream_port }};
{{ else }}
return 502;
{{ end }}
}
```
- The path on disk to both the global `ENV` file and app `ENV` files have been moved. Users should reference environment variables via the provided plugin triggers rather than directly sourcing the ENV files. Existing ENV files are left untouched and will be removed on the subsequent Dokku install.
- During a fresh apt install, the upstream nginx default vhost files (`/etc/nginx/sites-enabled/default`, `/etc/nginx/sites-available/default`, and `/etc/nginx/conf.d/default.conf`) are renamed to `${path}.dokku-disabled` (not deleted) to avoid a `duplicate default server for 0.0.0.0:80` error. Operators with local customizations can recover them by inspecting the `.dokku-disabled` siblings. Upgrade-in-place installs do not touch any existing nginx files.
- Fresh apt installs now ship a catch-all default site at `/etc/nginx/conf.d/00-default-vhost.conf` that rejects requests with unknown Host headers using `ssl_reject_handshake on` (HTTPS) and `return 444` (HTTP). This replaces the manual workaround previously documented in the nginx docs. The behavior can be opted out at install time via the `dokku/install_default_site` debconf prompt. See the [Default site documentation](/docs/networking/proxies/nginx.md#default-site).
- 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.
- 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 <app> <host>:<container>` 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-<hash>` entries in `storage:list-entries`. The migration is idempotent and tied to a per-app flag file at `$DOKKU_LIB_ROOT/config/storage/.migrated/<app>`; 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 <name> [<path>]` (the path defaults to the same `$DOKKU_LIB_ROOT/data/storage/<name>` 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.
### TLS handshake behavior change
With the new catch-all installed, an HTTPS request to a hostname that matches a configured dokku app but where the app has no TLS certificate configured will have its TLS handshake rejected by the catch-all (via `ssl_reject_handshake on`). Previously, nginx fell through to the lexicographically first port-443 server block and presented that block's certificate, producing a cert-mismatch error on the client. The new behavior is a correctness improvement, but operators who deliberately relied on the old fall-through certificate (for monitoring probes, for example) need to either configure a certificate for the target app or remove the catch-all on that host. Existing apps that already have certificates configured are unaffected: nginx selects the right server block via SNI before TLS completion, so the catch-all is never consulted for legitimate requests.

View File

@@ -5,6 +5,25 @@
> [!IMPORTANT]
> While the `app.json` format used by Dokku is based on the one [supported by Heroku](https://devcenter.heroku.com/articles/app-json-schema), not all Heroku functionality is supported by Dokku.
## Buildpacks
```json
{
"buildpacks": [
{
"url": "heroku/python"
},
{
"url": "https://github.com/heroku/heroku-buildpack-nodejs.git"
}
]
}
```
(list, optional) A list of buildpacks to use when deploying the app. Each entry is an object with a `url` property. Buildpacks specified via the `buildpacks:add` or `buildpacks:set` commands take precedence over those specified in `app.json`. Buildpack URLs may use the shorthand format (e.g., `heroku/python`) which will be expanded to the full GitHub URL.
- `url`: (string, required) The URL or shorthand reference of the buildpack.
## Cron
```json
@@ -25,6 +44,103 @@
- `schedule`: (string, required)
- `concurrency_policy`: (string, optional, default: `allow`, options: `allow`, `forbid`, `replace`)
## Env
```json
{
"env": {
"SIMPLE_VAR": "default_value",
"SECRET_KEY": {
"description": "A secret key for signing tokens",
"generator": "secret"
},
"DATABASE_URL": {
"description": "PostgreSQL connection string",
"required": true
},
"OPTIONAL_VAR": {
"description": "An optional configuration value",
"value": "default",
"required": false
},
"SYNC_VAR": {
"description": "A variable that updates on every deploy",
"value": "synced_value",
"sync": true
}
}
}
```
(object, optional) A key-value object for environment variable configuration. Keys are the variable names. Values can be either a string (used as the default value) or an object with the following properties:
- `description`: (string, optional) Human-readable explanation of the variable's purpose
- `value`: (string, optional) Default value for the variable
- `required`: (boolean, optional, default: `true`) Whether the variable must have a value
- `generator`: (string, optional) Function to generate the value. Currently only `"secret"` is supported, which generates a 64-character cryptographically secure hex string
- `sync`: (boolean, optional, default: `false`) If `true`, the value will be set on every deploy, overwriting any existing value
### Behavior
Environment variables from `app.json` are processed during the first deploy, before the predeploy script runs. The behavior depends on the variable configuration:
1. **Variables with `value` or simple string**: The default value is set if the variable doesn't already exist
2. **Variables with `generator: "secret"`**: A random 64-character hex string is generated if the variable doesn't exist
3. **Required variables without a value or generator**: If a TTY is available, the user is prompted for a value. Otherwise, the deploy fails with an error
4. **Optional variables without a value**: Skipped silently if no TTY is available
On subsequent deploys:
- Variables are NOT re-set unless `sync: true` is specified
- Variables with `sync: true` are always set to their configured value, overwriting any manual changes
- Variables that already have values are not modified
### Examples
**Simple default value:**
```json
{
"env": {
"WEB_CONCURRENCY": "5"
}
}
```
**Generated secret (recommended for API keys, tokens, etc.):**
```json
{
"env": {
"SECRET_KEY_BASE": {
"description": "Base secret for session encryption",
"generator": "secret"
}
}
}
```
**Required variable that must be provided:**
```json
{
"env": {
"DATABASE_URL": {
"description": "PostgreSQL connection URL",
"required": true
}
}
}
```
**Variable that stays in sync with app.json:**
```json
{
"env": {
"FEATURE_FLAGS": {
"value": "new_ui,dark_mode",
"sync": true
}
}
}
```
## Formation
```json

View File

@@ -1,3 +1,9 @@
# nginx.conf.sigil
The `nginx.conf.sigil` file is used to configure the nginx server for an application. The default template can be found [here](https://github.com/dokku/dokku/blob/master/plugins/nginx-vhosts/templates/nginx.conf.sigil). Dokku uses a tool named [sigil](https://github.com/gliderlabs/sigil) to generate the nginx configuration based on the template provided.
## Validation
A custom `nginx.conf.sigil` is pre-validated at the start of every deploy, immediately after it is extracted from the source tree and before the build phase runs. Pre-validation renders the template via sigil with the same parameters used at deploy time, wraps the rendered config in a minimal `events`/`http` scaffold, and runs `nginx -t` against the result. The deploy is aborted if either the sigil render or the `nginx -t` check fails, so build work is not wasted on a syntactically invalid template. When no app listeners exist yet (typical for first deploys), pre-validation injects a placeholder `127.0.0.1:5000` listener for `DOKKU_APP_WEB_LISTENERS` so that the rendered upstream block has a static server entry and `nginx -t` does not bail out on "host not found in upstream".
Pre-validation is skipped when the proxy type is not `nginx` or when `disable-custom-config` is set to `true` for the app.

View File

@@ -22,12 +22,12 @@ Environment variables are available both at run time and during the application
For buildpack deploys, Dokku will create a `/app/.env` file that can be used for legacy buildpacks. Note that this is _not_ updated when `config:set` or `config:unset` is called, and is only written during a `deploy` or `ps:rebuild`. Developers are encouraged to instead read from the application environment directly, as the proper values will be available then.
> [!NOTE]
> Global `ENV` files are sourced before app-specific `ENV` files. This means that app-specific variables will take precedence over global variables. Configuring your global `ENV` file is manual, and should be considered potentially dangerous as configuration applies to all applications.
> Global environment variables are sourced before app-specific environment variables. This means that app-specific variables will take precedence over global variables. Configuring global environment variables should be considered potentially dangerous as configuration applies to all applications.
You can set multiple environment variables at once:
```shell
dokku config:set node-js-app ENV=prod COMPILE_ASSETS=1
dokku config:set node-js-app APP_ENV=prod COMPILE_ASSETS=1
```
Whitespace and special characters get tricky. If you are using dokku locally you don't need to do any special escaping. If you are using dokku over ssh you will need to backslash-escape spaces:
@@ -45,7 +45,7 @@ dokku config:set --encoded node-js-app KEY="$(base64 -w 0 ~/.ssh/id_rsa)"
When setting or unsetting environment variables, you may wish to avoid an application restart. This is useful when developing plugins or when setting multiple environment variables in a scripted manner. To do so, use the `--no-restart` flag:
```shell
dokku config:set --no-restart node-js-app ENV=prod
dokku config:set --no-restart node-js-app APP_ENV=prod
```
If you wish to have the variables output in an `eval`-compatible form, you can use the `config:export` command
@@ -54,7 +54,7 @@ If you wish to have the variables output in an `eval`-compatible form, you can u
dokku config:export node-js-app
# outputs variables in the form:
#
# export ENV='prod'
# export APP_ENV='prod'
# export COMPILE_ASSETS='1'
# source in all the node-js-app app environment variables
@@ -68,9 +68,38 @@ dokku config:export --format shell node-js-app
# outputs variables in the form:
#
# ENV='prod' COMPILE_ASSETS='1'
# APP_ENV='prod' COMPILE_ASSETS='1'
```
## Setting Environment Variables via app.json
Environment variables can also be declared in an `app.json` file in your repository root. This is useful for setting default values, generating secrets, or requiring certain variables to be set before deployment.
```json
{
"env": {
"SIMPLE_VAR": "default_value",
"SECRET_KEY": {
"description": "A secret key for signing tokens",
"generator": "secret"
},
"DATABASE_URL": {
"description": "PostgreSQL connection string",
"required": true
}
}
}
```
Environment variables from `app.json` are processed during the first deploy, before the predeploy script runs. Variables can be configured as:
- **Simple string values**: Set as defaults if not already configured
- **Generated secrets**: Use `"generator": "secret"` to auto-generate a 64-character hex string
- **Required variables**: Use `"required": true` to prompt for or require a value
- **Synced variables**: Use `"sync": true` to update the value on every deploy
For full details on the `env` schema and behavior, see the [app.json documentation](/docs/appendices/file-formats/app-json.md#env).
## Special Config Variables
The following config variables have special meanings and can be set in a variety of ways. Unless specified via global app config, the values may not be passed into applications. Usage of these values within applications should be considered unsafe, as they are an internal configuration values that may be moved to the internal properties system in the future.
@@ -98,26 +127,34 @@ The following config variables have special meanings and can be set in a variety
| `DOKKU_QUIET_OUTPUT` | none | `--quiet` flag | Silences certain header output for `dokku` commands. |
| `DOKKU_RM_CONTAINER` | none | `dokku config:set` <br /> | Deprecated: Whether to keep `dokku run` containers around or not. |
| `DOKKU_TRACE` | none | `dokku trace:on` <br /> `dokku trace:off` <br /> `--trace` flag | Turn on very verbose debugging. |
| `DOKKU_APP_PROXY_TYPE` | `nginx` | `dokku proxy:set` | |
| `DOKKU_APP_RESTORE` | `1` | `dokku config:set` <br /> `dokku ps:stop` | |
| `DOKKU_APP_SHELL` | `/bin/bash` | `dokku config:set` | Allows users to change the default shell used by Dokku for `dokku enter` and execution of deployment tasks. |
| `DOKKU_CHECKS_DISABLED` | none | `dokku checks:disable` | |
| `DOKKU_CHECKS_ENABLED` | none | `dokku checks:enable` | |
| `DOKKU_CHECKS_SKIPPED` | none | `dokku checks:skip` | |
| `DOKKU_CHECKS_WAIT` | `5` | `dokku config:set` | Wait this many seconds for the container to start before running checks.
| `DOKKU_CHECKS_TIMEOUT` | `30` | `dokku config:set` | Wait this many seconds for each response before marking it as a failure.
| `DOKKU_CHECKS_ATTEMPTS` | `5` | `dokku config:set` | Number of retries for to run for a specific check before marking it as a failure
| `DOKKU_DEFAULT_CHECKS_WAIT` | `10` | `dokku config:set` | If no user-defined checks are specified - or if the process being checked is not a `web` process - this is the period of time Dokku will wait before checking that a container is still running. |
| `DOKKU_DISABLE_PROXY` | none | `dokku proxy:disable` <br /> `dokku proxy:enable` | Disables the proxy in front of your application, resulting in publicly routing the docker container. |
| `DOKKU_DISABLE_APP_AUTOCREATION` | none | `dokku config:set` | Disables automatic creation of a non-existent app on deploy. |
| `DOKKU_DOCKERFILE_START_CMD` | none | `dokku config:set` | |
| `DOKKU_PARALLEL_ARGUMENTS`. | none | `dokku config:set` | Allows passing custom arguments to parallel for `ps:*all` commands |
| `DOKKU_PROXY_PORT` | automatically assigned | `/etc/environment` <br /> `~dokku/.dokkurc` <br /> `~dokku/.dokkurc/*` <br /> `dokku config:set` | |
| `DOKKU_PROXY_SSL_PORT` | automatically assigned | `/etc/environment` <br /> `~dokku/.dokkurc` <br /> `~dokku/.dokkurc/*` <br /> `dokku config:set` | |
| `DOKKU_SKIP_ALL_CHECKS` | none | `dokku config:set` | |
| `DOKKU_SKIP_CLEANUP` | | `/etc/environment` <br /> `~dokku/.dokkurc` <br /> `~dokku/.dokkurc/*` | When a deploy is triggered, if this is set to a non-empty value, then old docker containers and images will not be removed. |
| `DOKKU_SKIP_DEFAULT_CHECKS` | | `dokku config:set` | |
| `DOKKU_SKIP_DEPLOY` | | `dokku config:set` | |
| `DOKKU_START_CMD` | none | `dokku config:set` | Command to run instead of `/start $PROC_TYPE` |
| `DOKKU_SKIP_CLEANUP` | | `/etc/environment` <br /> `~dokku/.dokkurc` <br /> `~dokku/.dokkurc/*` | When a deploy is triggered, if this is set to a non-empty value, then old docker containers and images will not be removed. Falls back to the `builder:set skip-cleanup` property. |
| `DOKKU_SYSTEM_GROUP` | `dokku` | `/etc/environment` <br /> `~dokku/.dokkurc` <br /> `~dokku/.dokkurc/*` | System group to chown files as. |
| `DOKKU_SYSTEM_USER` | `dokku` | `/etc/environment` <br /> `~dokku/.dokkurc` <br /> `~dokku/.dokkurc/*` | System user to chown files as. |
## Deprecated Environment Variables
The following environment variables have been migrated to plugin properties. Existing values will be automatically migrated on upgrade. Use the corresponding property commands going forward.
| Deprecated Env Var | Replacement Command |
|---|---|
| `DOKKU_APP_PROXY_TYPE` | `dokku proxy:set <app> type <value>` |
| `DOKKU_APP_RESTORE` | `dokku ps:set <app> restore <true\|false>` |
| `DOKKU_APP_SHELL` | `dokku scheduler:set <app> shell <value>` |
| `DOKKU_CHECKS_DISABLED` | `dokku checks:disable <app> [proctypes]` |
| `DOKKU_CHECKS_ENABLED` | `dokku checks:enable <app> [proctypes]` |
| `DOKKU_CHECKS_SKIPPED` | `dokku checks:skip <app> [proctypes]` |
| `DOKKU_CHECKS_WAIT` | `dokku checks:set <app> wait <value>` |
| `DOKKU_CHECKS_TIMEOUT` | `dokku checks:set <app> timeout <value>` |
| `DOKKU_CHECKS_ATTEMPTS` | `dokku checks:set <app> attempts <value>` |
| `DOKKU_DEFAULT_CHECKS_WAIT` | `dokku checks:set --global default-wait <value>` |
| `DOKKU_DISABLE_APP_AUTOCREATION` | `dokku apps:set --global disable-autocreation <true\|false>` |
| `DOKKU_DISABLE_PROXY` | `dokku proxy:disable <app>` / `dokku proxy:enable <app>` |
| `DOKKU_DOCKERFILE_START_CMD` | `dokku ps:set <app> dockerfile-start-cmd <value>` |
| `DOKKU_PARALLEL_ARGUMENTS` | Removed. No longer supported. |
| `DOKKU_PROXY_PORT` | `dokku proxy:set <app> proxy-port <value>` |
| `DOKKU_PROXY_SSL_PORT` | `dokku proxy:set <app> proxy-ssl-port <value>` |
| `DOKKU_SKIP_ALL_CHECKS` | `dokku checks:disable <app>` |
| `DOKKU_SKIP_CLEANUP` | `dokku builder:set <app> skip-cleanup <true\|false>` |
| `DOKKU_SKIP_DEFAULT_CHECKS` | `dokku checks:skip <app>` |
| `DOKKU_SKIP_DEPLOY` | `dokku ps:set <app> skip-deploy <true\|false>` |
| `DOKKU_START_CMD` | `dokku ps:set <app> start-cmd <value>` |

View File

@@ -143,11 +143,11 @@ dokku letsencrypt:cron-job --add
### Skipping deployment
If you only want to rebuild and tag a container, you can skip the deployment phase by setting `$DOKKU_SKIP_DEPLOY` to `true` by running:
If you only want to rebuild and tag a container, you can skip the deployment phase by setting the `skip-deploy` property to `true`:
``` shell
# on the Dokku host
dokku config:set ruby-getting-started DOKKU_SKIP_DEPLOY=true
dokku ps:set ruby-getting-started skip-deploy true
```
### Redeploying or restarting

View File

@@ -130,6 +130,24 @@ This will use the latest commit on the `master` branch of the specified buildpac
dokku buildpacks:set node-js-app https://github.com/heroku/heroku-buildpack-nodejs#v87
```
### Specifying buildpacks via app.json
Buildpacks can also be specified in the `app.json` file at the root of your repository:
```json
{
"buildpacks": [
{
"url": "heroku/python"
}
]
}
```
Buildpacks configured via `buildpacks:add` or `buildpacks:set` commands take precedence over those specified in `app.json`. If no buildpacks are configured via commands and `app.json` contains a `buildpacks` entry, those buildpacks will be used.
Shorthand buildpack references (e.g., `heroku/python`) are expanded to full GitHub URLs automatically.
### Displaying buildpack reports for an app
You can get a report about the app's buildpacks status using the `buildpacks:report` command:

View File

@@ -6,6 +6,7 @@
```
git:allow-host <host> # Adds a host to known_hosts
git:auth <host> [<username> <password>] # Configures netrc authentication for a given git server
git:auth-status <host> [<username> <password>] # Reports whether the netrc entry matches the requested state
git:from-archive [--archive-type ARCHIVE_TYPE] <app> <archive-url> [<git-username> <git-email>] # Updates an app's git repository with a given archive file
git:from-image [--build-dir DIRECTORY] <app> <docker-image> [<git-username> <git-email>] # Updates an app's git repository with a given docker image
git:generate-deploy-key # Generates a deploy ssh key
@@ -13,7 +14,7 @@ git:load-image [--build-dir DIRECTORY] <app> <docker-image> [<git-username> <git
git:sync [--build|--build-if-changes] [--skip-deploy-branch] <app> <repository> [<git-ref>] # Clone or fetch an app from remote git repo
git:initialize <app> # Initialize a git repository for an app
git:public-key # Outputs the dokku public deploy key
git:report [<app>] [<flag>] # Displays a git report for one or more apps
git:report [<app>] [<flag>|--format json] # Displays a git report for one or more apps
git:set <app> <key> (<value>) # Set or clear a git property for an app
git:status <app> # Show the working tree status for an app
```
@@ -191,6 +192,34 @@ dokku git:auth github.com
For syncing to a private repository stored on a remote Git product such as GitHub or GitLab, Dokku's recommendation is to use a personal access token on a bot user where possible. Please see your service's documentation for information regarding the recommended best practices.
The password for `git:auth` may also be provided over `STDIN` to avoid placing it on the command line:
```shell
# pipe the password into git:auth
echo "personal-access-token" | dokku git:auth github.com username
```
#### Checking the configured auth state
> [!IMPORTANT]
> New as of 0.38.0
The `git:auth-status` command reports whether the configured `netrc` entry matches a desired state without exposing the underlying file. It exits `0` when the configured state matches and `1` otherwise. This allows external tooling such as configuration management systems to perform idempotent updates without reading `$DOKKU_ROOT/.netrc` directly.
```shell
# check whether github.com is configured with the expected credentials
dokku git:auth-status github.com username personal-access-token
# check whether no credentials are configured for github.com
dokku git:auth-status github.com
```
As with `git:auth`, the password may be provided via `STDIN`:
```shell
echo "personal-access-token" | dokku git:auth-status github.com username
```
### Allowing remote repository hosts
By default, the Dokku host may not have access to a server containing the remote repository. This can be initialized via the `git:allow-host` command.
@@ -241,3 +270,42 @@ dokku git:public-key
```
If there is no key, an error message is shown that displays the command that can be run on the Dokku server to generate a new public/private ssh key pair.
### Displaying git reports for an app
You can get a report about the app's git configuration using the `git:report` command:
```shell
dokku git:report
```
```
=====> node-js-app git information
Git deploy branch: master
Git global deploy branch: master
Git keep git dir: false
Git rev env var: GIT_REV
Git sha: a1b2c3d
Git source image:
Git last updated at: 1700000000
```
You can run the command for a specific app also.
```shell
dokku git:report node-js-app
```
You can pass flags which will output only the value of the specific information you want. For example:
```shell
dokku git:report node-js-app --git-deploy-branch
```
The `git:report` command also takes a `--format` flag, with the valid options including `stdout` (default) and `json`. The `json` output format can be used for automation purposes:
```shell
dokku git:report node-js-app --format json
```
The `--format` flag cannot be combined with an info flag.

View File

@@ -444,7 +444,7 @@ To enable autoscaling, use the `app.json` `formation.$PROCESS_TYPE.autoscaling`
- `cooldown_seconds`: (default: 300) The number of seconds to wait in between each scaling event
- `triggers`: A list of autoscaling triggers.
Autoscaling triggers are passed as is to Keda, and should match the configuration keda uses for a given [scaler](https://keda.sh/docs/2.13/scalers/). Below is an example for [datadog](https://keda.sh/docs/2.13/scalers/datadog/#example-2---driving-scale-directly):
Autoscaling triggers are passed as is to Keda, and should match the configuration keda uses for a given [scaler](https://keda.sh/docs/2.19/scalers/). Below is an example for [datadog](https://keda.sh/docs/2.19/scalers/datadog/#example-2---driving-scale-directly):
```json
{
@@ -476,6 +476,8 @@ Each value in the `metadata` stanza can use the following interpolated strings:
- `DOKKU_PROCESS_TYPE`: The name of the process being scaled
- `DOKKU_APP_NAME`: The name of the app being scaled
Dokku configures a Keda `fallback` (with a failure threshold of `3` and the configured replica count) on the generated `ScaledObject` so that scaling falls back to the configured replica count when a scaler's metric source becomes unavailable. Keda only allows `fallback` when at least one trigger is something other than `cpu` or `memory`, so Dokku omits the `fallback` block when every configured trigger is a `cpu` or `memory` scaler.
##### HTTP Autoscaling
In addition to the built-in scalers that Keda provides, Dokku also supports Keda's HTTP Add On. This requires that the addon be properly installed and configured. For existing k3s clusters, this can be performed by the `scheduler-k3s:ensure-charts` command:
@@ -683,6 +685,35 @@ Alternatively, a comma separated list of chart names can be specified to only fo
dokku scheduler-k3s:ensure-charts --charts cert-manager
```
### Persistent storage
K3s apps mount persistent volumes through the Dokku storage plugin. A `storage:create --scheduler k3s` call provisions a PersistentVolumeClaim through a small per-entry helm release; `storage:mount <app> <name> --container-dir <path>` then attaches it to an app. Because storage entries are global, the same PVC can be mounted into multiple apps (for `ReadWriteMany` workloads) or into the same app at multiple subpaths.
```shell
# dynamically-provisioned PVC backed by Longhorn
dokku storage:create demo-data \
--scheduler k3s \
--size 2Gi \
--access-mode ReadWriteOnce \
--storage-class-name longhorn
dokku storage:mount demo demo-data --container-dir /data
dokku storage:wait demo-data
git push dokku master
```
For a hostPath-backed PV (no StorageClass), pass `<path>` as the second positional argument and omit `--storage-class-name`. The plugin renders both the PV and the PVC into the entry's helm release. The `--reclaim-policy` flag (`Retain` or `Delete`) controls whether the underlying PV survives `storage:destroy`. Annotations and labels on `storage:create` / `storage:set` propagate to both the PVC and the PV so backup tools (Velero, Longhorn snapshots) can find them.
The legacy `storage:mount <app> <host>:<container>` colon form is rejected on k3s apps; create a named entry instead. See [Persistent Storage](/docs/advanced-usage/persistent-storage.md) for the full command reference.
### Chart upgrade callbacks
Some chart version transitions require side-effects that a plain `helm upgrade` cannot perform. For example, the upgrade of `keda-add-ons-http` to `0.12.2` introduces breaking changes to deployment selectors, which are immutable in Kubernetes; the chart-managed deployments must be deleted before the upgrade can proceed.
Dokku handles these cases internally by registering version-targeted pre-upgrade and post-upgrade callbacks against affected charts. When `scheduler-k3s:ensure-charts` runs, the installed chart version is compared against the registered callbacks: any whose target version is greater than the currently installed version and at most the configured chart version are executed in ascending semver order, each bracketed around an upgrade to that intermediate version. A final upgrade to the configured chart version then completes the run.
These callbacks are not user-configurable. Failures during a callback or its surrounding upgrade abort the run, and a subsequent `scheduler-k3s:ensure-charts` resumes from the current state.
## Scheduler Interface
The following sections describe implemented and unimplemented scheduler functionality for the `k3s` scheduler.
@@ -742,6 +773,7 @@ Next, run the `scheduler-k3s:ensure-charts` command with the `vector` chart to f
```shell
dokku scheduler-k3s:ensure-charts --charts vector
```
Please see the [vector logs documentation](/docs/deployment/logs.md#configuring-a-log-sink) for more information on specifying vector sinks.
### Supported Resource Management Properties

View File

@@ -28,7 +28,9 @@ You may both create user-defined checks for web processes using the `healthcheck
### wait-to-retire
After a successful deploy, the grace period given to old containers before they are stopped/terminated is determined by the value of `wait-to-retire`. This is useful for ensuring completion of long-running HTTP connections.
After a successful deploy, Dokku immediately sends `SIGTERM` to old containers so the application can begin a graceful shutdown (draining in-flight connections, flushing buffers, and so on) the moment proxy traffic switches to the new containers. The old containers are then left running for the `wait-to-retire` grace period before being stopped, which sends `SIGTERM` again and ultimately `SIGKILL` after `stop-timeout-seconds`. This mirrors Heroku's [dyno shutdown behavior](https://devcenter.heroku.com/articles/dyno-shutdown-behavior).
The `wait-to-retire` value controls the grace period and is useful for ensuring completion of long-running HTTP connections.
```shell
dokku checks:set node-js-app wait-to-retire 30
@@ -40,17 +42,17 @@ Defaults to `60`.
You can set the `stop-timeout-seconds` property on the `ps` plugin to change this value (default: `30`). See the [process management documentation](/docs/processes/process-management.md#changing-process-management-settings) for more information.
## Configuring check settings using the `config` plugin
## Configuring check settings
There are certain settings that can be configured via environment variables:
There are certain settings that can be configured via the `checks` plugin properties:
- `DOKKU_DEFAULT_CHECKS_WAIT`: (default: `10`) If no user-defined checks are specified - or if the process being checked is not a `web` process - this is the period of time Dokku will wait before checking that a container is still running.
- `default-wait`: (default: `10`) If no user-defined checks are specified - or if the process being checked is not a `web` process - this is the period of time Dokku will wait before checking that a container is still running. Set globally via `dokku checks:set --global default-wait <value>`.
The following settings may also be specified in the `app.json` file, though are available as environment variables in order to ease application reuse.
The following settings may also be specified in the `app.json` file, though are available as properties in order to ease application reuse.
- `DOKKU_CHECKS_WAIT`: (default: `5`) Wait this many seconds for the container to start before running checks.
- `DOKKU_CHECKS_TIMEOUT`: (default: `30`) Wait this many seconds for each response before marking it as a failure.
- `DOKKU_CHECKS_ATTEMPTS`: (default: `5`) Number of retries for to run for a specific check before marking it as a failure
- `wait`: (default: `5`) Wait this many seconds for the container to start before running checks. Set via `dokku checks:set <app> wait <value>`.
- `timeout`: (default: `30`) Wait this many seconds for each response before marking it as a failure. Set via `dokku checks:set <app> timeout <value>`.
- `attempts`: (default: `5`) Number of retries for to run for a specific check before marking it as a failure. Set via `dokku checks:set <app> attempts <value>`.
## Skipping and Disabling Checks

View File

@@ -545,6 +545,21 @@ set -eo pipefail; [[ $DOKKU_TRACE ]] && set -x
# TODO
```
### `config-set`
- Description: Sets one or more config values for an app without restarting (when --no-restart flag is specified)
- Invoked by: app-json plugin
- Arguments: `[--no-restart] $APP $KEY1=VALUE1 [$KEY2=VALUE2 ...]`
- Example:
```shell
#!/usr/bin/env bash
set -eo pipefail; [[ $DOKKU_TRACE ]] && set -x
# TODO
```
### `core-post-deploy`
> To avoid issues with community plugins, this plugin trigger should be used _only_ for core plugins. Please avoid using this trigger in your own plugins.
@@ -794,7 +809,7 @@ APP="$1"; IMAGE_SOURCE_TYPE="$2"
### `docker-args-process-deploy`
- Description: `$PROC_TYPE` may be set to magic `_all_` process type to signify global docker deploy options.
- Description: emits docker arguments scoped to a specific Procfile process type. The `docker-options` plugin implements this trigger to surface options registered via `docker-options:add --process <PROC>`. `$PROC_TYPE` may be empty (or set to the magic `_default_` value) to signify default-scope docker deploy options.
- Invoked by: `dokku deploy`
- Arguments: `$APP $IMAGE_SOURCE_TYPE $IMAGE_TAG [$PROC_TYPE $CONTAINER_INDEX]`
- Example:
@@ -2889,21 +2904,52 @@ DOKKU_SCHEDULER="$1"; APP="$2"; REMOVE_CONTAINERS="$3";
# TODO
```
### `storage-list`
### `storage-list` (deprecated)
- Description: Returns a list of storage mounts
- Invoked by: `dokku storage:list` and `dokku deploy`
> [!WARNING]
> Deprecated as of 0.38.0. The trigger handler still functions for back-compat
> with external plugins but emits a deprecation warning on every invocation.
> In-process callers should use the `storage-app-mounts` trigger instead, or
> the `storage` Go package directly.
- Description: Returns a list of storage mounts for an app
- Invoked by: external plugins (the core `storage:list` subcommand no longer uses it)
- Arguments: `$APP $PHASE $FORMAT`
- Example:
```shell
#!/usr/bin/env bash
### `storage-app-mounts`
set -eo pipefail; [[ $DOKKU_TRACE ]] && set -x
APP="$1"
- Description: Returns the (entry, attachment) pairs an app has for the given phase, in JSON. Schedulers consume this to render volume mounts.
- Invoked by: scheduler plugins during deploy
- Arguments: `$APP $PHASE`
- Output: a JSON array of objects shaped `{"entry": Entry, "attachment": Attachment}`
# TODO
```
### `storage-create`
- Description: Provisions or updates the underlying volume for a k3s storage entry. Called when `storage:create` or `storage:set` runs against a k3s entry. Receives the JSON-encoded entry on stdin.
- Invoked by: `dokku storage:create`, `dokku storage:set`
- Arguments: `$ENTRY_NAME`
- Stdin: JSON-encoded `Entry` payload
### `storage-destroy`
- Description: Releases the underlying volume for a k3s storage entry. Called when `storage:destroy` runs against a k3s entry. Receives the JSON-encoded entry on stdin.
- Invoked by: `dokku storage:destroy`
- Arguments: `$ENTRY_NAME`
- Stdin: JSON-encoded `Entry` payload
### `storage-status`
- Description: Returns the current status of a storage entry's underlying volume. Used by `storage:wait` to poll the cluster. The handler should print one line: `Bound`, `Pending`, or `Lost` for k3s entries.
- Invoked by: `dokku storage:wait`
- Arguments: `$ENTRY_NAME`
- Stdin: JSON-encoded `Entry` payload
### `scheduler-storage-exec`
- Description: Runs an interactive or non-interactive command against a storage entry. The storage plugin fires this with `<scheduler>` as the first arg; each scheduler plugin's handler matches against its own scheduler name and either handles or no-ops, mirroring `scheduler-deploy` / `scheduler-app-status`. Plugn forwards stdin/stdout/stderr to the handler subprocess so an interactive shell streams cleanly. The handler exits with the underlying tool's status code so `dokku storage:exec` propagates exit codes verbatim.
- Invoked by: `dokku storage:exec`
- Arguments: `$SCHEDULER $ENTRY_NAME $IMAGE [-- $cmd...]`
- Flags: `--interactive` (stdin is open), `--tty` (stdin is a terminal), `--as-user <uid>` (override `entry.Chown`).
### `traefik-template-source`

View File

@@ -18,6 +18,7 @@ Docker releases updates periodically to their engine. We recommend reading their
Before upgrading, check the migration guides to get comfortable with new features and prepare your deployment to be upgraded.
- [Upgrading to 0.38](/docs/appendices/0.38.0-migration-guide.md)
- [Upgrading to 0.37](/docs/appendices/0.37.0-migration-guide.md)
- [Upgrading to 0.36](/docs/appendices/0.36.0-migration-guide.md)
- [Upgrading to 0.35](/docs/appendices/0.35.0-migration-guide.md)
@@ -152,4 +153,3 @@ new buildpacks that were released:
```shell
dokku ps:rebuild --all
```

View File

@@ -5,7 +5,8 @@ Dokku uses nginx as its server for routing requests to specific applications. By
```
nginx:access-logs <app> [-t] # Show the nginx access logs for an application (-t follows)
nginx:error-logs <app> [-t] # Show the nginx error logs for an application (-t follows)
nginx:report [<app>] [<flag>] # Displays a nginx report for one or more apps
nginx:reload # Reloads the nginx server config
nginx:report [<app>] [<flag>|--format json] # Displays a nginx report for one or more apps
nginx:set <app> <property> (<value>) # Set or clear an nginx property for an app
nginx:show-config <app> # Display app nginx config
nginx:start # Starts the nginx server
@@ -27,6 +28,21 @@ Nginx will proxy the requests in a [round-robin balancing fashion](http://nginx.
> [!NOTE]
> Due to how the plugin is implemented, if an app successfully starts up `web` containers but fails to deploy some other containers, nginx may eventually stop routing requests. Users should revert their code in these cases, or manually trigger `dokku proxy:build-config $APP` in order to ensure requests route to the new web containers.
### Nginx Configuration for Undeployed Apps
> [!IMPORTANT]
> New as of 0.38.0
When an app is created but not yet deployed, has no `web` process type, or has no running web processes, Dokku generates a minimal nginx configuration that returns `502 Bad Gateway` responses. This ensures that:
- The app's domain resolves and returns a non-200 status code, allowing monitoring tools to detect the issue.
- SSL certificate provisioning tools such as letsencrypt can function, as an nginx server is listening on the domain.
- The `nginx.conf.d/` include directory is available for plugin customization.
The 502 error page includes auto-retry JavaScript that will automatically reload the page when the application becomes available.
Once the app is deployed with running `web` processes, the placeholder configuration is automatically replaced with the full proxy configuration.
### Starting nginx
> [!IMPORTANT]
@@ -49,6 +65,19 @@ The nginx server can be stopped via `nginx:stop`.
dokku nginx:stop
```
### Reloading nginx
> [!IMPORTANT]
> New as of 0.38.0
The nginx server can be reloaded via `nginx:reload`. This validates the current nginx configuration and, if valid, signals nginx to reload without restarting the running process. This is the preferred command after editing files in `/etc/nginx/conf.d/` directly.
```shell
dokku nginx:reload
```
If the configuration is invalid, the command exits non-zero and prints the validation error without reloading.
### Checking access logs
> [!NOTE]
@@ -90,7 +119,9 @@ dokku nginx:show-config node-js-app
### Validating nginx configs
It may be desired to validate an nginx config outside of the deployment process. To do so, run the `nginx:validate-config` command. With no arguments, this will validate all app nginx configs, one at a time. A minimal wrapper nginx config is generated for each app's nginx config, upon which `nginx -t` will be run.
App-supplied `nginx.conf.sigil` files are pre-validated automatically at the start of every deploy, immediately after the template is extracted from the source tree and before the build phase runs. The template is rendered with sigil and run through `nginx -t` against a minimal wrapper config; if validation fails, the deploy is aborted before any build work begins. To bypass this behavior, set `disable-custom-config` to `true` (see "Disabling custom nginx config" below).
It may also be desired to validate an nginx config outside of the deployment process. To do so, run the `nginx:validate-config` command. With no arguments, this will validate all app nginx configs, one at a time. A minimal wrapper nginx config is generated for each app's nginx config, upon which `nginx -t` will be run.
```shell
dokku nginx:validate-config
@@ -113,6 +144,34 @@ The `--clean` flag may also be specified for a given app:
dokku nginx:validate-config node-js-app --clean
```
### Displaying nginx reports for an app
You can get a report about the app's nginx configuration using the `nginx:report` command:
```shell
dokku nginx:report
```
You can run the command for a specific app also.
```shell
dokku nginx:report node-js-app
```
You can pass flags which will output only the value of the specific information you want. For example:
```shell
dokku nginx:report node-js-app --nginx-bind-address-ipv4
```
The `nginx:report` command also takes a `--format` flag, with the valid options including `stdout` (default) and `json`. The `json` output format can be used for automation purposes:
```shell
dokku nginx:report node-js-app --format json
```
The `--format` flag cannot be combined with an info flag.
### Custom Error Pages
By default, Dokku provides custom error pages for the following three categories of errors:
@@ -125,52 +184,91 @@ These are provided as an alternative to the generic Nginx error page, are shared
### Default site
By default, Dokku will route any received request with an unknown HOST header value to the lexicographically first site in the nginx config stack. This means that accessing the dokku server via its IP address or a bogus domain name may return a seemingly random website.
> [!IMPORTANT]
> New as of 0.38.0
> [!WARNING]
> Some versions of Nginx may create a default site when installed. This site is simply a static page which says "Welcome to Nginx", and if this default site is enabled, Nginx will not route any requests with an unknown HOST header to Dokku. If you want Dokku to receive all requests, run the following commands:
>
> ```
> rm /etc/nginx/sites-enabled/default
> dokku nginx:stop
> dokku nginx:start
> ```
On fresh apt installs, Dokku ships a catch-all default site at `/etc/nginx/conf.d/00-default-vhost.conf` that rejects requests whose Host header (or TLS SNI) does not match any deployed app. The catch-all uses [`ssl_reject_handshake on`](https://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_reject_handshake) for HTTPS and [`return 444`](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#return) for HTTP. Both close the connection without sending a response.
If services should only be accessed via their domain name, you may want to disable the default site by adding the following configuration to the global nginx configuration.
Create the file at `/etc/nginx/conf.d/00-default-vhost.conf`:
The shipped file looks like:
```nginx
server {
listen 80 default_server;
listen [::]:80 default_server;
# If services hosted by dokku are available via HTTPS, it is recommended
# to also uncomment the following section.
#
# Please note that in order to let this work, you need an SSL certificate. However
# it does not need to be valid. Users of Debian-based distributions can install the
# `ssl-cert` package with `sudo apt install ssl-cert` to automatically generate
# a self-signed certificate that is stored at `/etc/ssl/certs/ssl-cert-snakeoil.pem`.
#
#listen 443 ssl;
#listen [::]:443 ssl;
#ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
#ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
listen 80 default_server;
listen [::]:80 default_server;
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
access_log off;
access_log off;
ssl_reject_handshake on;
return 444;
}
```
Make sure to reload nginx after creating this file by running `systemctl reload nginx.service`.
The `00-` prefix forces nginx to load this file before `/etc/nginx/conf.d/dokku.conf`, so its `default_server` markers establish the default for each port before any per-app server blocks are loaded.
This will catch all unknown HOST header values and close the connection without responding. You can replace the `return 444;` with `return 410;` which will cause nginx to respond with an error page.
#### TLS handshake behavior
The configuration file must be loaded before `/etc/nginx/conf.d/dokku.conf`, so it can not be arranged as a vhost in `/etc/nginx/sites-enabled` that is only processed afterwards.
The catch-all does not affect TLS handshakes for legitimate apps. nginx selects the matching server block via SNI before completing the handshake; only requests that have no matching app fall through to the catch-all.
Alternatively, you may push an app to your Dokku host with a name like "00-default". As long as it lists first in `ls /home/dokku/*/nginx.conf | head`, it will be used as the default nginx vhost.
| Request | Result |
| --- | --- |
| HTTPS to a configured app's hostname (with matching SNI) and the app has a cert | Handshake completes with the app's cert. Catch-all not consulted. |
| HTTPS to a configured app's hostname when the app has no cert configured | Handshake rejected by the catch-all. (Previously: nginx fell through to the lexicographically first port-443 server block and presented its cert, producing a confusing cert-mismatch error.) |
| HTTPS to the server's IP with no SNI, or with an SNI matching no app | Handshake rejected. |
| HTTP to a configured app's hostname | Routed normally to that app. Catch-all not consulted. |
| HTTP to a hostname matching no app | Catch-all `return 444`. |
#### Conflicting upstream nginx default vhost
A fresh nginx install ships its own default vhost that also claims port 80 with `default_server`, which would cause `nginx -t` to fail with `a duplicate default server for 0.0.0.0:80` once the dokku catch-all is in place. To avoid this, the dokku postinst renames any of the following files in place to `${path}.dokku-disabled` instead of deleting them, preserving any local customizations:
- `/etc/nginx/sites-enabled/default`
- `/etc/nginx/sites-available/default`
- `/etc/nginx/conf.d/default.conf`
To recover an original file, inspect the `.dokku-disabled` sibling.
#### Disabling the catch-all on first install
Select "No" at the `dokku/install_default_site` debconf prompt, or pre-seed the answer before installing:
```shell
echo 'dokku dokku/install_default_site boolean false' | debconf-set-selections
```
#### Customizing or removing the catch-all after install
To customize the reject behavior, edit `/etc/nginx/conf.d/00-default-vhost.conf` and reload:
```shell
dokku nginx:reload
```
For example, replace `return 444;` with `return 410;` to respond with an HTTP 410 Gone error page instead of dropping the connection.
To disable the catch-all without uninstalling dokku:
```shell
mv /etc/nginx/conf.d/00-default-vhost.conf{,.disabled}
dokku nginx:reload
```
#### Installing the catch-all on upgraded or non-apt installs
Existing dokku installs upgraded from earlier versions do not get the catch-all installed automatically; the upgrade leaves their nginx configuration untouched. To install it manually, copy the shipped template into place and reload nginx:
```shell
cp /var/lib/dokku/core-plugins/available/nginx-vhosts/templates/default-site.conf /etc/nginx/conf.d/00-default-vhost.conf
dokku nginx:reload
```
If the upstream nginx default vhost is also active, disable it first (e.g. `mv /etc/nginx/sites-enabled/default{,.disabled}`) to avoid a duplicate-default-server error.
#### App-name based alternative
As an alternative to the catch-all file, you may push an app to your Dokku host with a name like `00-default`. As long as it lists first in `ls /home/dokku/*/nginx.conf | head`, it will be used as the default nginx vhost.
### Customizing the nginx configuration

View File

@@ -188,6 +188,7 @@
<a href="#" class="list-group-item disabled">Advanced Usage</a>
<a href="/{{NAME}}/advanced-usage/backup-recovery/" class="list-group-item">Backup and Recovery</a>
<a href="/{{NAME}}/advanced-usage/builds/" class="list-group-item">Build Tracking</a>
<a href="/{{NAME}}/advanced-usage/deployment-tasks/" class="list-group-item">Deployment Tasks</a>
<a href="/{{NAME}}/advanced-usage/docker-options/" class="list-group-item">Docker Container Options</a>
<a href="/{{NAME}}/advanced-usage/registry-management/" class="list-group-item">Docker Registry Management</a>
@@ -222,6 +223,17 @@
<a href="#" class="list-group-item disabled">Community Contributions</a>
<a href="/{{NAME}}/community/clients/" class="list-group-item">Clients</a>
<a href="/{{NAME}}/community/plugins/" class="list-group-item">Plugins</a>
<a href="#" class="list-group-item disabled">File Formats</a>
<a href="/{{NAME}}/appendices/file-formats/app-json/" class="list-group-item">app.json</a>
<a href="/{{NAME}}/appendices/file-formats/buildpack-file/" class="list-group-item">.buildpack</a>
<a href="/{{NAME}}/appendices/file-formats/dockerfile/" class="list-group-item">Dockerfile</a>
<a href="/{{NAME}}/appendices/file-formats/lambda-yml/" class="list-group-item">lambda.yml</a>
<a href="/{{NAME}}/appendices/file-formats/nginx-conf-sigil/" class="list-group-item">nginx.conf.sigil</a>
<a href="/{{NAME}}/appendices/file-formats/nixpacks-toml/" class="list-group-item">nixpacks.toml</a>
<a href="/{{NAME}}/appendices/file-formats/procfile/" class="list-group-item">Procfile</a>
<a href="/{{NAME}}/appendices/file-formats/project-toml/" class="list-group-item">project.toml</a>
<a href="/{{NAME}}/appendices/file-formats/railpack-json/" class="list-group-item">railpack.json</a>
</div>
</div>
<div class="col-8 col-md-9 markdown-body" style="padding-top:16px">

4
dokku
View File

@@ -188,6 +188,10 @@ execute_dokku_cmd() {
set -- "$PLUGIN_CMD" "$@"
fi
if [[ "$PLUGIN_NAME" == "git-hook" ]] || [[ "$PLUGIN_NAME" == "ps:rebuild" ]] || [[ "$PLUGIN_NAME" == "ps:restart" ]]; then
dokku_setup_build_capture "$2" "$PLUGIN_NAME"
fi
if [[ -x $PLUGIN_ENABLED_PATH/$PLUGIN_NAME/subcommands/default ]]; then
"$PLUGIN_ENABLED_PATH/$PLUGIN_NAME/subcommands/default" "$@"
implemented=1

View File

@@ -1,4 +1,4 @@
go 1.25.5
go 1.26.2
use (
./plugins/app-json
@@ -21,4 +21,5 @@ use (
./plugins/scheduler
./plugins/scheduler-docker-local
./plugins/scheduler-k3s
./plugins/storage
)

View File

@@ -24,11 +24,56 @@ var (
}
)
// EnvVarValue represents an environment variable definition in app.json
// It supports both simple string values and complex object definitions
type EnvVarValue struct {
// Description provides context for the env var (used in prompts)
Description string `json:"description,omitempty"`
// Value is the default value for the env var
Value string `json:"value,omitempty"`
// Required indicates if the env var must have a value (defaults to true per Heroku spec)
Required *bool `json:"required,omitempty"`
// Generator specifies how to auto-generate a value (currently only "secret" is supported)
Generator string `json:"generator,omitempty"`
// Sync indicates if the value should be set on every deploy, not just first
Sync bool `json:"sync,omitempty"`
}
// UnmarshalJSON handles both string and object formats for env vars
func (e *EnvVarValue) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err == nil {
e.Value = str
return nil
}
type envVarAlias EnvVarValue
return json.Unmarshal(data, (*envVarAlias)(e))
}
// IsRequired returns true if the env var is required (defaults to true per Heroku spec)
func (e *EnvVarValue) IsRequired() bool {
if e.Required == nil {
return true
}
return *e.Required
}
// AppJSON is a struct that represents an app.json file as understood by Dokku
type AppJSON struct {
// Buildpacks is a list of buildpacks to use for the app
Buildpacks []Buildpack `json:"buildpacks"`
// Cron is a list of cron tasks to execute
Cron []CronTask `json:"cron"`
// Env is a map of environment variables to set
Env map[string]EnvVarValue `json:"env,omitempty"`
// Formation is a map of process types to scale
Formation map[string]Formation `json:"formation"`
@@ -51,6 +96,12 @@ type AppJSON struct {
} `json:"scripts"`
}
// Buildpack is a struct that represents a single buildpack
type Buildpack struct {
// URL is the URL of the buildpack
URL string `json:"url"`
}
// CronTask is a struct that represents a single cron task from an app.json file
type CronTask struct {
// Command is the command to execute

View File

@@ -1,16 +1,21 @@
package appjson
import (
"bufio"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/dokku/dokku/plugins/common"
shellquote "github.com/kballard/go-shellquote"
"github.com/mattn/go-isatty"
)
func constructScript(command string, shell string, isHerokuishImage bool, isCnbImage bool, dockerfileEntrypoint string) []string {
@@ -455,3 +460,199 @@ func setScale(appName string) error {
})
return err
}
// generateSecret generates a cryptographically secure random hex string of specified length
func generateSecret(length int) (string, error) {
bytes := make([]byte, length/2)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate secret: %w", err)
}
return hex.EncodeToString(bytes), nil
}
// isInteractive returns true if stdin is a terminal (TTY available)
func isInteractive() bool {
return isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd())
}
// promptForValue prompts the user for an environment variable value
func promptForValue(varName string, envVar EnvVarValue) (string, error) {
prompt := fmt.Sprintf("Enter value for %s", varName)
if envVar.Description != "" {
prompt = fmt.Sprintf("Enter value for %s (%s)", varName, envVar.Description)
}
common.LogInfo1(prompt)
fmt.Print("> ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSpace(response), nil
}
// getCurrentConfig retrieves the current config values for an app
func getCurrentConfig(appName string) (map[string]string, error) {
results, err := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "config-export",
Args: []string{appName, "false", "true", "json"},
})
if err != nil {
return nil, fmt.Errorf("failed to get current config: %w", err)
}
var currentConfig map[string]string
if err := json.Unmarshal(results.StdoutBytes(), &currentConfig); err != nil {
return nil, fmt.Errorf("failed to parse current config: %w", err)
}
return currentConfig, nil
}
// setConfigVars sets multiple config vars for an app without triggering restart
func setConfigVars(appName string, vars map[string]string) error {
if len(vars) == 0 {
return nil
}
// Build arguments for config-set trigger: --no-restart flag, appName, key=value pairs
args := []string{"--no-restart", appName}
for key, value := range vars {
args = append(args, fmt.Sprintf("%s=%s", key, value))
}
_, err := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "config-set",
Args: args,
StreamStdio: true,
})
if err != nil {
return fmt.Errorf("failed to set config vars: %w", err)
}
return nil
}
// processAppJSONEnv processes env vars from app.json and sets them as config
func processAppJSONEnv(appName string) error {
appJSON, err := GetAppJSON(appName)
if err != nil {
return err
}
if len(appJSON.Env) == 0 {
return nil
}
common.LogInfo1("Processing app.json env vars")
isFirstDeploy := common.PropertyGet("common", appName, "deployed") != "true"
// Check if env vars have been processed for this deploy
envProcessedProperty := "appjson-env-processed"
alreadyProcessed := common.PropertyGet("app-json", appName, envProcessedProperty) == "executed"
// Get current config values
currentConfig, err := getCurrentConfig(appName)
if err != nil {
return err
}
varsToSet := make(map[string]string)
interactive := isInteractive()
// Sort keys for deterministic ordering
var keys []string
for k := range appJSON.Env {
keys = append(keys, k)
}
sort.Strings(keys)
for _, varName := range keys {
envVar := appJSON.Env[varName]
// Determine if we should process this var
shouldProcess := false
if isFirstDeploy && !alreadyProcessed {
// First deploy: process all vars
shouldProcess = true
} else if envVar.Sync {
// Subsequent deploy with sync: always process sync vars
shouldProcess = true
}
if !shouldProcess {
continue
}
// Check if already set (for first deploy, skip if already has value and not sync)
currentValue, hasValue := currentConfig[varName]
if hasValue && currentValue != "" && !envVar.Sync {
common.LogDebug(fmt.Sprintf("Skipping %s: already set", varName))
continue
}
// Determine the value
var value string
// Handle generator
if envVar.Generator == "secret" {
if !hasValue || currentValue == "" || envVar.Sync {
generated, err := generateSecret(64)
if err != nil {
return fmt.Errorf("failed to generate secret for %s: %w", varName, err)
}
value = generated
common.LogDebug(fmt.Sprintf("Generated secret for %s", varName))
} else {
continue // Already has value, not sync
}
} else if envVar.Value != "" {
// Use default value
value = envVar.Value
} else if interactive {
// Prompt user
prompted, err := promptForValue(varName, envVar)
if err != nil {
if envVar.IsRequired() {
return fmt.Errorf("required env var %s has no value and prompt failed: %w", varName, err)
}
continue
}
if prompted == "" && envVar.IsRequired() {
return fmt.Errorf("required env var %s cannot be empty", varName)
}
value = prompted
} else {
// No TTY, no default, no generator
if envVar.IsRequired() {
return fmt.Errorf("required env var %s has no value, no default, and no TTY for prompt", varName)
}
continue
}
if value != "" {
varsToSet[varName] = value
}
}
if len(varsToSet) == 0 {
common.LogVerbose("No env vars to set from app.json")
if !isFirstDeploy {
return common.PropertyWrite("app-json", appName, envProcessedProperty, "executed")
}
return nil
}
// Set the config vars without restart (we're in middle of deploy)
common.LogInfo1(fmt.Sprintf("Setting %d env var(s) from app.json", len(varsToSet)))
if err := setConfigVars(appName, varsToSet); err != nil {
return fmt.Errorf("failed to set env vars: %w", err)
}
// Mark as processed
return common.PropertyWrite("app-json", appName, envProcessedProperty, "executed")
}

View File

@@ -1,10 +1,11 @@
module github.com/dokku/dokku/plugins/app-json
go 1.25.5
go 1.26.2
require (
github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mattn/go-isatty v0.0.20
github.com/spf13/pflag v1.0.10
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a
k8s.io/utils v0.0.0-20240102154912-e7106e64919e
@@ -17,7 +18,6 @@ require (
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/melbahja/goph v1.5.0 // indirect
github.com/otiai10/copy v1.14.1 // indirect
github.com/otiai10/mint v1.6.3 // indirect

View File

@@ -20,8 +20,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/melbahja/goph v1.5.0 h1:RQUBpLvfg3i7fjfG8rTcSWyMjVRfdhwrrfQhjYee4dQ=
github.com/melbahja/goph v1.5.0/go.mod h1:dDwo+44cmvfDLdiVpc6fJxexf5BA5yEDUeE5YgtuDO4=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc=
github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8=
github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I=
github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs=

View File

@@ -6,14 +6,23 @@ import (
// ReportSingleApp is an internal function that displays the builder report for one or more apps
func ReportSingleApp(appName string, format string, infoFlag string) error {
if err := common.VerifyAppName(appName); err != nil {
return err
if appName != "--global" {
if err := common.VerifyAppName(appName); err != nil {
return err
}
}
flags := map[string]common.ReportFunc{
"--app-json-computed-selected": reportComputedAppjsonpath,
"--app-json-global-selected": reportGlobalAppjsonpath,
"--app-json-selected": reportAppjsonpath,
var flags map[string]common.ReportFunc
if appName == "--global" {
flags = map[string]common.ReportFunc{
"--app-json-global-selected": reportGlobalAppjsonpath,
}
} else {
flags = map[string]common.ReportFunc{
"--app-json-computed-selected": reportComputedAppjsonpath,
"--app-json-global-selected": reportGlobalAppjsonpath,
"--app-json-selected": reportAppjsonpath,
}
}
flagKeys := []string{}

View File

@@ -21,11 +21,14 @@ func main() {
case "report":
args := flag.NewFlagSet("app-json:report", flag.ExitOnError)
format := args.String("format", "stdout", "format: [ stdout | json ]")
osArgs, infoFlag, flagErr := common.ParseReportArgs("appjson", os.Args[2:])
reportArgs, flagErr := common.ParseReportArgs("appjson", os.Args[2:])
if flagErr == nil {
args.Parse(osArgs)
args.Parse(reportArgs.OSArgs)
appName := args.Arg(0)
err = appjson.CommandReport(appName, *format, infoFlag)
if reportArgs.IsGlobal {
appName = "--global"
}
err = appjson.CommandReport(appName, *format, reportArgs.InfoFlag)
}
case "set":
args := flag.NewFlagSet("app-json:set", flag.ExitOnError)

View File

@@ -71,6 +71,11 @@ func TriggerCorePostDeploy(appName string) error {
// TriggerCorePostExtract ensures that the main app.json is the one specified by app-json-path
func TriggerCorePostExtract(appName string, sourceWorkDir string) error {
// Clear the env-processed property on new source extraction to allow re-processing
if err := common.PropertyDelete("app-json", appName, "appjson-env-processed"); err != nil {
return err
}
destination := common.GetAppDataDirectory("app-json", appName)
appJSONPath := strings.Trim(reportComputedAppjsonpath(appName), "/")
if appJSONPath == "" {
@@ -190,7 +195,13 @@ func TriggerPostDeploy(appName string, imageTag string) error {
return executeScript(appName, image, imageTag, "postdeploy")
}
// TriggerPreReleaseBuilder processes app.json env vars and executes the predeploy task
func TriggerPreReleaseBuilder(builderType string, appName string, image string) error {
// Process app.json env vars before predeploy script
if err := processAppJSONEnv(appName); err != nil {
return err
}
parts := strings.Split(image, ":")
imageTag := parts[len(parts)-1]
return executeScript(appName, image, imageTag, "predeploy")

View File

@@ -1,4 +1,4 @@
SUBCOMMANDS = subcommands/clone subcommands/create subcommands/destroy subcommands/exists subcommands/list subcommands/lock subcommands/locked subcommands/rename subcommands/report subcommands/unlock
SUBCOMMANDS = subcommands/clone subcommands/create subcommands/destroy subcommands/exists subcommands/list subcommands/lock subcommands/locked subcommands/rename subcommands/report subcommands/set subcommands/unlock
TRIGGERS = triggers/app-create triggers/app-destroy triggers/app-exists triggers/app-maybe-create triggers/deploy-source-set triggers/install triggers/post-app-clone-setup triggers/post-app-rename triggers/post-app-rename-setup triggers/post-create triggers/post-delete triggers/report
BUILD = commands subcommands triggers
PLUGIN_NAME = apps

View File

@@ -5,11 +5,13 @@ var (
DefaultProperties = map[string]string{
"deploy-source": "",
"deploy-source-metadata": "",
"disable-autocreation": "",
}
// GlobalProperties is a map of all valid global apps properties
GlobalProperties = map[string]bool{
"deploy-source": true,
"deploy-source-metadata": true,
"disable-autocreation": true,
}
)

View File

@@ -132,11 +132,7 @@ func maybeCreateApp(appName string) error {
return nil
}
results, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "config-get-global",
Args: []string{"DOKKU_DISABLE_APP_AUTOCREATION"},
})
disableAutocreate := results.StdoutContents()
disableAutocreate := common.PropertyGetDefault("apps", "--global", "disable-autocreation", "")
if disableAutocreate == "true" {
common.LogWarn("App auto-creation disabled.")
return fmt.Errorf("Re-enable app auto-creation or create an app with 'dokku apps:create %s'", appName)

View File

@@ -1,6 +1,6 @@
module github.com/dokku/dokku/plugins/apps
go 1.25.5
go 1.26.2
require (
github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000

View File

@@ -18,8 +18,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/melbahja/goph v1.5.0 h1:RQUBpLvfg3i7fjfG8rTcSWyMjVRfdhwrrfQhjYee4dQ=
github.com/melbahja/goph v1.5.0/go.mod h1:dDwo+44cmvfDLdiVpc6fJxexf5BA5yEDUeE5YgtuDO4=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc=
github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8=
github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I=
github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs=

View File

@@ -9,16 +9,23 @@ import (
// ReportSingleApp is an internal function that displays the app report for one or more apps
func ReportSingleApp(appName string, format string, infoFlag string) error {
if err := common.VerifyAppName(appName); err != nil {
return err
if appName != "--global" {
if err := common.VerifyAppName(appName); err != nil {
return err
}
}
flags := map[string]common.ReportFunc{
"--app-created-at": reportCreatedAt,
"--app-deploy-source": reportDeploySource,
"--app-deploy-source-metadata": reportDeploySourceMetadata,
"--app-dir": reportDir,
"--app-locked": reportLocked,
var flags map[string]common.ReportFunc
if appName == "--global" {
flags = map[string]common.ReportFunc{}
} else {
flags = map[string]common.ReportFunc{
"--app-created-at": reportCreatedAt,
"--app-deploy-source": reportDeploySource,
"--app-deploy-source-metadata": reportDeploySourceMetadata,
"--app-dir": reportDir,
"--app-locked": reportLocked,
}
}
flagKeys := []string{}

View File

@@ -28,6 +28,7 @@ Additional commands:`
apps:locked <app>, Checks if an app is locked for deployment
apps:rename <old-app> <new-app>, Rename an app
apps:report [<app>] [<flag>], Display report about an app
apps:set <app>|--global <key> <value>, Set or clear an apps property
apps:unlock <app>, Unlocks an app for deployment`
)

View File

@@ -66,12 +66,28 @@ func main() {
case "report":
args := flag.NewFlagSet("apps:report", flag.ExitOnError)
format := args.String("format", "stdout", "format: [ stdout | json ]")
osArgs, infoFlag, flagErr := common.ParseReportArgs("apps", os.Args[2:])
reportArgs, flagErr := common.ParseReportArgs("apps", os.Args[2:])
if flagErr == nil {
args.Parse(osArgs)
args.Parse(reportArgs.OSArgs)
appName := args.Arg(0)
err = apps.CommandReport(appName, *format, infoFlag)
if reportArgs.IsGlobal {
appName = "--global"
}
err = apps.CommandReport(appName, *format, reportArgs.InfoFlag)
}
case "set":
args := flag.NewFlagSet("apps:set", flag.ExitOnError)
global := args.Bool("global", false, "--global: set a global property")
args.Parse(os.Args[2:])
appName := args.Arg(0)
property := args.Arg(1)
value := args.Arg(2)
if *global {
appName = "--global"
property = args.Arg(0)
value = args.Arg(1)
}
err = apps.CommandSet(appName, property, value)
case "unlock":
args := flag.NewFlagSet("apps:unlock", flag.ExitOnError)
args.Parse(os.Args[2:])

View File

@@ -226,6 +226,12 @@ func CommandReport(appName string, format string, infoFlag string) error {
return ReportSingleApp(appName, format, infoFlag)
}
// CommandSet set or clear an apps property for an app
func CommandSet(appName string, property string, value string) error {
common.CommandPropertySet("apps", appName, property, value, DefaultProperties, GlobalProperties)
return nil
}
// CommandUnlock unlocks an app for deployment
func CommandUnlock(appName string) error {
if err := common.VerifyAppName(appName); err != nil {

View File

@@ -53,6 +53,15 @@ func TriggerInstall() error {
return nil
}
if err := common.MigrateConfigToProperties("apps", []common.MigrateConfigEntry{
{
GlobalConfigVar: "DOKKU_DISABLE_APP_AUTOCREATION",
Property: "disable-autocreation",
},
}); err != nil {
return err
}
// migrate all created-at values from app mod-time to property
for _, appName := range apps {
if common.PropertyExists("apps", appName, "created-at") {

View File

@@ -141,6 +141,28 @@ trigger-builder-dockerfile-builder-build() {
DOCKERFILE_ARGS+=("--label" "${1#--label=}")
shift 1
;;
--memory | -m)
DOCKERFILE_ARGS+=("--memory")
DOCKERFILE_ARGS+=("$2")
shift 2
;;
--memory=* | -m=*)
if [[ "$1" == "--memory=*" ]]; then
DOCKERFILE_ARGS+=("--memory" "${1#--memory=}")
elif [[ "$1" == "-m=*" ]]; then
DOCKERFILE_ARGS+=("--memory" "${1#-m=}")
fi
shift 1
;;
--memory-swap)
DOCKERFILE_ARGS+=("--memory-swap")
DOCKERFILE_ARGS+=("$2")
shift 2
;;
--memory-swap=*)
DOCKERFILE_ARGS+=("--memory-swap" "${1#--memory-swap=}")
shift 1
;;
--network)
DOCKERFILE_ARGS+=("--network")
DOCKERFILE_ARGS+=("$2")
@@ -205,7 +227,7 @@ trigger-builder-dockerfile-builder-build() {
shift 1
;;
--ssh)
DOCKERFILE_ARGS+=("--platform")
DOCKERFILE_ARGS+=("--ssh")
DOCKERFILE_ARGS+=("$2")
shift 2
;;
@@ -213,6 +235,15 @@ trigger-builder-dockerfile-builder-build() {
DOCKERFILE_ARGS+=("--ssh" "${1#--ssh=}")
shift 1
;;
--tag)
DOCKERFILE_ARGS+=("--tag")
DOCKERFILE_ARGS+=("$2")
shift 2
;;
--tag=*)
DOCKERFILE_ARGS+=("--tag" "${1#--tag=}")
shift 1
;;
--target)
DOCKERFILE_ARGS+=("--target")
DOCKERFILE_ARGS+=("$2")
@@ -223,12 +254,12 @@ trigger-builder-dockerfile-builder-build() {
shift 1
;;
--ulimit)
DOCKERFILE_ARGS+=("--tag")
DOCKERFILE_ARGS+=("--ulimit")
DOCKERFILE_ARGS+=("$2")
shift 2
;;
--tag=*)
DOCKERFILE_ARGS+=("--tag" "${1#--tag=}")
--ulimit=*)
DOCKERFILE_ARGS+=("--ulimit" "${1#--ulimit=}")
shift 1
;;
--check | -D | --debug | --no-cache)

View File

@@ -8,9 +8,13 @@ cmd-builder-dockerfile-report() {
declare desc="displays a builder-dockerfile report for one or more apps"
declare cmd="builder-dockerfile:report"
[[ "$1" == "$cmd" ]] && shift 1
declare APP="$1" INFO_FLAG="$2"
fn-report-parse-args "$@"
set -- "${REPORT_ARGS[@]}"
declare APP="${1:-}" INFO_FLAG="${2:-}"
if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
if [[ "$REPORT_IS_GLOBAL" == "true" ]]; then
APP="--global"
elif [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
INFO_FLAG="$APP"
APP=""
fi
@@ -19,29 +23,49 @@ cmd-builder-dockerfile-report() {
INFO_FLAG="true"
fi
if [[ -z "$APP" ]]; then
if [[ "$APP" == "--global" ]]; then
cmd-builder-dockerfile-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
elif [[ -z "$APP" ]]; then
for app in $(dokku_apps); do
cmd-builder-dockerfile-report-single "$app" "$INFO_FLAG" | tee || true
cmd-builder-dockerfile-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true
done
else
cmd-builder-dockerfile-report-single "$APP" "$INFO_FLAG"
cmd-builder-dockerfile-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
fi
}
cmd-builder-dockerfile-report-single() {
declare APP="$1" INFO_FLAG="$2"
declare APP="$1" INFO_FLAG="$2" FORMAT="${3:-stdout}"
if [[ "$INFO_FLAG" == "true" ]]; then
INFO_FLAG=""
fi
verify_app_name "$APP"
local flag_map=(
"--builder-dockerfile-computed-dockerfile-path: $(fn-builder-dockerfile-computed-dockerfile-path "$APP")"
"--builder-dockerfile-global-dockerfile-path: $(fn-builder-dockerfile-global-dockerfile-path "$APP")"
"--builder-dockerfile-dockerfile-path: $(fn-builder-dockerfile-dockerfile-path "$APP")"
)
local flag_map=()
if [[ "$APP" == "--global" ]]; then
flag_map=(
"--builder-dockerfile-global-dockerfile-path: $(fn-builder-dockerfile-global-dockerfile-path "$APP")"
)
else
verify_app_name "$APP"
flag_map=(
"--builder-dockerfile-computed-dockerfile-path: $(fn-builder-dockerfile-computed-dockerfile-path "$APP")"
"--builder-dockerfile-global-dockerfile-path: $(fn-builder-dockerfile-global-dockerfile-path "$APP")"
"--builder-dockerfile-dockerfile-path: $(fn-builder-dockerfile-dockerfile-path "$APP")"
)
fi
fn-report-validate-format "$FORMAT" "$INFO_FLAG"
if [[ "$FORMAT" == "json" ]]; then
fn-report-emit-json flag_map "builder-dockerfile"
return
fi
if [[ -z "$INFO_FLAG" ]]; then
dokku_log_info2_quiet "${APP} builder-dockerfile information"
if [[ "$APP" == "--global" ]]; then
dokku_log_info2_quiet "global builder-dockerfile information"
else
dokku_log_info2_quiet "${APP} builder-dockerfile information"
fi
for flag in "${flag_map[@]}"; do
key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')"
dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")"

View File

@@ -78,7 +78,7 @@ trigger-builder-herokuish-builder-build() {
fn-builder-herokuish-ensure-cache "$APP"
if ! CID=$("$DOCKER_BIN" container create "${DOCKER_RUN_LABEL_ARGS[@]}" $DOKKU_GLOBAL_RUN_ARGS -v "cache-$APP:/cache" --env=CACHE_PATH=/cache "${ARG_ARRAY[@]}" "$IMAGE" /build); then
plugn trigger scheduler-register-retired "$APP" "$CID"
dokku_log_warn "Failure during app build"
dokku_log_warn "Failure creating container during app build"
return 1
fi
@@ -86,14 +86,14 @@ trigger-builder-herokuish-builder-build() {
"$DOCKER_BIN" container start "$CID" >/dev/null || DOKKU_CONTAINER_EXIT_CODE=$?
if ! "$DOCKER_BIN" container attach "$CID"; then
plugn trigger scheduler-register-retired "$APP" "$CID"
dokku_log_warn "Failure during app build"
dokku_log_warn "Failure starting container during app build"
return 1
fi
DOKKU_CONTAINER_EXIT_CODE="$("$DOCKER_BIN" container wait "$CID" 2>/dev/null || echo "$DOKKU_CONTAINER_EXIT_CODE")"
if [[ "$DOKKU_CONTAINER_EXIT_CODE" -ne 0 ]]; then
plugn trigger scheduler-register-retired "$APP" "$CID"
dokku_log_warn "Failure during app build"
dokku_log_warn "Failure attaching to container during app build"
return 1
fi

View File

@@ -8,9 +8,13 @@ cmd-builder-herokuish-report() {
declare desc="displays a builder-herokuish report for one or more apps"
declare cmd="builder-herokuish:report"
[[ "$1" == "$cmd" ]] && shift 1
declare APP="$1" INFO_FLAG="$2"
fn-report-parse-args "$@"
set -- "${REPORT_ARGS[@]}"
declare APP="${1:-}" INFO_FLAG="${2:-}"
if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
if [[ "$REPORT_IS_GLOBAL" == "true" ]]; then
APP="--global"
elif [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
INFO_FLAG="$APP"
APP=""
fi
@@ -19,29 +23,49 @@ cmd-builder-herokuish-report() {
INFO_FLAG="true"
fi
if [[ -z "$APP" ]]; then
if [[ "$APP" == "--global" ]]; then
cmd-builder-herokuish-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
elif [[ -z "$APP" ]]; then
for app in $(dokku_apps); do
cmd-builder-herokuish-report-single "$app" "$INFO_FLAG" | tee || true
cmd-builder-herokuish-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true
done
else
cmd-builder-herokuish-report-single "$APP" "$INFO_FLAG"
cmd-builder-herokuish-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
fi
}
cmd-builder-herokuish-report-single() {
declare APP="$1" INFO_FLAG="$2"
declare APP="$1" INFO_FLAG="$2" FORMAT="${3:-stdout}"
if [[ "$INFO_FLAG" == "true" ]]; then
INFO_FLAG=""
fi
verify_app_name "$APP"
local flag_map=(
"--builder-herokuish-computed-allowed: $(fn-builder-herokuish-computed-allowed "$APP")"
"--builder-herokuish-global-allowed: $(fn-builder-herokuish-global-allowed)"
"--builder-herokuish-allowed: $(fn-builder-herokuish-allowed "$APP")"
)
local flag_map=()
if [[ "$APP" == "--global" ]]; then
flag_map=(
"--builder-herokuish-global-allowed: $(fn-builder-herokuish-global-allowed)"
)
else
verify_app_name "$APP"
flag_map=(
"--builder-herokuish-computed-allowed: $(fn-builder-herokuish-computed-allowed "$APP")"
"--builder-herokuish-global-allowed: $(fn-builder-herokuish-global-allowed)"
"--builder-herokuish-allowed: $(fn-builder-herokuish-allowed "$APP")"
)
fi
fn-report-validate-format "$FORMAT" "$INFO_FLAG"
if [[ "$FORMAT" == "json" ]]; then
fn-report-emit-json flag_map "builder-herokuish"
return
fi
if [[ -z "$INFO_FLAG" ]]; then
dokku_log_info2_quiet "${APP} builder-herokuish information"
if [[ "$APP" == "--global" ]]; then
dokku_log_info2_quiet "global builder-herokuish information"
else
dokku_log_info2_quiet "${APP} builder-herokuish information"
fi
for flag in "${flag_map[@]}"; do
key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')"
dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")"

View File

@@ -8,9 +8,13 @@ cmd-builder-lambda-report() {
declare desc="displays a builder-lambda report for one or more apps"
declare cmd="builder-lambda:report"
[[ "$1" == "$cmd" ]] && shift 1
declare APP="$1" INFO_FLAG="$2"
fn-report-parse-args "$@"
set -- "${REPORT_ARGS[@]}"
declare APP="${1:-}" INFO_FLAG="${2:-}"
if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
if [[ "$REPORT_IS_GLOBAL" == "true" ]]; then
APP="--global"
elif [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
INFO_FLAG="$APP"
APP=""
fi
@@ -19,29 +23,49 @@ cmd-builder-lambda-report() {
INFO_FLAG="true"
fi
if [[ -z "$APP" ]]; then
if [[ "$APP" == "--global" ]]; then
cmd-builder-lambda-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
elif [[ -z "$APP" ]]; then
for app in $(dokku_apps); do
cmd-builder-lambda-report-single "$app" "$INFO_FLAG" | tee || true
cmd-builder-lambda-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true
done
else
cmd-builder-lambda-report-single "$APP" "$INFO_FLAG"
cmd-builder-lambda-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
fi
}
cmd-builder-lambda-report-single() {
declare APP="$1" INFO_FLAG="$2"
declare APP="$1" INFO_FLAG="$2" FORMAT="${3:-stdout}"
if [[ "$INFO_FLAG" == "true" ]]; then
INFO_FLAG=""
fi
verify_app_name "$APP"
local flag_map=(
"--builder-lambda-computed-lambdayml-path: $(fn-builder-lambda-computed-lambdayml-path "$APP")"
"--builder-lambda-global-lambdayml-path: $(fn-builder-lambda-global-lambdayml-path "$APP")"
"--builder-lambda-lambdayml-path: $(fn-builder-lambda-lambdayml-path "$APP")"
)
local flag_map=()
if [[ "$APP" == "--global" ]]; then
flag_map=(
"--builder-lambda-global-lambdayml-path: $(fn-builder-lambda-global-lambdayml-path "$APP")"
)
else
verify_app_name "$APP"
flag_map=(
"--builder-lambda-computed-lambdayml-path: $(fn-builder-lambda-computed-lambdayml-path "$APP")"
"--builder-lambda-global-lambdayml-path: $(fn-builder-lambda-global-lambdayml-path "$APP")"
"--builder-lambda-lambdayml-path: $(fn-builder-lambda-lambdayml-path "$APP")"
)
fi
fn-report-validate-format "$FORMAT" "$INFO_FLAG"
if [[ "$FORMAT" == "json" ]]; then
fn-report-emit-json flag_map "builder-lambda"
return
fi
if [[ -z "$INFO_FLAG" ]]; then
dokku_log_info2_quiet "${APP} builder-lambda information"
if [[ "$APP" == "--global" ]]; then
dokku_log_info2_quiet "global builder-lambda information"
else
dokku_log_info2_quiet "${APP} builder-lambda information"
fi
for flag in "${flag_map[@]}"; do
key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')"
dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")"

View File

@@ -8,9 +8,13 @@ cmd-builder-nixpacks-report() {
declare desc="displays a builder-nixpacks report for one or more apps"
declare cmd="builder-nixpacks:report"
[[ "$1" == "$cmd" ]] && shift 1
declare APP="$1" INFO_FLAG="$2"
fn-report-parse-args "$@"
set -- "${REPORT_ARGS[@]}"
declare APP="${1:-}" INFO_FLAG="${2:-}"
if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
if [[ "$REPORT_IS_GLOBAL" == "true" ]]; then
APP="--global"
elif [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
INFO_FLAG="$APP"
APP=""
fi
@@ -19,29 +23,49 @@ cmd-builder-nixpacks-report() {
INFO_FLAG="true"
fi
if [[ -z "$APP" ]]; then
if [[ "$APP" == "--global" ]]; then
cmd-builder-nixpacks-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
elif [[ -z "$APP" ]]; then
for app in $(dokku_apps); do
cmd-builder-nixpacks-report-single "$app" "$INFO_FLAG" | tee || true
cmd-builder-nixpacks-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true
done
else
cmd-builder-nixpacks-report-single "$APP" "$INFO_FLAG"
cmd-builder-nixpacks-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
fi
}
cmd-builder-nixpacks-report-single() {
declare APP="$1" INFO_FLAG="$2"
declare APP="$1" INFO_FLAG="$2" FORMAT="${3:-stdout}"
if [[ "$INFO_FLAG" == "true" ]]; then
INFO_FLAG=""
fi
verify_app_name "$APP"
local flag_map=(
"--builder-nixpacks-computed-nixpackstoml-path: $(fn-builder-nixpacks-computed-nixpackstoml-path "$APP")"
"--builder-nixpacks-global-nixpackstoml-path: $(fn-builder-nixpacks-global-nixpackstoml-path "$APP")"
"--builder-nixpacks-nixpackstoml-path: $(fn-builder-nixpacks-nixpackstoml-path "$APP")"
)
local flag_map=()
if [[ "$APP" == "--global" ]]; then
flag_map=(
"--builder-nixpacks-global-nixpackstoml-path: $(fn-builder-nixpacks-global-nixpackstoml-path "$APP")"
)
else
verify_app_name "$APP"
flag_map=(
"--builder-nixpacks-computed-nixpackstoml-path: $(fn-builder-nixpacks-computed-nixpackstoml-path "$APP")"
"--builder-nixpacks-global-nixpackstoml-path: $(fn-builder-nixpacks-global-nixpackstoml-path "$APP")"
"--builder-nixpacks-nixpackstoml-path: $(fn-builder-nixpacks-nixpackstoml-path "$APP")"
)
fi
fn-report-validate-format "$FORMAT" "$INFO_FLAG"
if [[ "$FORMAT" == "json" ]]; then
fn-report-emit-json flag_map "builder-nixpacks"
return
fi
if [[ -z "$INFO_FLAG" ]]; then
dokku_log_info2_quiet "${APP} builder-nixpacks information"
if [[ "$APP" == "--global" ]]; then
dokku_log_info2_quiet "global builder-nixpacks information"
else
dokku_log_info2_quiet "${APP} builder-nixpacks information"
fi
for flag in "${flag_map[@]}"; do
key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')"
dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")"

View File

@@ -20,7 +20,7 @@ trigger-builder-pack-docker-args-process-deploy() {
if [[ "$inject_launcher" == "true" ]]; then
# without this, the command and arguments are passed as args to the default process type
output="--entrypoint launcher "
output=" --entrypoint launcher"
fi
echo -n "$STDIN$output"

View File

@@ -8,9 +8,13 @@ cmd-builder-pack-report() {
declare desc="displays a builder-pack report for one or more apps"
declare cmd="builder-pack:report"
[[ "$1" == "$cmd" ]] && shift 1
declare APP="$1" INFO_FLAG="$2"
fn-report-parse-args "$@"
set -- "${REPORT_ARGS[@]}"
declare APP="${1:-}" INFO_FLAG="${2:-}"
if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
if [[ "$REPORT_IS_GLOBAL" == "true" ]]; then
APP="--global"
elif [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
INFO_FLAG="$APP"
APP=""
fi
@@ -19,29 +23,49 @@ cmd-builder-pack-report() {
INFO_FLAG="true"
fi
if [[ -z "$APP" ]]; then
if [[ "$APP" == "--global" ]]; then
cmd-builder-pack-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
elif [[ -z "$APP" ]]; then
for app in $(dokku_apps); do
cmd-builder-pack-report-single "$app" "$INFO_FLAG" | tee || true
cmd-builder-pack-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true
done
else
cmd-builder-pack-report-single "$APP" "$INFO_FLAG"
cmd-builder-pack-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
fi
}
cmd-builder-pack-report-single() {
declare APP="$1" INFO_FLAG="$2"
declare APP="$1" INFO_FLAG="$2" FORMAT="${3:-stdout}"
if [[ "$INFO_FLAG" == "true" ]]; then
INFO_FLAG=""
fi
verify_app_name "$APP"
local flag_map=(
"--builder-pack-computed-projecttoml-path: $(fn-builder-pack-computed-projecttoml-path "$APP")"
"--builder-pack-global-projecttoml-path: $(fn-builder-pack-global-projecttoml-path "$APP")"
"--builder-pack-projecttoml-path: $(fn-builder-pack-projecttoml-path "$APP")"
)
local flag_map=()
if [[ "$APP" == "--global" ]]; then
flag_map=(
"--builder-pack-global-projecttoml-path: $(fn-builder-pack-global-projecttoml-path "$APP")"
)
else
verify_app_name "$APP"
flag_map=(
"--builder-pack-computed-projecttoml-path: $(fn-builder-pack-computed-projecttoml-path "$APP")"
"--builder-pack-global-projecttoml-path: $(fn-builder-pack-global-projecttoml-path "$APP")"
"--builder-pack-projecttoml-path: $(fn-builder-pack-projecttoml-path "$APP")"
)
fi
fn-report-validate-format "$FORMAT" "$INFO_FLAG"
if [[ "$FORMAT" == "json" ]]; then
fn-report-emit-json flag_map "builder-pack"
return
fi
if [[ -z "$INFO_FLAG" ]]; then
dokku_log_info2_quiet "${APP} builder-pack information"
if [[ "$APP" == "--global" ]]; then
dokku_log_info2_quiet "global builder-pack information"
else
dokku_log_info2_quiet "${APP} builder-pack information"
fi
for flag in "${flag_map[@]}"; do
key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')"
dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")"

View File

@@ -8,9 +8,13 @@ cmd-builder-railpack-report() {
declare desc="displays a builder-railpack report for one or more apps"
declare cmd="builder-railpack:report"
[[ "$1" == "$cmd" ]] && shift 1
declare APP="$1" INFO_FLAG="$2"
fn-report-parse-args "$@"
set -- "${REPORT_ARGS[@]}"
declare APP="${1:-}" INFO_FLAG="${2:-}"
if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
if [[ "$REPORT_IS_GLOBAL" == "true" ]]; then
APP="--global"
elif [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
INFO_FLAG="$APP"
APP=""
fi
@@ -19,29 +23,49 @@ cmd-builder-railpack-report() {
INFO_FLAG="true"
fi
if [[ -z "$APP" ]]; then
if [[ "$APP" == "--global" ]]; then
cmd-builder-railpack-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
elif [[ -z "$APP" ]]; then
for app in $(dokku_apps); do
cmd-builder-railpack-report-single "$app" "$INFO_FLAG" | tee || true
cmd-builder-railpack-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true
done
else
cmd-builder-railpack-report-single "$APP" "$INFO_FLAG"
cmd-builder-railpack-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
fi
}
cmd-builder-railpack-report-single() {
declare APP="$1" INFO_FLAG="$2"
declare APP="$1" INFO_FLAG="$2" FORMAT="${3:-stdout}"
if [[ "$INFO_FLAG" == "true" ]]; then
INFO_FLAG=""
fi
verify_app_name "$APP"
local flag_map=(
"--builder-railpack-computed-railpackjson-path: $(fn-builder-railpack-computed-railpackjson-path "$APP")"
"--builder-railpack-global-railpackjson-path: $(fn-builder-railpack-global-railpackjson-path "$APP")"
"--builder-railpack-railpackjson-path: $(fn-builder-railpack-railpackjson-path "$APP")"
)
local flag_map=()
if [[ "$APP" == "--global" ]]; then
flag_map=(
"--builder-railpack-global-railpackjson-path: $(fn-builder-railpack-global-railpackjson-path "$APP")"
)
else
verify_app_name "$APP"
flag_map=(
"--builder-railpack-computed-railpackjson-path: $(fn-builder-railpack-computed-railpackjson-path "$APP")"
"--builder-railpack-global-railpackjson-path: $(fn-builder-railpack-global-railpackjson-path "$APP")"
"--builder-railpack-railpackjson-path: $(fn-builder-railpack-railpackjson-path "$APP")"
)
fi
fn-report-validate-format "$FORMAT" "$INFO_FLAG"
if [[ "$FORMAT" == "json" ]]; then
fn-report-emit-json flag_map "builder-railpack"
return
fi
if [[ -z "$INFO_FLAG" ]]; then
dokku_log_info2_quiet "${APP} builder-railpack information"
if [[ "$APP" == "--global" ]]; then
dokku_log_info2_quiet "global builder-railpack information"
else
dokku_log_info2_quiet "${APP} builder-railpack information"
fi
for flag in "${flag_map[@]}"; do
key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')"
dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")"

View File

@@ -3,14 +3,16 @@ package builder
var (
// DefaultProperties is a map of all valid builder properties with corresponding default property values
DefaultProperties = map[string]string{
"selected": "",
"detected": "",
"build-dir": "",
"build-dir": "",
"detected": "",
"selected": "",
"skip-cleanup": "",
}
// GlobalProperties is a map of all valid global builder properties
GlobalProperties = map[string]bool{
"selected": true,
"build-dir": true,
"build-dir": true,
"selected": true,
"skip-cleanup": true,
}
)

View File

@@ -1,6 +1,6 @@
module github.com/dokku/dokku/plugins/builder
go 1.25.5
go 1.26.2
require (
github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000

View File

@@ -18,8 +18,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/melbahja/goph v1.5.0 h1:RQUBpLvfg3i7fjfG8rTcSWyMjVRfdhwrrfQhjYee4dQ=
github.com/melbahja/goph v1.5.0/go.mod h1:dDwo+44cmvfDLdiVpc6fJxexf5BA5yEDUeE5YgtuDO4=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc=
github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8=
github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I=
github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs=

View File

@@ -6,18 +6,28 @@ import (
// ReportSingleApp is an internal function that displays the builder report for one or more apps
func ReportSingleApp(appName string, format string, infoFlag string) error {
if err := common.VerifyAppName(appName); err != nil {
return err
if appName != "--global" {
if err := common.VerifyAppName(appName); err != nil {
return err
}
}
flags := map[string]common.ReportFunc{
"--builder-computed-selected": reportComputedSelected,
"--builder-global-selected": reportGlobalSelected,
"--builder-selected": reportSelected,
"--builder-detected": reportDetected,
"--builder-computed-build-dir": reportComputedBuildDir,
"--builder-global-build-dir": reportGlobalBuildDir,
"--builder-build-dir": reportBuildDir,
var flags map[string]common.ReportFunc
if appName == "--global" {
flags = map[string]common.ReportFunc{
"--builder-global-selected": reportGlobalSelected,
"--builder-global-build-dir": reportGlobalBuildDir,
}
} else {
flags = map[string]common.ReportFunc{
"--builder-computed-selected": reportComputedSelected,
"--builder-global-selected": reportGlobalSelected,
"--builder-selected": reportSelected,
"--builder-detected": reportDetected,
"--builder-computed-build-dir": reportComputedBuildDir,
"--builder-global-build-dir": reportGlobalBuildDir,
"--builder-build-dir": reportBuildDir,
}
}
flagKeys := []string{}

View File

@@ -21,11 +21,14 @@ func main() {
case "report":
args := flag.NewFlagSet("builder:report", flag.ExitOnError)
format := args.String("format", "stdout", "format: [ stdout | json ]")
osArgs, infoFlag, flagErr := common.ParseReportArgs("builder", os.Args[2:])
reportArgs, flagErr := common.ParseReportArgs("builder", os.Args[2:])
if flagErr == nil {
args.Parse(osArgs)
args.Parse(reportArgs.OSArgs)
appName := args.Arg(0)
err = builder.CommandReport(appName, *format, infoFlag)
if reportArgs.IsGlobal {
appName = "--global"
}
err = builder.CommandReport(appName, *format, reportArgs.InfoFlag)
}
case "set":
args := flag.NewFlagSet("builder:set", flag.ExitOnError)

View File

@@ -108,30 +108,21 @@ func TriggerInstall() error {
return fmt.Errorf("Unable to install the builder plugin: %s", err.Error())
}
apps, err := common.UnfilteredDokkuApps()
if err != nil && !errors.Is(err, common.NoAppsExist) {
return nil
if err := common.MigrateConfigToProperties("builder", []common.MigrateConfigEntry{
{
ConfigVar: "DOKKU_APP_TYPE",
Property: "detected",
},
{
ConfigVar: "DOKKU_SKIP_CLEANUP",
GlobalConfigVar: "DOKKU_SKIP_CLEANUP",
Property: "skip-cleanup",
},
}); err != nil {
return err
}
for _, appName := range apps {
if common.PropertyExists("builder", appName, "detected") {
continue
}
results, err := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "config-get",
Args: []string{appName, "DOKKU_APP_TYPE"},
})
if err != nil {
return err
}
if results.StdoutContents() != "" {
common.PropertyWrite("builder", appName, "detected", results.StdoutContents())
}
}
_, err = common.CallPlugnTrigger(common.PlugnTriggerInput{
_, err := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "install-builder-prune",
})

View File

@@ -0,0 +1,141 @@
package buildpacks
import (
"os"
"path/filepath"
"testing"
. "github.com/onsi/gomega"
)
func setupTestEnvironment(t *testing.T) string {
t.Helper()
tmpDir, err := os.MkdirTemp("", "buildpacks-test")
if err != nil {
t.Fatal(err)
}
os.Setenv("DOKKU_LIB_ROOT", tmpDir)
os.Setenv("PLUGIN_PATH", "/var/lib/dokku/plugins")
os.Setenv("PLUGIN_ENABLED_PATH", "/var/lib/dokku/plugins/enabled")
return tmpDir
}
func teardownTestEnvironment(tmpDir string) {
os.RemoveAll(tmpDir)
}
func TestValidBuildpackURL(t *testing.T) {
RegisterTestingT(t)
url, err := validBuildpackURL("")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Must specify a buildpack"))
url, err = validBuildpackURL("heroku/nodejs")
Expect(err).NotTo(HaveOccurred())
Expect(url).To(Equal("https://github.com/heroku/heroku-buildpack-nodejs.git"))
url, err = validBuildpackURL("heroku/python")
Expect(err).NotTo(HaveOccurred())
Expect(url).To(Equal("https://github.com/heroku/heroku-buildpack-python.git"))
url, err = validBuildpackURL("heroku-community/apt")
Expect(err).NotTo(HaveOccurred())
Expect(url).To(Equal("https://github.com/heroku/heroku-buildpack-apt.git"))
url, err = validBuildpackURL("https://github.com/heroku/heroku-buildpack-nodejs.git")
Expect(err).NotTo(HaveOccurred())
Expect(url).To(Equal("https://github.com/heroku/heroku-buildpack-nodejs.git"))
url, err = validBuildpackURL("https://github.com/heroku/heroku-buildpack-nodejs")
Expect(err).NotTo(HaveOccurred())
Expect(url).To(Equal("https://github.com/heroku/heroku-buildpack-nodejs"))
url, err = validBuildpackURL("git://github.com/heroku/heroku-buildpack-nodejs.git")
Expect(err).NotTo(HaveOccurred())
Expect(url).To(Equal("git://github.com/heroku/heroku-buildpack-nodejs.git"))
_, err = validBuildpackURL("nodejs")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Invalid buildpack specified"))
_, err = validBuildpackURL("/nodejs")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Invalid buildpack specified"))
}
func TestGetBuildpacksEmpty(t *testing.T) {
RegisterTestingT(t)
tmpDir := setupTestEnvironment(t)
defer teardownTestEnvironment(tmpDir)
buildpacks, err := getBuildpacks("test-app")
Expect(err).NotTo(HaveOccurred())
Expect(buildpacks).To(BeEmpty())
}
func TestGetBuildpacksFromProperties(t *testing.T) {
RegisterTestingT(t)
tmpDir := setupTestEnvironment(t)
defer teardownTestEnvironment(tmpDir)
propDir := filepath.Join(tmpDir, "config", "buildpacks", "test-app")
Expect(os.MkdirAll(propDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(propDir, "buildpacks"), []byte("https://github.com/heroku/heroku-buildpack-nodejs.git\nhttps://github.com/heroku/heroku-buildpack-python.git\n"), 0644)).To(Succeed())
buildpacks, err := getBuildpacks("test-app")
Expect(err).NotTo(HaveOccurred())
Expect(buildpacks).To(HaveLen(2))
Expect(buildpacks[0]).To(Equal("https://github.com/heroku/heroku-buildpack-nodejs.git"))
Expect(buildpacks[1]).To(Equal("https://github.com/heroku/heroku-buildpack-python.git"))
}
func TestGetBuildpacksFromAppJSON(t *testing.T) {
RegisterTestingT(t)
tmpDir := setupTestEnvironment(t)
defer teardownTestEnvironment(tmpDir)
appJSONDir := filepath.Join(tmpDir, "data", "app-json", "test-app")
Expect(os.MkdirAll(appJSONDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(appJSONDir, "app.json"), []byte(`{"buildpacks": [{"url": "https://github.com/heroku/heroku-buildpack-nodejs.git"}]}`), 0644)).To(Succeed())
buildpacks, err := getBuildpacks("test-app")
Expect(err).NotTo(HaveOccurred())
Expect(buildpacks).To(HaveLen(1))
Expect(buildpacks[0]).To(Equal("https://github.com/heroku/heroku-buildpack-nodejs.git"))
}
func TestGetBuildpacksPropertiesOverrideAppJSON(t *testing.T) {
RegisterTestingT(t)
tmpDir := setupTestEnvironment(t)
defer teardownTestEnvironment(tmpDir)
propDir := filepath.Join(tmpDir, "config", "buildpacks", "test-app")
Expect(os.MkdirAll(propDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(propDir, "buildpacks"), []byte("https://github.com/heroku/heroku-buildpack-ruby.git\n"), 0644)).To(Succeed())
appJSONDir := filepath.Join(tmpDir, "data", "app-json", "test-app")
Expect(os.MkdirAll(appJSONDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(appJSONDir, "app.json"), []byte(`{"buildpacks": [{"url": "https://github.com/heroku/heroku-buildpack-nodejs.git"}]}`), 0644)).To(Succeed())
buildpacks, err := getBuildpacks("test-app")
Expect(err).NotTo(HaveOccurred())
Expect(buildpacks).To(HaveLen(1))
Expect(buildpacks[0]).To(Equal("https://github.com/heroku/heroku-buildpack-ruby.git"))
}
func TestGetBuildpacksAppJSONEmptyArray(t *testing.T) {
RegisterTestingT(t)
tmpDir := setupTestEnvironment(t)
defer teardownTestEnvironment(tmpDir)
appJSONDir := filepath.Join(tmpDir, "data", "app-json", "test-app")
Expect(os.MkdirAll(appJSONDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(appJSONDir, "app.json"), []byte(`{"buildpacks": []}`), 0644)).To(Succeed())
buildpacks, err := getBuildpacks("test-app")
Expect(err).NotTo(HaveOccurred())
Expect(buildpacks).To(BeEmpty())
}

View File

@@ -8,9 +8,50 @@ import (
"regexp"
"strings"
appjson "github.com/dokku/dokku/plugins/app-json"
"github.com/dokku/dokku/plugins/common"
)
// getBuildpacks returns the buildpacks for a given app
// the order of application is:
// 1. .buildpacks: if it exists, use it
// 2. app.json: if it exists and has buildpacks, use them
// 3. buildpack properties: if they exist, use them
// 4. buildpack detection
func getBuildpacks(appName string) ([]string, error) {
buildpacks, err := common.PropertyListGet("buildpacks", appName, "buildpacks")
if err != nil {
return buildpacks, err
}
if len(buildpacks) > 0 {
return buildpacks, nil
}
results, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "git-get-property",
Args: []string{appName, "source-image"},
})
if results.StdoutContents() != "" {
return buildpacks, nil
}
appJSON, err := appjson.GetAppJSON(appName)
if err != nil {
return buildpacks, err
}
if len(appJSON.Buildpacks) == 0 {
return buildpacks, nil
}
for _, b := range appJSON.Buildpacks {
buildpacks = append(buildpacks, b.URL)
}
return buildpacks, nil
}
func rewriteBuildpacksFile(sourceWorkDir string) error {
buildpacksPath := filepath.Join(sourceWorkDir, ".buildpacks")
if !common.FileExists(buildpacksPath) {
@@ -27,6 +68,10 @@ func rewriteBuildpacksFile(sourceWorkDir string) error {
continue
}
if strings.HasPrefix(buildpack, "#") {
continue
}
buildpack, err = validBuildpackURL(buildpack)
if err != nil {
return fmt.Errorf("Unable to parse .buildpacks file, line %d: %s", i, err)

View File

@@ -1,17 +1,21 @@
module github.com/dokku/dokku/plugins/buildpacks
go 1.25.5
go 1.26.2
require (
github.com/dokku/dokku/plugins/app-json v0.0.0-00010101000000-000000000000
github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000
github.com/onsi/gomega v1.40.0
github.com/spf13/pflag v1.0.10
)
require (
github.com/alexellis/go-execute/v2 v2.2.1 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -21,9 +25,16 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/sftp v1.13.10 // indirect
github.com/ryanuber/columnize v2.1.2+incompatible // indirect
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
k8s.io/utils v0.0.0-20240102154912-e7106e64919e // indirect
)
replace github.com/dokku/dokku/plugins/app-json => ../app-json
replace github.com/dokku/dokku/plugins/common => ../common

View File

@@ -10,16 +10,22 @@ github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/U
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/melbahja/goph v1.5.0 h1:RQUBpLvfg3i7fjfG8rTcSWyMjVRfdhwrrfQhjYee4dQ=
github.com/melbahja/goph v1.5.0/go.mod h1:dDwo+44cmvfDLdiVpc6fJxexf5BA5yEDUeE5YgtuDO4=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc=
github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8=
github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I=
github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs=
@@ -30,12 +36,16 @@ github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk=
github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a h1:a6TNDN9CgG+cYjaeN8l2mc4kSz2iMiCDQxPEyltUV/I=
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
@@ -51,5 +61,10 @@ golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/utils v0.0.0-20240102154912-e7106e64919e h1:eQ/4ljkx21sObifjzXwlPKpdGLrCfRziVtos3ofG/sQ=
k8s.io/utils v0.0.0-20240102154912-e7106e64919e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=

View File

@@ -9,15 +9,24 @@ import (
// ReportSingleApp is an internal function that displays the buildpacks report for one or more apps
func ReportSingleApp(appName string, format string, infoFlag string) error {
if err := common.VerifyAppName(appName); err != nil {
return err
if appName != "--global" {
if err := common.VerifyAppName(appName); err != nil {
return err
}
}
flags := map[string]common.ReportFunc{
"--buildpacks-computed-stack": reportComputedStack,
"--buildpacks-global-stack": reportGlobalStack,
"--buildpacks-list": reportList,
"--buildpacks-stack": reportStack,
var flags map[string]common.ReportFunc
if appName == "--global" {
flags = map[string]common.ReportFunc{
"--buildpacks-global-stack": reportGlobalStack,
}
} else {
flags = map[string]common.ReportFunc{
"--buildpacks-computed-stack": reportComputedStack,
"--buildpacks-global-stack": reportGlobalStack,
"--buildpacks-list": reportList,
"--buildpacks-stack": reportStack,
}
}
flagKeys := []string{}

View File

@@ -54,11 +54,14 @@ func main() {
case "report":
args := flag.NewFlagSet("buildpacks:report", flag.ExitOnError)
format := args.String("format", "stdout", "format: [ stdout | json ]")
osArgs, infoFlag, flagErr := common.ParseReportArgs("buildpacks", os.Args[2:])
reportArgs, flagErr := common.ParseReportArgs("buildpacks", os.Args[2:])
if flagErr == nil {
args.Parse(osArgs)
args.Parse(reportArgs.OSArgs)
appName := args.Arg(0)
err = buildpacks.CommandReport(appName, *format, infoFlag)
if reportArgs.IsGlobal {
appName = "--global"
}
err = buildpacks.CommandReport(appName, *format, reportArgs.InfoFlag)
}
case "set":
args := flag.NewFlagSet("buildpacks:set", flag.ExitOnError)

View File

@@ -74,7 +74,7 @@ func TriggerPostDelete(appName string) error {
// TriggerPostExtract writes a .buildpacks file into the app
func TriggerPostExtract(appName string, sourceWorkDir string) error {
buildpacks, err := common.PropertyListGet("buildpacks", appName, "buildpacks")
buildpacks, err := getBuildpacks(appName)
if err != nil {
return nil
}
@@ -91,13 +91,20 @@ func TriggerPostExtract(appName string, sourceWorkDir string) error {
w := bufio.NewWriter(file)
for _, buildpack := range buildpacks {
buildpack, err = validBuildpackURL(buildpack)
if err != nil {
return err
}
fmt.Fprintln(w, buildpack)
}
if err = w.Flush(); err != nil {
return fmt.Errorf("Error writing .buildpacks file: %s", err.Error())
}
file.Chmod(0600)
if err = file.Chmod(0600); err != nil {
return fmt.Errorf("Error setting .buildpacks file permissions: %s", err.Error())
}
return nil
}

10
plugins/builds/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
/commands
/subcommands/*
/triggers/*
/triggers
/builds-generate-id
/builds-record-finalize
/builds-record-start
/install
/post-app-rename-setup
/post-delete

6
plugins/builds/Makefile Normal file
View File

@@ -0,0 +1,6 @@
SUBCOMMANDS = subcommands/cancel subcommands/info subcommands/list subcommands/output subcommands/prune subcommands/report subcommands/set
TRIGGERS = triggers/builds-generate-id triggers/builds-record-finalize triggers/builds-record-start triggers/install triggers/post-app-rename-setup triggers/post-delete
BUILD = commands subcommands triggers
PLUGIN_NAME = builds
include ../../common.mk

446
plugins/builds/builds.go Normal file
View File

@@ -0,0 +1,446 @@
package builds
import (
"crypto/rand"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
"github.com/dokku/dokku/plugins/common"
"github.com/multiformats/go-base36"
)
const (
DefaultRetention = 20
MinimumRetention = 1
)
var (
DefaultProperties = map[string]string{
"retention": strconv.Itoa(DefaultRetention),
}
GlobalProperties = map[string]bool{
"retention": true,
}
)
// BuildKind represents whether a record is a full build or a deploy-only run.
type BuildKind string
const (
BuildKindBuild BuildKind = "build"
BuildKindDeploy BuildKind = "deploy"
)
// Valid reports whether the kind is one of the recognized values.
func (k BuildKind) Valid() bool {
return k == BuildKindBuild || k == BuildKindDeploy
}
// BuildStatus is the on-disk lifecycle status of a build record.
// BuildStatusAbandoned is intentionally NOT considered Valid - it is a
// display-only value computed at read time and never persisted.
type BuildStatus string
const (
BuildStatusRunning BuildStatus = "running"
BuildStatusSucceeded BuildStatus = "succeeded"
BuildStatusFailed BuildStatus = "failed"
BuildStatusCanceled BuildStatus = "canceled"
BuildStatusAbandoned BuildStatus = "abandoned"
)
// Valid reports whether the status is a persistable value.
func (s BuildStatus) Valid() bool {
switch s {
case BuildStatusRunning, BuildStatusSucceeded, BuildStatusFailed, BuildStatusCanceled:
return true
}
return false
}
// IsTerminal reports whether the status represents a finalized build.
func (s BuildStatus) IsTerminal() bool {
switch s {
case BuildStatusSucceeded, BuildStatusFailed, BuildStatusCanceled:
return true
}
return false
}
// BuildSource identifies the user-typed command (or internal trigger) that
// originated a build.
type BuildSource string
const (
BuildSourceGitHook BuildSource = "git-hook"
BuildSourcePsRebuild BuildSource = "ps:rebuild"
BuildSourcePsRestart BuildSource = "ps:restart"
BuildSourcePsStart BuildSource = "ps:start"
BuildSourceDeploy BuildSource = "deploy"
BuildSourceConfigRedeploy BuildSource = "config-redeploy"
BuildSourceGitSync BuildSource = "git:sync"
BuildSourceGitFromArchive BuildSource = "git:from-archive"
BuildSourceGitFromImage BuildSource = "git:from-image"
BuildSourceGitLoadImage BuildSource = "git:load-image"
BuildSourceUnknown BuildSource = "unknown"
)
var allBuildSources = []BuildSource{
BuildSourceGitHook,
BuildSourcePsRebuild,
BuildSourcePsRestart,
BuildSourcePsStart,
BuildSourceDeploy,
BuildSourceConfigRedeploy,
BuildSourceGitSync,
BuildSourceGitFromArchive,
BuildSourceGitFromImage,
BuildSourceGitLoadImage,
BuildSourceUnknown,
}
// Valid reports whether the source is one of the recognized values.
func (s BuildSource) Valid() bool {
for _, candidate := range allBuildSources {
if s == candidate {
return true
}
}
return false
}
// DefaultKind maps a source to the kind of work it performs. Sources that
// produce a new image map to BuildKindBuild; sources that re-deploy an existing
// image map to BuildKindDeploy.
func (s BuildSource) DefaultKind() BuildKind {
switch s {
case BuildSourceGitHook,
BuildSourcePsRebuild,
BuildSourceGitSync,
BuildSourceGitFromArchive,
BuildSourceGitFromImage,
BuildSourceGitLoadImage:
return BuildKindBuild
}
return BuildKindDeploy
}
// Build is the persisted record for a single build/deploy.
type Build struct {
ID string `json:"id"`
App string `json:"app"`
Kind BuildKind `json:"kind"`
PID int `json:"pid"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Status BuildStatus `json:"status"`
Source BuildSource `json:"source"`
ExitCode *int `json:"exit_code,omitempty"`
}
// DisplayStatus returns the status the operator should see, computing
// BuildStatusAbandoned for running records whose PID is no longer alive.
func (b Build) DisplayStatus() BuildStatus {
if b.Status == BuildStatusRunning && !CheckPIDAlive(b.PID) {
return BuildStatusAbandoned
}
return b.Status
}
// Duration returns the elapsed time between start and finish (or now, for
// in-flight builds).
func (b Build) Duration() time.Duration {
end := time.Now().UTC()
if b.FinishedAt != nil {
end = *b.FinishedAt
}
if end.Before(b.StartedAt) {
return 0
}
return end.Sub(b.StartedAt).Round(time.Second)
}
// LogPath returns the on-disk path of the per-build log file.
func (b Build) LogPath() string {
return filepath.Join(common.GetAppDataDirectory("builds", b.App), b.ID+".log")
}
// GenerateBuildID produces a sortable base36 ULID-style id.
//
// Format: <8 base36 chars of ms timestamp><6 base36 chars of randomness>.
func GenerateBuildID() string {
const tsLen = 8
const randLen = 6
ms := uint64(time.Now().UTC().UnixNano() / int64(time.Millisecond))
tsBytes := make([]byte, 8)
for i := 7; i >= 0; i-- {
tsBytes[i] = byte(ms & 0xff)
ms >>= 8
}
tsEncoded := base36.EncodeToStringLc(tsBytes)
if len(tsEncoded) > tsLen {
tsEncoded = tsEncoded[len(tsEncoded)-tsLen:]
}
for len(tsEncoded) < tsLen {
tsEncoded = "0" + tsEncoded
}
randBytes := make([]byte, 6)
if _, err := rand.Read(randBytes); err != nil {
// fall back to time-derived noise; uniqueness is degraded but never zero
now := time.Now().UTC().UnixNano()
for i := 0; i < len(randBytes); i++ {
randBytes[i] = byte(now >> (8 * i))
}
}
randEncoded := base36.EncodeToStringLc(randBytes)
if len(randEncoded) > randLen {
randEncoded = randEncoded[len(randEncoded)-randLen:]
}
for len(randEncoded) < randLen {
randEncoded = "0" + randEncoded
}
return tsEncoded + randEncoded
}
// AppDataDir returns the directory that stores build records and logs for app.
func AppDataDir(appName string) string {
return common.GetAppDataDirectory("builds", appName)
}
// RecordPath returns the on-disk path to the JSON record for a given build.
func RecordPath(appName, buildID string) string {
return filepath.Join(AppDataDir(appName), buildID+".json")
}
// LogPathFor returns the on-disk path to the log file for a given build.
func LogPathFor(appName, buildID string) string {
return filepath.Join(AppDataDir(appName), buildID+".log")
}
// WriteBuild writes a Build record to disk. It creates the data dir as needed.
func WriteBuild(b Build) error {
if err := os.MkdirAll(AppDataDir(b.App), 0755); err != nil {
return fmt.Errorf("create builds data dir: %w", err)
}
body, err := json.MarshalIndent(b, "", " ")
if err != nil {
return fmt.Errorf("marshal build record: %w", err)
}
tmp := RecordPath(b.App, b.ID) + ".tmp"
if err := os.WriteFile(tmp, body, 0644); err != nil {
return fmt.Errorf("write temp build record: %w", err)
}
if err := os.Rename(tmp, RecordPath(b.App, b.ID)); err != nil {
return fmt.Errorf("rename build record into place: %w", err)
}
return nil
}
// ReadBuild loads the Build record from disk. Returns os.ErrNotExist when the
// record does not exist.
func ReadBuild(appName, buildID string) (Build, error) {
var b Build
body, err := os.ReadFile(RecordPath(appName, buildID))
if err != nil {
return b, err
}
if err := json.Unmarshal(body, &b); err != nil {
return b, fmt.Errorf("parse build record %s: %w", buildID, err)
}
return b, nil
}
// FetchBuilds returns every recorded build for an app, sorted newest-first by
// StartedAt. It is read-only; callers that want to reap abandoned records
// should call ReapAbandonedBuilds explicitly via PruneAppBuilds or the
// builds:prune subcommand.
func FetchBuilds(appName string) ([]Build, error) {
dir := AppDataDir(appName)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("list builds dir for %s: %w", appName, err)
}
builds := make([]Build, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".json") {
continue
}
buildID := strings.TrimSuffix(name, ".json")
b, err := ReadBuild(appName, buildID)
if err != nil {
common.LogWarn(fmt.Sprintf("Skipping unreadable build record %s/%s: %s", appName, name, err))
continue
}
builds = append(builds, b)
}
sort.Slice(builds, func(i, j int) bool {
return builds[i].StartedAt.After(builds[j].StartedAt)
})
return builds, nil
}
// FetchRunningBuilds returns the in-flight (display-status running) builds for
// an app. Records whose on-disk status is running but PID is dead are excluded.
func FetchRunningBuilds(appName string) ([]Build, error) {
all, err := FetchBuilds(appName)
if err != nil {
return nil, err
}
running := make([]Build, 0, len(all))
for _, b := range all {
if b.Status == BuildStatusRunning && CheckPIDAlive(b.PID) {
running = append(running, b)
}
}
return running, nil
}
// CheckPIDAlive reports whether a process with the given pid currently exists.
// On unix, kill(pid, 0) returns nil if the process exists (or ESRCH if it does
// not). It treats invalid pids (<= 0) as dead.
func CheckPIDAlive(pid int) bool {
if pid <= 0 {
return false
}
if err := syscall.Kill(pid, 0); err != nil {
if err == syscall.ESRCH {
return false
}
// EPERM means the process exists but we don't have permission to
// signal it - still alive from the operator's perspective.
if err == syscall.EPERM {
return true
}
return false
}
return true
}
// ResolveRetention returns the retention count for an app, cascading from
// per-app override → global override → DefaultRetention. Invalid (non-int or
// non-positive) property values fall through to the next level with a warning.
func ResolveRetention(appName string) int {
if appName != "" && appName != "--global" {
if n, ok := parseRetention(common.PropertyGet("builds", appName, "retention"), appName); ok {
return n
}
}
if n, ok := parseRetention(common.PropertyGet("builds", "--global", "retention"), "--global"); ok {
return n
}
return DefaultRetention
}
func parseRetention(raw string, scope string) (int, bool) {
if raw == "" {
return 0, false
}
n, err := strconv.Atoi(raw)
if err != nil {
common.LogWarn(fmt.Sprintf("Ignoring non-integer builds retention %q for %s", raw, scope))
return 0, false
}
if n < MinimumRetention {
common.LogWarn(fmt.Sprintf("Ignoring builds retention %d below minimum %d for %s", n, MinimumRetention, scope))
return 0, false
}
return n, true
}
// ReapAbandonedBuilds finalizes any record whose on-disk status is running but
// whose PID is no longer alive. Records are written with status=failed,
// exit_code=-1, finished_at=now. Returns the number of records reaped.
func ReapAbandonedBuilds(appName string) (int, error) {
builds, err := FetchBuilds(appName)
if err != nil {
return 0, err
}
now := time.Now().UTC()
exitCode := -1
reaped := 0
for _, b := range builds {
if b.Status != BuildStatusRunning {
continue
}
if CheckPIDAlive(b.PID) {
continue
}
b.Status = BuildStatusFailed
b.FinishedAt = &now
b.ExitCode = &exitCode
if err := WriteBuild(b); err != nil {
common.LogWarn(fmt.Sprintf("Could not reap abandoned build %s/%s: %s", appName, b.ID, err))
continue
}
reaped++
}
return reaped, nil
}
// PruneAppBuilds reaps abandoned records, then prunes finalized records beyond
// the retention cap. Live in-flight builds (status=running with alive PID) are
// always preserved.
func PruneAppBuilds(appName string) error {
if _, err := ReapAbandonedBuilds(appName); err != nil {
return err
}
builds, err := FetchBuilds(appName)
if err != nil {
return err
}
retention := ResolveRetention(appName)
finalized := make([]Build, 0, len(builds))
for _, b := range builds {
if b.Status == BuildStatusRunning && CheckPIDAlive(b.PID) {
continue
}
finalized = append(finalized, b)
}
if len(finalized) <= retention {
return nil
}
for _, b := range finalized[retention:] {
removeBuildFiles(appName, b.ID)
}
return nil
}
func removeBuildFiles(appName, buildID string) {
if err := os.Remove(RecordPath(appName, buildID)); err != nil && !os.IsNotExist(err) {
common.LogWarn(fmt.Sprintf("Could not remove build record %s/%s: %s", appName, buildID, err))
}
if err := os.Remove(LogPathFor(appName, buildID)); err != nil && !os.IsNotExist(err) {
common.LogWarn(fmt.Sprintf("Could not remove build log %s/%s.log: %s", appName, buildID, err))
}
}

View File

@@ -0,0 +1,611 @@
package builds
import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
func setupTestRoot(t *testing.T) string {
t.Helper()
tmpDir, err := os.MkdirTemp("", "builds-test")
if err != nil {
t.Fatalf("mkdirtemp: %v", err)
}
t.Setenv("DOKKU_LIB_ROOT", tmpDir)
t.Setenv("PLUGIN_PATH", filepath.Join(tmpDir, "plugins"))
t.Setenv("PLUGIN_ENABLED_PATH", filepath.Join(tmpDir, "plugins", "enabled"))
t.Cleanup(func() { os.RemoveAll(tmpDir) })
return tmpDir
}
func TestBuildKindValid(t *testing.T) {
cases := map[BuildKind]bool{
BuildKindBuild: true,
BuildKindDeploy: true,
BuildKind(""): false,
BuildKind("nope"): false,
}
for k, want := range cases {
if got := k.Valid(); got != want {
t.Errorf("BuildKind(%q).Valid() = %v, want %v", string(k), got, want)
}
}
}
func TestBuildStatusValid(t *testing.T) {
cases := map[BuildStatus]bool{
BuildStatusRunning: true,
BuildStatusSucceeded: true,
BuildStatusFailed: true,
BuildStatusCanceled: true,
BuildStatusAbandoned: false, // intentionally not persistable
BuildStatus(""): false,
BuildStatus("nope"): false,
}
for s, want := range cases {
if got := s.Valid(); got != want {
t.Errorf("BuildStatus(%q).Valid() = %v, want %v", string(s), got, want)
}
}
}
func TestBuildStatusIsTerminal(t *testing.T) {
cases := map[BuildStatus]bool{
BuildStatusRunning: false,
BuildStatusSucceeded: true,
BuildStatusFailed: true,
BuildStatusCanceled: true,
BuildStatusAbandoned: false,
}
for s, want := range cases {
if got := s.IsTerminal(); got != want {
t.Errorf("BuildStatus(%q).IsTerminal() = %v, want %v", string(s), got, want)
}
}
}
func TestBuildSourceValid(t *testing.T) {
for _, src := range allBuildSources {
if !src.Valid() {
t.Errorf("expected %q to be valid", string(src))
}
}
for _, bad := range []BuildSource{"", "garbage", "git:", "ps:something"} {
if bad.Valid() {
t.Errorf("expected %q to be invalid", string(bad))
}
}
}
func TestBuildSourceDefaultKind(t *testing.T) {
build := []BuildSource{
BuildSourceGitHook, BuildSourcePsRebuild, BuildSourceGitSync,
BuildSourceGitFromArchive, BuildSourceGitFromImage, BuildSourceGitLoadImage,
}
deploy := []BuildSource{
BuildSourcePsRestart, BuildSourcePsStart, BuildSourceDeploy,
BuildSourceConfigRedeploy, BuildSourceUnknown,
}
for _, s := range build {
if got := s.DefaultKind(); got != BuildKindBuild {
t.Errorf("%q.DefaultKind() = %v, want build", string(s), got)
}
}
for _, s := range deploy {
if got := s.DefaultKind(); got != BuildKindDeploy {
t.Errorf("%q.DefaultKind() = %v, want deploy", string(s), got)
}
}
}
func TestGenerateBuildIDUniquenessAndShape(t *testing.T) {
const samples = 200
seen := make(map[string]struct{}, samples)
for i := 0; i < samples; i++ {
id := GenerateBuildID()
if len(id) != 14 {
t.Fatalf("expected len 14, got %d for %q", len(id), id)
}
for _, r := range id {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'z')) {
t.Fatalf("non-base36 char %q in %q", r, id)
}
}
if _, dup := seen[id]; dup {
t.Fatalf("duplicate id %q in %d samples", id, samples)
}
seen[id] = struct{}{}
}
}
func TestWriteAndReadBuildRoundTrip(t *testing.T) {
setupTestRoot(t)
now := time.Now().UTC().Truncate(time.Second)
exit := 0
finished := now.Add(time.Minute)
want := Build{
ID: GenerateBuildID(),
App: "myapp",
Kind: BuildKindBuild,
PID: 12345,
StartedAt: now,
FinishedAt: &finished,
Status: BuildStatusSucceeded,
Source: BuildSourceGitHook,
ExitCode: &exit,
}
if err := WriteBuild(want); err != nil {
t.Fatalf("WriteBuild: %v", err)
}
got, err := ReadBuild(want.App, want.ID)
if err != nil {
t.Fatalf("ReadBuild: %v", err)
}
if got.ID != want.ID || got.Status != want.Status || got.Kind != want.Kind || got.Source != want.Source {
t.Errorf("round-trip mismatch: got %+v want %+v", got, want)
}
if got.ExitCode == nil || *got.ExitCode != 0 {
t.Errorf("exit code mismatch: got %v want 0", got.ExitCode)
}
if got.FinishedAt == nil || !got.FinishedAt.Equal(finished) {
t.Errorf("finished_at mismatch: got %v want %v", got.FinishedAt, finished)
}
}
func TestFetchBuildsSortsNewestFirst(t *testing.T) {
setupTestRoot(t)
base := time.Now().UTC().Truncate(time.Second)
for i, offset := range []time.Duration{2 * time.Hour, 1 * time.Hour, 0} {
b := Build{
ID: "build-" + strconv.Itoa(i),
App: "myapp",
Kind: BuildKindBuild,
PID: 100 + i,
StartedAt: base.Add(-offset),
Status: BuildStatusSucceeded,
Source: BuildSourceGitHook,
}
if err := WriteBuild(b); err != nil {
t.Fatalf("write %d: %v", i, err)
}
}
builds, err := FetchBuilds("myapp")
if err != nil {
t.Fatalf("fetch: %v", err)
}
if len(builds) != 3 {
t.Fatalf("got %d builds, want 3", len(builds))
}
for i := 0; i < len(builds)-1; i++ {
if !builds[i].StartedAt.After(builds[i+1].StartedAt) {
t.Errorf("not sorted descending at %d: %v vs %v", i, builds[i].StartedAt, builds[i+1].StartedAt)
}
}
}
func TestFetchBuildsMissingDir(t *testing.T) {
setupTestRoot(t)
builds, err := FetchBuilds("never-deployed")
if err != nil {
t.Fatalf("expected nil err, got %v", err)
}
if len(builds) != 0 {
t.Errorf("expected empty slice, got %d entries", len(builds))
}
}
func TestCheckPIDAlive(t *testing.T) {
if CheckPIDAlive(os.Getpid()) != true {
t.Error("expected current pid to be alive")
}
if CheckPIDAlive(0) {
t.Error("expected pid 0 to be dead")
}
if CheckPIDAlive(-1) {
t.Error("expected pid -1 to be dead")
}
// PID 1 is init/launchd; it always exists. We don't have permission to
// signal it from a non-root user, but CheckPIDAlive should treat EPERM
// as alive.
if !CheckPIDAlive(1) {
t.Error("expected pid 1 (init) to be reported alive")
}
}
func TestReapAbandonedBuilds(t *testing.T) {
setupTestRoot(t)
app := "reapapp"
// 1: alive (this test process), should NOT be reaped.
alive := Build{
ID: "alive",
App: app,
Kind: BuildKindBuild,
PID: os.Getpid(),
StartedAt: time.Now().UTC(),
Status: BuildStatusRunning,
Source: BuildSourceGitHook,
}
// 2: dead pid, status=running, should be reaped.
dead := Build{
ID: "dead",
App: app,
Kind: BuildKindBuild,
PID: 1, // signal-eperm but we treat as alive; use a clearly-dead one
StartedAt: time.Now().UTC(),
Status: BuildStatusRunning,
Source: BuildSourceGitHook,
}
// pick a pid that shouldn't exist
dead.PID = pickDeadPID(t)
// 3: terminal, should not be touched.
finished := time.Now().UTC()
exit := 0
terminal := Build{
ID: "terminal",
App: app,
Kind: BuildKindBuild,
PID: 9999,
StartedAt: time.Now().UTC().Add(-time.Hour),
FinishedAt: &finished,
Status: BuildStatusSucceeded,
Source: BuildSourceGitHook,
ExitCode: &exit,
}
for _, b := range []Build{alive, dead, terminal} {
if err := WriteBuild(b); err != nil {
t.Fatalf("write %s: %v", b.ID, err)
}
}
reaped, err := ReapAbandonedBuilds(app)
if err != nil {
t.Fatalf("reap: %v", err)
}
if reaped != 1 {
t.Errorf("expected 1 reap, got %d", reaped)
}
got, err := ReadBuild(app, "dead")
if err != nil {
t.Fatalf("read dead: %v", err)
}
if got.Status != BuildStatusFailed {
t.Errorf("dead.Status = %v, want failed", got.Status)
}
if got.ExitCode == nil || *got.ExitCode != -1 {
t.Errorf("dead.ExitCode = %v, want -1", got.ExitCode)
}
stillAlive, err := ReadBuild(app, "alive")
if err != nil {
t.Fatalf("read alive: %v", err)
}
if stillAlive.Status != BuildStatusRunning {
t.Errorf("alive.Status = %v, want running", stillAlive.Status)
}
stillTerminal, err := ReadBuild(app, "terminal")
if err != nil {
t.Fatalf("read terminal: %v", err)
}
if stillTerminal.Status != BuildStatusSucceeded {
t.Errorf("terminal.Status = %v, want succeeded", stillTerminal.Status)
}
}
func pickDeadPID(t *testing.T) int {
t.Helper()
for candidate := 99999; candidate < 200000; candidate++ {
if !CheckPIDAlive(candidate) {
return candidate
}
}
t.Fatal("could not find a dead pid candidate")
return 0
}
func TestPruneAppBuildsRespectsRetentionAndProtectsLive(t *testing.T) {
setupTestRoot(t)
app := "pruneapp"
now := time.Now().UTC()
// 1 live in-flight build (status=running, alive PID).
live := Build{
ID: "live",
App: app,
Kind: BuildKindBuild,
PID: os.Getpid(),
StartedAt: now,
Status: BuildStatusRunning,
Source: BuildSourceGitHook,
}
if err := WriteBuild(live); err != nil {
t.Fatalf("write live: %v", err)
}
// Force retention down to 2 via property.
if err := os.MkdirAll(filepath.Join(os.Getenv("DOKKU_LIB_ROOT"), "config", "builds", app), 0755); err != nil {
t.Fatalf("setup property dir: %v", err)
}
if err := os.WriteFile(filepath.Join(os.Getenv("DOKKU_LIB_ROOT"), "config", "builds", app, "retention"), []byte("2"), 0644); err != nil {
t.Fatalf("write retention property: %v", err)
}
// 5 finalized builds with descending start times.
exit := 0
for i := 0; i < 5; i++ {
finished := now.Add(-time.Duration(i+1) * time.Minute)
started := finished.Add(-time.Second)
b := Build{
ID: "old-" + strconv.Itoa(i),
App: app,
Kind: BuildKindBuild,
PID: 9000 + i,
StartedAt: started,
FinishedAt: &finished,
Status: BuildStatusSucceeded,
Source: BuildSourceGitHook,
ExitCode: &exit,
}
if err := WriteBuild(b); err != nil {
t.Fatalf("write old-%d: %v", i, err)
}
// Touch a log file so the prune deletes that too.
_ = os.WriteFile(b.LogPath(), []byte("log"), 0644)
}
if err := PruneAppBuilds(app); err != nil {
t.Fatalf("prune: %v", err)
}
remaining, err := FetchBuilds(app)
if err != nil {
t.Fatalf("fetch after prune: %v", err)
}
// live + 2 retained = 3
if len(remaining) != 3 {
t.Fatalf("expected 3 records after prune, got %d (%v)", len(remaining), remainingIDs(remaining))
}
// live record must survive
foundLive := false
for _, r := range remaining {
if r.ID == "live" {
foundLive = true
if r.Status != BuildStatusRunning {
t.Errorf("live.Status changed to %v", r.Status)
}
}
}
if !foundLive {
t.Errorf("live record was pruned (remaining=%v)", remainingIDs(remaining))
}
// the two newest old-* records (old-0 and old-1) should remain
expectedKept := map[string]bool{"old-0": true, "old-1": true}
for _, r := range remaining {
if r.ID == "live" {
continue
}
if !expectedKept[r.ID] {
t.Errorf("unexpected surviving record %s", r.ID)
}
}
// log files for pruned old-* should be gone; for kept ones, still present.
for i := 2; i < 5; i++ {
path := filepath.Join(AppDataDir(app), "old-"+strconv.Itoa(i)+".log")
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("expected log %s removed, got err=%v", path, err)
}
}
}
func remainingIDs(builds []Build) []string {
ids := make([]string, 0, len(builds))
for _, b := range builds {
ids = append(ids, b.ID)
}
return ids
}
func TestResolveRetentionCascade(t *testing.T) {
tmp := setupTestRoot(t)
app := "retapp"
// no overrides: default
if got := ResolveRetention(app); got != DefaultRetention {
t.Errorf("no overrides: got %d want %d", got, DefaultRetention)
}
// global override
globalDir := filepath.Join(tmp, "config", "builds", "--global")
if err := os.MkdirAll(globalDir, 0755); err != nil {
t.Fatalf("mkdir global: %v", err)
}
if err := os.WriteFile(filepath.Join(globalDir, "retention"), []byte("7"), 0644); err != nil {
t.Fatalf("write global retention: %v", err)
}
if got := ResolveRetention(app); got != 7 {
t.Errorf("global override: got %d want 7", got)
}
// per-app override beats global
appDir := filepath.Join(tmp, "config", "builds", app)
if err := os.MkdirAll(appDir, 0755); err != nil {
t.Fatalf("mkdir app: %v", err)
}
if err := os.WriteFile(filepath.Join(appDir, "retention"), []byte("3"), 0644); err != nil {
t.Fatalf("write app retention: %v", err)
}
if got := ResolveRetention(app); got != 3 {
t.Errorf("per-app override: got %d want 3", got)
}
// invalid app value falls back to global
if err := os.WriteFile(filepath.Join(appDir, "retention"), []byte("not-a-number"), 0644); err != nil {
t.Fatalf("write invalid: %v", err)
}
if got := ResolveRetention(app); got != 7 {
t.Errorf("invalid app value: got %d want 7 (global)", got)
}
// negative value also falls through
if err := os.WriteFile(filepath.Join(appDir, "retention"), []byte("0"), 0644); err != nil {
t.Fatalf("write zero: %v", err)
}
if got := ResolveRetention(app); got != 7 {
t.Errorf("zero app value: got %d want 7 (global)", got)
}
}
func TestDisplayStatusComputesAbandoned(t *testing.T) {
setupTestRoot(t)
abandoned := Build{
ID: "x",
App: "myapp",
Kind: BuildKindBuild,
PID: pickDeadPID(t),
StartedAt: time.Now().UTC(),
Status: BuildStatusRunning,
Source: BuildSourceGitHook,
}
if got := abandoned.DisplayStatus(); got != BuildStatusAbandoned {
t.Errorf("DisplayStatus on dead-PID running record = %v, want abandoned", got)
}
live := abandoned
live.PID = os.Getpid()
if got := live.DisplayStatus(); got != BuildStatusRunning {
t.Errorf("DisplayStatus on alive-PID running record = %v, want running", got)
}
exit := 0
finished := time.Now().UTC()
terminal := Build{
ID: "y",
App: "myapp",
Kind: BuildKindBuild,
PID: os.Getpid(),
StartedAt: time.Now().UTC(),
FinishedAt: &finished,
Status: BuildStatusSucceeded,
Source: BuildSourceGitHook,
ExitCode: &exit,
}
if got := terminal.DisplayStatus(); got != BuildStatusSucceeded {
t.Errorf("terminal DisplayStatus = %v, want succeeded", got)
}
}
func TestRecordStartFinalizeIdempotency(t *testing.T) {
setupTestRoot(t)
app := "idem"
id := GenerateBuildID()
if err := TriggerBuildsRecordStart(app, id, strconv.Itoa(os.Getpid()), string(BuildSourceGitHook)); err != nil {
t.Fatalf("record-start: %v", err)
}
// First finalize: succeeded
if err := TriggerBuildsRecordFinalize(app, id, "0"); err != nil {
t.Fatalf("first finalize: %v", err)
}
first, err := ReadBuild(app, id)
if err != nil {
t.Fatalf("read after first finalize: %v", err)
}
if first.Status != BuildStatusSucceeded {
t.Errorf("first finalize Status = %v, want succeeded", first.Status)
}
// Manually mark canceled, then call finalize with non-zero exit; the
// canceled status should win because cancel finalized it first.
canceled := first
canceled.Status = BuildStatusCanceled
if err := WriteBuild(canceled); err != nil {
t.Fatalf("write canceled: %v", err)
}
if err := TriggerBuildsRecordFinalize(app, id, "1"); err != nil {
t.Fatalf("second finalize: %v", err)
}
final, err := ReadBuild(app, id)
if err != nil {
t.Fatalf("read after second finalize: %v", err)
}
if final.Status != BuildStatusCanceled {
t.Errorf("idempotent finalize Status = %v, want canceled", final.Status)
}
}
func TestRecordStartUnknownSourceCoercedToUnknown(t *testing.T) {
setupTestRoot(t)
app := "src"
id := GenerateBuildID()
if err := TriggerBuildsRecordStart(app, id, strconv.Itoa(os.Getpid()), "made-up-source"); err != nil {
t.Fatalf("record-start: %v", err)
}
got, err := ReadBuild(app, id)
if err != nil {
t.Fatalf("read: %v", err)
}
if got.Source != BuildSourceUnknown {
t.Errorf("Source = %v, want unknown", got.Source)
}
if got.Kind != BuildKindDeploy {
t.Errorf("Kind = %v, want deploy (DefaultKind for unknown)", got.Kind)
}
}
func TestBuildJSONShape(t *testing.T) {
setupTestRoot(t)
now := time.Now().UTC().Truncate(time.Second)
b := Build{
ID: "abc",
App: "j",
Kind: BuildKindDeploy,
PID: 100,
StartedAt: now,
Status: BuildStatusRunning,
Source: BuildSourcePsRestart,
}
if err := WriteBuild(b); err != nil {
t.Fatalf("write: %v", err)
}
raw, err := os.ReadFile(RecordPath(b.App, b.ID))
if err != nil {
t.Fatalf("read raw: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, key := range []string{"id", "app", "kind", "pid", "started_at", "status", "source"} {
if _, ok := parsed[key]; !ok {
t.Errorf("expected key %q in JSON, got %s", key, string(raw))
}
}
for _, omittable := range []string{"finished_at", "exit_code"} {
if _, ok := parsed[omittable]; ok {
t.Errorf("expected key %q to be omitted while running, got %s", omittable, string(raw))
}
}
if !strings.Contains(string(raw), `"kind": "deploy"`) {
t.Errorf("kind not serialized as enum string: %s", string(raw))
}
}

30
plugins/builds/go.mod Normal file
View File

@@ -0,0 +1,30 @@
module github.com/dokku/dokku/plugins/builds
go 1.26.2
require (
github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000
github.com/multiformats/go-base36 v0.2.0
github.com/ryanuber/columnize v2.1.2+incompatible
github.com/spf13/pflag v1.0.10
)
require (
github.com/alexellis/go-execute/v2 v2.2.1 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/melbahja/goph v1.5.0 // indirect
github.com/otiai10/copy v1.14.1 // indirect
github.com/otiai10/mint v1.6.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/sftp v1.13.10 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
)
replace github.com/dokku/dokku/plugins/common => ../common

57
plugins/builds/go.sum Normal file
View File

@@ -0,0 +1,57 @@
github.com/alexellis/go-execute/v2 v2.2.1 h1:4Ye3jiCKQarstODOEmqDSRCqxMHLkC92Bhse743RdOI=
github.com/alexellis/go-execute/v2 v2.2.1/go.mod h1:FMdRnUTiFAmYXcv23txrp3VYZfLo24nMpiIneWgKHTQ=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/melbahja/goph v1.5.0 h1:RQUBpLvfg3i7fjfG8rTcSWyMjVRfdhwrrfQhjYee4dQ=
github.com/melbahja/goph v1.5.0/go.mod h1:dDwo+44cmvfDLdiVpc6fJxexf5BA5yEDUeE5YgtuDO4=
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc=
github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8=
github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I=
github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs=
github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk=
github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,4 @@
[plugin]
description = "dokku core builds plugin"
version = "0.34.0"
[plugin.config]

130
plugins/builds/report.go Normal file
View File

@@ -0,0 +1,130 @@
package builds
import (
"strconv"
"time"
"github.com/dokku/dokku/plugins/common"
)
// ReportSingleApp emits the report for one app (or --global).
func ReportSingleApp(appName, format, infoFlag string) error {
if appName != "--global" {
if err := common.VerifyAppName(appName); err != nil {
return err
}
}
var flags map[string]common.ReportFunc
if appName == "--global" {
flags = map[string]common.ReportFunc{
"--builds-global-retention": reportGlobalRetention,
}
} else {
flags = map[string]common.ReportFunc{
"--build-id": reportBuildID,
"--build-kind": reportBuildKind,
"--build-status": reportBuildStatus,
"--build-pid": reportBuildPID,
"--build-source": reportBuildSource,
"--build-started-at": reportBuildStartedAt,
"--build-finished-at": reportBuildFinishedAt,
"--build-exit-code": reportBuildExitCode,
"--builds-retention": reportRetention,
"--builds-global-retention": reportGlobalRetention,
"--builds-computed-retention": reportComputedRetention,
}
}
flagKeys := make([]string, 0, len(flags))
for k := range flags {
flagKeys = append(flagKeys, k)
}
infoFlags := common.CollectReport(appName, infoFlag, flags)
return common.ReportSingleApp("builds", appName, infoFlag, infoFlags, flagKeys, format, false, true)
}
func mostRecentBuild(appName string) (Build, bool) {
builds, err := FetchBuilds(appName)
if err != nil || len(builds) == 0 {
return Build{}, false
}
return builds[0], true
}
func reportBuildID(appName string) string {
b, ok := mostRecentBuild(appName)
if !ok {
return ""
}
return b.ID
}
func reportBuildKind(appName string) string {
b, ok := mostRecentBuild(appName)
if !ok {
return ""
}
return string(b.Kind)
}
func reportBuildStatus(appName string) string {
b, ok := mostRecentBuild(appName)
if !ok {
return ""
}
return string(b.DisplayStatus())
}
func reportBuildPID(appName string) string {
b, ok := mostRecentBuild(appName)
if !ok {
return ""
}
return strconv.Itoa(b.PID)
}
func reportBuildSource(appName string) string {
b, ok := mostRecentBuild(appName)
if !ok {
return ""
}
return string(b.Source)
}
func reportBuildStartedAt(appName string) string {
b, ok := mostRecentBuild(appName)
if !ok {
return ""
}
return b.StartedAt.Format(time.RFC3339)
}
func reportBuildFinishedAt(appName string) string {
b, ok := mostRecentBuild(appName)
if !ok || b.FinishedAt == nil {
return ""
}
return b.FinishedAt.Format(time.RFC3339)
}
func reportBuildExitCode(appName string) string {
b, ok := mostRecentBuild(appName)
if !ok || b.ExitCode == nil {
return ""
}
return strconv.Itoa(*b.ExitCode)
}
func reportRetention(appName string) string {
return common.PropertyGet("builds", appName, "retention")
}
func reportGlobalRetention(_ string) string {
return common.PropertyGet("builds", "--global", "retention")
}
func reportComputedRetention(appName string) string {
return strconv.Itoa(ResolveRetention(appName))
}

44
plugins/builds/set.go Normal file
View File

@@ -0,0 +1,44 @@
package builds
import (
"fmt"
"strconv"
"github.com/dokku/dokku/plugins/common"
)
// CommandSet writes (or clears) a builds property for an app or globally.
func CommandSet(appName, property, value string) error {
if appName != "--global" {
if err := common.VerifyAppName(appName); err != nil {
return err
}
}
if _, ok := DefaultProperties[property]; !ok {
return fmt.Errorf("Invalid property %q (allowed: retention)", property)
}
if value != "" {
if err := validateSetValue(property, value); err != nil {
return err
}
}
common.CommandPropertySet("builds", appName, property, value, DefaultProperties, GlobalProperties)
return nil
}
func validateSetValue(property, value string) error {
switch property {
case "retention":
n, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("Invalid retention %q: must be a positive integer", value)
}
if n < MinimumRetention {
return fmt.Errorf("Invalid retention %d: must be >= %d", n, MinimumRetention)
}
}
return nil
}

View File

@@ -0,0 +1,60 @@
package main
import (
"flag"
"fmt"
"os"
"strconv"
"strings"
"github.com/dokku/dokku/plugins/common"
)
const (
helpHeader = `Usage: dokku builds[:COMMAND]
Manage running and historical builds
Additional commands:`
helpContent = `
builds:cancel <app>, Cancel a running build for an app
builds:info <app> <build-id> [--format json|stdout], Show details for a single build
builds:list [<app>] [--format json] [--kind build|deploy] [--status <status>], List builds
builds:output <app> [<build-id>|current], Show build output
builds:prune <app> [--all-apps], Prune build records to retention
builds:report [<app>] [<flag>], Display a build report for one or more apps
builds:set [--global|<app>] <key> [<value>], Set or clear a builds property`
)
func main() {
flag.Usage = usage
flag.Parse()
cmd := flag.Arg(0)
switch cmd {
case "builds", "builds:help":
usage()
case "help":
result, err := common.CallExecCommand(common.ExecCommandInput{
Command: "ps",
Args: []string{"-o", "command=", strconv.Itoa(os.Getppid())},
})
if err == nil && strings.Contains(result.StdoutContents(), "--all") {
fmt.Println(helpContent)
} else {
fmt.Print("\n builds, Manage running and historical builds\n")
}
default:
dokkuNotImplementExitCode, err := strconv.Atoi(os.Getenv("DOKKU_NOT_IMPLEMENTED_EXIT"))
if err != nil {
fmt.Println("failed to retrieve DOKKU_NOT_IMPLEMENTED_EXIT environment variable")
dokkuNotImplementExitCode = 10
}
os.Exit(dokkuNotImplementExitCode)
}
}
func usage() {
common.CommandUsage(helpHeader, helpContent)
}

View File

@@ -0,0 +1,77 @@
package main
import (
"fmt"
"os"
"strings"
"github.com/dokku/dokku/plugins/builds"
"github.com/dokku/dokku/plugins/common"
flag "github.com/spf13/pflag"
)
func main() {
parts := strings.Split(os.Args[0], "/")
subcommand := parts[len(parts)-1]
var err error
switch subcommand {
case "cancel":
args := flag.NewFlagSet("builds:cancel", flag.ExitOnError)
args.Parse(os.Args[2:])
err = builds.CommandCancel(args.Arg(0))
case "info":
args := flag.NewFlagSet("builds:info", flag.ExitOnError)
format := args.String("format", "stdout", "format: [ stdout | json ]")
args.Parse(os.Args[2:])
err = builds.CommandInfo(args.Arg(0), args.Arg(1), *format)
case "list":
args := flag.NewFlagSet("builds:list", flag.ExitOnError)
format := args.String("format", "stdout", "format: [ stdout | json ]")
kind := args.String("kind", "", "filter by kind: [ build | deploy ]")
status := args.String("status", "", "filter by status: [ running | succeeded | failed | canceled | abandoned ]")
args.Parse(os.Args[2:])
err = builds.CommandList(args.Arg(0), *format, *kind, *status)
case "output":
args := flag.NewFlagSet("builds:output", flag.ExitOnError)
args.Parse(os.Args[2:])
err = builds.CommandOutput(args.Arg(0), args.Arg(1))
case "prune":
args := flag.NewFlagSet("builds:prune", flag.ExitOnError)
allApps := args.Bool("all-apps", false, "--all-apps: prune every app")
args.Parse(os.Args[2:])
err = builds.CommandPrune(args.Arg(0), *allApps)
case "report":
args := flag.NewFlagSet("builds:report", flag.ExitOnError)
format := args.String("format", "stdout", "format: [ stdout | json ]")
reportArgs, flagErr := common.ParseReportArgs("builds", os.Args[2:])
if flagErr == nil {
args.Parse(reportArgs.OSArgs)
appName := args.Arg(0)
if reportArgs.IsGlobal {
appName = "--global"
}
err = builds.CommandReport(appName, *format, reportArgs.InfoFlag)
}
case "set":
args := flag.NewFlagSet("builds:set", flag.ExitOnError)
global := args.Bool("global", false, "--global: set a global property")
args.Parse(os.Args[2:])
appName := args.Arg(0)
property := args.Arg(1)
value := args.Arg(2)
if *global {
appName = "--global"
property = args.Arg(0)
value = args.Arg(1)
}
err = builds.CommandSet(appName, property, value)
default:
err = fmt.Errorf("Invalid plugin subcommand call: %s", subcommand)
}
if err != nil {
common.LogFailWithError(err)
}
}

View File

@@ -0,0 +1,49 @@
package main
import (
"flag"
"fmt"
"os"
"strings"
"github.com/dokku/dokku/plugins/builds"
"github.com/dokku/dokku/plugins/common"
)
func main() {
parts := strings.Split(os.Args[0], "/")
trigger := parts[len(parts)-1]
flag.Parse()
var err error
switch trigger {
case "builds-generate-id":
err = builds.TriggerBuildsGenerateID()
case "builds-record-finalize":
appName := flag.Arg(0)
buildID := flag.Arg(1)
exitStr := flag.Arg(2)
err = builds.TriggerBuildsRecordFinalize(appName, buildID, exitStr)
case "builds-record-start":
appName := flag.Arg(0)
buildID := flag.Arg(1)
pid := flag.Arg(2)
source := flag.Arg(3)
err = builds.TriggerBuildsRecordStart(appName, buildID, pid, source)
case "install":
err = builds.TriggerInstall()
case "post-app-rename-setup":
oldAppName := flag.Arg(0)
newAppName := flag.Arg(1)
err = builds.TriggerPostAppRenameSetup(oldAppName, newAppName)
case "post-delete":
appName := flag.Arg(0)
err = builds.TriggerPostDelete(appName)
default:
err = fmt.Errorf("Invalid plugin trigger call: %s", trigger)
}
if err != nil {
common.LogFailWithError(err)
}
}

View File

@@ -0,0 +1,458 @@
package builds
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"github.com/dokku/dokku/plugins/common"
"github.com/ryanuber/columnize"
)
// listView holds a Build plus its computed display status, used by
// builds:list / builds:info JSON output.
type listView struct {
Build
DisplayStatus BuildStatus `json:"display_status"`
Duration string `json:"duration"`
LogPath string `json:"log_path"`
}
func toListView(b Build) listView {
return listView{
Build: b,
DisplayStatus: b.DisplayStatus(),
Duration: b.Duration().String(),
LogPath: b.LogPath(),
}
}
// CommandList lists running builds across apps (no app arg) or running + recent
// records for one app.
func CommandList(appName string, format string, kindFilter string, statusFilter string) error {
if format == "" {
format = "stdout"
}
if format != "stdout" && format != "json" {
return fmt.Errorf("Invalid format specified, supported formats: json, stdout")
}
if kindFilter != "" {
k := BuildKind(kindFilter)
if !k.Valid() {
return fmt.Errorf("Invalid --kind value %q (allowed: build, deploy)", kindFilter)
}
}
if statusFilter != "" {
if !validStatusFilter(BuildStatus(statusFilter)) {
return fmt.Errorf("Invalid --status value %q (allowed: running, succeeded, failed, canceled, abandoned)", statusFilter)
}
}
if appName == "" {
return commandListAllRunning(format, kindFilter, statusFilter)
}
if err := common.VerifyAppName(appName); err != nil {
return err
}
all, err := FetchBuilds(appName)
if err != nil {
return err
}
retention := ResolveRetention(appName)
views := make([]listView, 0, len(all))
for _, b := range all {
v := toListView(b)
if !matchesKind(b, kindFilter) {
continue
}
if !matchesStatus(v, statusFilter) {
continue
}
views = append(views, v)
}
sort.SliceStable(views, func(i, j int) bool {
// Live-running first, then by start time descending.
iLive := views[i].Build.Status == BuildStatusRunning && views[i].DisplayStatus == BuildStatusRunning
jLive := views[j].Build.Status == BuildStatusRunning && views[j].DisplayStatus == BuildStatusRunning
if iLive != jLive {
return iLive
}
return views[i].StartedAt.After(views[j].StartedAt)
})
if statusFilter == "" && kindFilter == "" && len(views) > retention {
live := 0
for _, v := range views {
if v.DisplayStatus == BuildStatusRunning {
live++
}
}
cap := live + retention
if cap < len(views) {
views = views[:cap]
}
}
if format == "json" {
body, err := json.Marshal(views)
if err != nil {
return err
}
common.Log(string(body))
return nil
}
if len(views) == 0 {
fmt.Println("No builds recorded for this app")
return nil
}
rows := []string{"Build ID | Kind | Status | PID | Source | Started | Duration"}
for _, v := range views {
rows = append(rows, fmt.Sprintf("%s | %s | %s | %d | %s | %s | %s",
v.ID,
v.Kind,
v.DisplayStatus,
v.PID,
v.Source,
v.StartedAt.Format(time.RFC3339),
v.Duration,
))
}
fmt.Println(columnize.SimpleFormat(rows))
return nil
}
func commandListAllRunning(format, kindFilter, statusFilter string) error {
apps, err := common.DokkuApps()
if err != nil && !errors.Is(err, common.NoAppsExist) {
return err
}
views := []listView{}
for _, appName := range apps {
running, err := FetchBuilds(appName)
if err != nil {
common.LogWarn(fmt.Sprintf("Could not read builds for %s: %s", appName, err))
continue
}
for _, b := range running {
if b.Status != BuildStatusRunning {
continue
}
v := toListView(b)
if !matchesKind(b, kindFilter) {
continue
}
if !matchesStatus(v, statusFilter) {
continue
}
views = append(views, v)
}
}
sort.SliceStable(views, func(i, j int) bool {
return views[i].StartedAt.After(views[j].StartedAt)
})
if format == "json" {
body, err := json.Marshal(views)
if err != nil {
return err
}
common.Log(string(body))
return nil
}
if len(views) == 0 {
fmt.Println("No builds currently running")
return nil
}
rows := []string{"App | Build ID | Kind | PID | Source | Started"}
for _, v := range views {
rows = append(rows, fmt.Sprintf("%s | %s | %s | %d | %s | %s",
v.App,
v.ID,
v.Kind,
v.PID,
v.Source,
v.StartedAt.Format(time.RFC3339),
))
}
fmt.Println(columnize.SimpleFormat(rows))
return nil
}
func matchesKind(b Build, filter string) bool {
if filter == "" {
return true
}
return string(b.Kind) == filter
}
func matchesStatus(v listView, filter string) bool {
if filter == "" {
return true
}
return string(v.DisplayStatus) == filter
}
func validStatusFilter(s BuildStatus) bool {
switch s {
case BuildStatusRunning, BuildStatusSucceeded, BuildStatusFailed, BuildStatusCanceled, BuildStatusAbandoned:
return true
}
return false
}
// CommandInfo prints details for a single build record.
func CommandInfo(appName, buildID, format string) error {
if format == "" {
format = "stdout"
}
if format != "stdout" && format != "json" {
return fmt.Errorf("Invalid format specified, supported formats: json, stdout")
}
if err := common.VerifyAppName(appName); err != nil {
return err
}
if buildID == "" {
return errors.New("Please specify a build id")
}
b, err := ReadBuild(appName, buildID)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("No build record found for %s/%s", appName, buildID)
}
return err
}
v := toListView(b)
if format == "json" {
body, err := json.Marshal(v)
if err != nil {
return err
}
common.Log(string(body))
return nil
}
rows := []string{
fmt.Sprintf("Build ID:|%s", v.ID),
fmt.Sprintf("App:|%s", v.App),
fmt.Sprintf("Kind:|%s", v.Kind),
fmt.Sprintf("Status:|%s", v.DisplayStatus),
fmt.Sprintf("PID:|%d", v.PID),
fmt.Sprintf("Source:|%s", v.Source),
fmt.Sprintf("Started:|%s", v.StartedAt.Format(time.RFC3339)),
}
if v.FinishedAt != nil {
rows = append(rows, fmt.Sprintf("Finished:|%s", v.FinishedAt.Format(time.RFC3339)))
}
rows = append(rows, fmt.Sprintf("Duration:|%s", v.Duration))
if v.ExitCode != nil {
rows = append(rows, fmt.Sprintf("Exit Code:|%d", *v.ExitCode))
}
rows = append(rows, fmt.Sprintf("Log:|%s", v.LogPath))
fmt.Println(columnize.Format(rows, &columnize.Config{Delim: "|"}))
return nil
}
// CommandCancel cancels the in-flight build for an app.
func CommandCancel(appName string) error {
if err := common.VerifyAppName(appName); err != nil {
return err
}
lockPath := deployLockPath(appName)
body, err := os.ReadFile(lockPath)
if err != nil {
if os.IsNotExist(err) {
common.LogInfo1("App not currently deploying")
return nil
}
return err
}
buildID := strings.TrimSpace(string(body))
if buildID == "" {
common.LogInfo1("No matching app deploy found")
return nil
}
b, err := ReadBuild(appName, buildID)
if err != nil {
if os.IsNotExist(err) {
common.LogInfo1(fmt.Sprintf("No build record for %s, removing stale lock file", buildID))
_ = os.Remove(lockPath)
return nil
}
return err
}
if b.Status != BuildStatusRunning {
common.LogInfo1(fmt.Sprintf("Build %s is no longer running (status=%s); leaving record untouched", buildID, b.Status))
return nil
}
if !CheckPIDAlive(b.PID) {
common.LogInfo1("Build was already terminated, marking failed")
now := time.Now().UTC()
exitCode := -1
b.Status = BuildStatusFailed
b.FinishedAt = &now
b.ExitCode = &exitCode
if err := WriteBuild(b); err != nil {
return err
}
_ = os.Remove(lockPath)
return nil
}
common.LogInfo1("Killing app deploy")
if err := killProcessGroup(b.PID); err != nil {
common.LogWarn(fmt.Sprintf("Failed to signal process group %d: %s", b.PID, err))
}
current, err := ReadBuild(appName, buildID)
if err == nil && current.Status == BuildStatusRunning {
now := time.Now().UTC()
exitCode := -1
current.Status = BuildStatusCanceled
current.FinishedAt = &now
current.ExitCode = &exitCode
if err := WriteBuild(current); err != nil {
return err
}
}
_ = os.Remove(lockPath)
return nil
}
func killProcessGroup(pid int) error {
pgid, err := syscall.Getpgid(pid)
if err != nil {
return err
}
return syscall.Kill(-pgid, syscall.SIGQUIT)
}
func deployLockPath(appName string) string {
return filepath.Join(common.GetAppDataDirectory("apps", appName), ".deploy.lock")
}
// CommandOutput streams the build log for a given build (or the current one).
func CommandOutput(appName, buildID string) error {
if err := common.VerifyAppName(appName); err != nil {
return err
}
if buildID == "" || buildID == "current" {
body, err := os.ReadFile(deployLockPath(appName))
if err != nil {
if os.IsNotExist(err) {
common.LogInfo1("App not currently deploying")
return nil
}
return err
}
buildID = strings.TrimSpace(string(body))
if buildID == "" {
common.LogInfo1("No matching app deploy found")
return nil
}
}
logPath := LogPathFor(appName, buildID)
if _, err := os.Stat(logPath); err != nil {
if os.IsNotExist(err) {
return outputViaJournalctl(buildID)
}
return err
}
b, err := ReadBuild(appName, buildID)
if err != nil && !os.IsNotExist(err) {
return err
}
if err == nil && b.Status == BuildStatusRunning && CheckPIDAlive(b.PID) {
return execStreaming("tail", "-n", "1000", "-f", logPath)
}
return execStreaming("cat", logPath)
}
func outputViaJournalctl(buildID string) error {
if _, err := exec.LookPath("journalctl"); err != nil {
return fmt.Errorf("log file missing and journalctl not available for build %s", buildID)
}
return execStreaming("journalctl", "-n", "1000", "-a", "-o", "cat", fmt.Sprintf("SYSLOG_IDENTIFIER=dokku-%s", buildID))
}
func execStreaming(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// CommandPrune manually invokes PruneAppBuilds for one or every app.
func CommandPrune(appName string, allApps bool) error {
if allApps {
apps, err := common.DokkuApps()
if err != nil {
if errors.Is(err, common.NoAppsExist) {
common.LogWarn(err.Error())
return nil
}
return err
}
for _, app := range apps {
if err := PruneAppBuilds(app); err != nil {
common.LogWarn(fmt.Sprintf("Prune failed for %s: %s", app, err))
}
}
return nil
}
if err := common.VerifyAppName(appName); err != nil {
return err
}
return PruneAppBuilds(appName)
}
// CommandReport displays a build report for one or more apps.
func CommandReport(appName, format, infoFlag 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 _, app := range apps {
if err := ReportSingleApp(app, format, infoFlag); err != nil {
return err
}
}
return nil
}
return ReportSingleApp(appName, format, infoFlag)
}

145
plugins/builds/triggers.go Normal file
View File

@@ -0,0 +1,145 @@
package builds
import (
"errors"
"fmt"
"os"
"strconv"
"time"
"github.com/dokku/dokku/plugins/common"
)
// TriggerInstall sets up the builds plugin's property storage.
func TriggerInstall() error {
if err := common.PropertySetup("builds"); err != nil {
return fmt.Errorf("Unable to install the builds plugin: %s", err.Error())
}
return nil
}
// TriggerPostDelete removes all builds data and properties for the given app.
func TriggerPostDelete(appName string) error {
if err := os.RemoveAll(AppDataDir(appName)); err != nil {
common.LogWarn(fmt.Sprintf("Could not remove builds data for %s: %s", appName, err))
}
if err := common.PropertyDestroy("builds", appName); err != nil {
return err
}
return nil
}
// TriggerPostAppRenameSetup renames the per-app data directory and clones
// builds properties to the new app name.
func TriggerPostAppRenameSetup(oldAppName string, newAppName string) error {
oldDir := AppDataDir(oldAppName)
newDir := AppDataDir(newAppName)
if _, err := os.Stat(oldDir); err == nil {
if err := os.MkdirAll(common.GetAppDataDirectory("builds", ""), 0755); err == nil {
if err := os.Rename(oldDir, newDir); err != nil {
common.LogWarn(fmt.Sprintf("Could not rename builds data dir for %s: %s", oldAppName, err))
}
}
}
if err := common.PropertyClone("builds", oldAppName, newAppName); err != nil {
return err
}
if err := common.PropertyDestroy("builds", oldAppName); err != nil {
return err
}
return nil
}
// TriggerBuildsGenerateID writes a fresh build-id to stdout. The bash callers
// of this trigger consume the value via $(...) capture, so this function MUST
// emit only the build-id and nothing else - no log output, no warnings.
func TriggerBuildsGenerateID() error {
fmt.Println(GenerateBuildID())
return nil
}
// TriggerBuildsRecordStart persists the initial build record on lock-acquire.
//
// Args: <app> <build-id> <pid> <source>
//
// kind is derived from source via BuildSource.DefaultKind(); callers do not
// pass it.
func TriggerBuildsRecordStart(appName, buildID, pidStr, sourceStr string) error {
if appName == "" {
return errors.New("builds-record-start: missing app name")
}
if buildID == "" {
return errors.New("builds-record-start: missing build id")
}
pid, err := strconv.Atoi(pidStr)
if err != nil {
return fmt.Errorf("builds-record-start: invalid pid %q: %w", pidStr, err)
}
source := BuildSource(sourceStr)
if !source.Valid() {
common.LogWarn(fmt.Sprintf("builds-record-start: unknown source %q for app %s, recording as %q", sourceStr, appName, BuildSourceUnknown))
source = BuildSourceUnknown
}
b := Build{
ID: buildID,
App: appName,
Kind: source.DefaultKind(),
PID: pid,
StartedAt: time.Now().UTC(),
Status: BuildStatusRunning,
Source: source,
}
return WriteBuild(b)
}
// TriggerBuildsRecordFinalize writes the terminal status onto an existing
// build record. It is idempotent: records that are already terminal (or have
// been overwritten by an earlier finalize) are left untouched.
//
// Args: <app> <build-id> <exit-code>
func TriggerBuildsRecordFinalize(appName, buildID, exitStr string) error {
if appName == "" {
return errors.New("builds-record-finalize: missing app name")
}
if buildID == "" {
return errors.New("builds-record-finalize: missing build id")
}
exitCode, err := strconv.Atoi(exitStr)
if err != nil {
return fmt.Errorf("builds-record-finalize: invalid exit code %q: %w", exitStr, err)
}
b, err := ReadBuild(appName, buildID)
if err != nil {
if os.IsNotExist(err) {
common.LogWarn(fmt.Sprintf("builds-record-finalize: no record for %s/%s, skipping", appName, buildID))
return nil
}
return err
}
if b.Status.IsTerminal() {
// Idempotent path - typically hit when builds:cancel finalized the
// record before the dying process reached release_app_deploy_lock.
return PruneAppBuilds(appName)
}
now := time.Now().UTC()
b.FinishedAt = &now
b.ExitCode = &exitCode
if exitCode == 0 {
b.Status = BuildStatusSucceeded
} else {
b.Status = BuildStatusFailed
}
if err := WriteBuild(b); err != nil {
return err
}
return PruneAppBuilds(appName)
}

View File

@@ -33,9 +33,13 @@ cmd-caddy-report() {
declare desc="displays a caddy report for one or more apps"
declare cmd="caddy:report"
[[ "$1" == "$cmd" ]] && shift 1
declare APP="$1" INFO_FLAG="$2"
fn-report-parse-args "$@"
set -- "${REPORT_ARGS[@]}"
declare APP="${1:-}" INFO_FLAG="${2:-}"
if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
if [[ "$REPORT_IS_GLOBAL" == "true" ]]; then
APP="--global"
elif [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
INFO_FLAG="$APP"
APP=""
fi
@@ -44,32 +48,56 @@ cmd-caddy-report() {
INFO_FLAG="true"
fi
if [[ -z "$APP" ]]; then
if [[ "$APP" == "--global" ]]; then
cmd-caddy-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
elif [[ -z "$APP" ]]; then
for app in $(dokku_apps); do
cmd-caddy-report-single "$app" "$INFO_FLAG" | tee || true
cmd-caddy-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true
done
else
cmd-caddy-report-single "$APP" "$INFO_FLAG"
cmd-caddy-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
fi
}
cmd-caddy-report-single() {
declare APP="$1" INFO_FLAG="$2"
declare APP="$1" INFO_FLAG="$2" FORMAT="${3:-stdout}"
if [[ "$INFO_FLAG" == "true" ]]; then
INFO_FLAG=""
fi
verify_app_name "$APP"
local flag_map=(
"--caddy-image: $(fn-caddy-image)"
"--caddy-letsencrypt-email: $(fn-caddy-letsencrypt-email)"
"--caddy-letsencrypt-server: $(fn-caddy-letsencrypt-server)"
"--caddy-log-level: $(fn-caddy-log-level)"
"--caddy-polling-interval: $(fn-caddy-polling-interval)"
"--caddy-tls-internal: $(fn-caddy-tls-internal "$APP")"
)
local flag_map=()
if [[ "$APP" == "--global" ]]; then
flag_map=(
"--caddy-image: $(fn-caddy-image)"
"--caddy-letsencrypt-email: $(fn-caddy-letsencrypt-email)"
"--caddy-letsencrypt-server: $(fn-caddy-letsencrypt-server)"
"--caddy-log-level: $(fn-caddy-log-level)"
"--caddy-polling-interval: $(fn-caddy-polling-interval)"
)
else
verify_app_name "$APP"
flag_map=(
"--caddy-image: $(fn-caddy-image)"
"--caddy-letsencrypt-email: $(fn-caddy-letsencrypt-email)"
"--caddy-letsencrypt-server: $(fn-caddy-letsencrypt-server)"
"--caddy-log-level: $(fn-caddy-log-level)"
"--caddy-polling-interval: $(fn-caddy-polling-interval)"
"--caddy-tls-internal: $(fn-caddy-tls-internal "$APP")"
)
fi
fn-report-validate-format "$FORMAT" "$INFO_FLAG"
if [[ "$FORMAT" == "json" ]]; then
fn-report-emit-json flag_map "caddy"
return
fi
if [[ -z "$INFO_FLAG" ]]; then
dokku_log_info2_quiet "${APP} caddy information"
if [[ "$APP" == "--global" ]]; then
dokku_log_info2_quiet "global caddy information"
else
dokku_log_info2_quiet "${APP} caddy information"
fi
for flag in "${flag_map[@]}"; do
key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')"
dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")"

View File

@@ -8,9 +8,13 @@ cmd-certs-report() {
declare desc="displays an ssl report for one or more apps"
declare cmd="certs:report"
[[ "$1" == "$cmd" ]] && shift 1
declare APP="$1" INFO_FLAG="$2"
fn-report-parse-args "$@"
set -- "${REPORT_ARGS[@]}"
declare APP="${1:-}" INFO_FLAG="${2:-}"
if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
if [[ "$REPORT_IS_GLOBAL" == "true" ]]; then
APP="--global"
elif [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
INFO_FLAG="$APP"
APP=""
fi
@@ -19,34 +23,50 @@ cmd-certs-report() {
INFO_FLAG="true"
fi
if [[ -z "$APP" ]]; then
if [[ "$APP" == "--global" ]]; then
cmd-certs-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
elif [[ -z "$APP" ]]; then
for app in $(dokku_apps); do
cmd-certs-report-single "$app" "$INFO_FLAG" | tee || true
cmd-certs-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true
done
else
cmd-certs-report-single "$APP" "$INFO_FLAG"
cmd-certs-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
fi
}
cmd-certs-report-single() {
declare APP="$1" INFO_FLAG="$2"
declare APP="$1" INFO_FLAG="$2" FORMAT="${3:-stdout}"
if [[ "$INFO_FLAG" == "true" ]]; then
INFO_FLAG=""
fi
verify_app_name "$APP"
local flag_map=(
"--ssl-dir: $DOKKU_ROOT/$APP/tls"
"--ssl-enabled: $(fn-ssl-enabled "$APP")"
"--ssl-hostnames: $(fn-ssl-hostnames "$APP")"
"--ssl-expires-at: $(fn-ssl-expires-at "$APP")"
"--ssl-issuer: $(fn-ssl-issuer "$APP")"
"--ssl-starts-at: $(fn-ssl-starts-at "$APP")"
"--ssl-subject: $(fn-ssl-subject "$APP")"
"--ssl-verified: $(fn-ssl-verified "$APP")"
)
local flag_map=()
if [[ "$APP" != "--global" ]]; then
verify_app_name "$APP"
flag_map=(
"--ssl-dir: $DOKKU_ROOT/$APP/tls"
"--ssl-enabled: $(fn-ssl-enabled "$APP")"
"--ssl-hostnames: $(fn-ssl-hostnames "$APP")"
"--ssl-expires-at: $(fn-ssl-expires-at "$APP")"
"--ssl-issuer: $(fn-ssl-issuer "$APP")"
"--ssl-starts-at: $(fn-ssl-starts-at "$APP")"
"--ssl-subject: $(fn-ssl-subject "$APP")"
"--ssl-verified: $(fn-ssl-verified "$APP")"
)
fi
fn-report-validate-format "$FORMAT" "$INFO_FLAG"
if [[ "$FORMAT" == "json" ]]; then
fn-report-emit-json flag_map "ssl"
return
fi
if [[ -z "$INFO_FLAG" ]]; then
dokku_log_info2_quiet "$APP ssl information"
if [[ "$APP" == "--global" ]]; then
dokku_log_info2_quiet "global ssl information"
else
dokku_log_info2_quiet "$APP ssl information"
fi
for flag in "${flag_map[@]}"; do
key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')"
dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")"

View File

@@ -2,13 +2,13 @@
set -eo pipefail
[[ $DOKKU_TRACE ]] && set -x
source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions"
source "$PLUGIN_AVAILABLE_PATH/config/functions"
source "$PLUGIN_CORE_AVAILABLE_PATH/common/property-functions"
is_app_proctype_checks_disabled() {
declare desc="return true if app's proctype(s) checks are disabled"
local APP="$1"
local PROCTYPE="$2" status=false
local DOKKU_CHECKS_DISABLED=$(config_get "$APP" DOKKU_CHECKS_DISABLED || true)
local DOKKU_CHECKS_DISABLED=$(fn-plugin-property-get-default "checks" "$APP" "disabled" "")
if [[ "$DOKKU_CHECKS_DISABLED" == "_all_" ]] || [[ "$(is_val_in_list "$PROCTYPE" "$DOKKU_CHECKS_DISABLED")" == "true" ]]; then
status=true
@@ -21,7 +21,7 @@ is_app_proctype_checks_skipped() {
declare desc="return true if app's proctype(s) checks are skipped"
local APP="$1"
local PROCTYPE="$2" status=false
local DOKKU_CHECKS_SKIPPED=$(config_get "$APP" DOKKU_CHECKS_SKIPPED || true)
local DOKKU_CHECKS_SKIPPED=$(fn-plugin-property-get-default "checks" "$APP" "skipped" "")
if [[ "$DOKKU_CHECKS_SKIPPED" == "_all_" ]] || [[ "$(is_val_in_list "$PROCTYPE" "$DOKKU_CHECKS_SKIPPED")" == "true" ]]; then
status=true

View File

@@ -3,16 +3,14 @@ set -eo pipefail
[[ $DOKKU_TRACE ]] && set -x
source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions"
source "$PLUGIN_CORE_AVAILABLE_PATH/common/property-functions"
source "$PLUGIN_AVAILABLE_PATH/config/functions"
migrate_checks_vars_0_29_0() {
declare desc="migrates deprecated DOKKU_WAIT_TO_RETIRE config variables to property counter part introduced in 0.29.x"
fn-migrate-config-to-property "checks" "wait-to-retire" "DOKKU_WAIT_TO_RETIRE" "DOKKU_WAIT_TO_RETIRE"
}
migrate_checks_vars_0_5_0() {
declare desc="migrates deprecated CHECKS config variables to simplified counter part introduced in 0.5.x"
if ! declare -f -F config_get >/dev/null; then
source "$PLUGIN_AVAILABLE_PATH/config/functions"
fi
local GLOBAL_SKIP_ALL_CHECKS=$(config_get --global DOKKU_SKIP_ALL_CHECKS || true)
local GLOBAL_SKIP_DEFAULT_CHECKS=$(config_get --global DOKKU_SKIP_DEFAULT_CHECKS || true)
@@ -20,13 +18,13 @@ migrate_checks_vars_0_5_0() {
local APP_SKIP_ALL_CHECKS=$(config_get "$app" DOKKU_SKIP_ALL_CHECKS || true)
local APP_SKIP_DEFAULT_CHECKS=$(config_get "$app" DOKKU_SKIP_DEFAULT_CHECKS || true)
if [[ "$APP_SKIP_ALL_CHECKS" == "true" ]] || [[ "$APP_SKIP_DEFAULT_CHECKS" == "true" ]] || [[ "$GLOBAL_SKIP_ALL_CHECKS" == "true" ]] || [[ "$GLOBAL_SKIP_DEFAULT_CHECKS" == "true" ]]; then
dokku_log_info1 "Migrating zero downtime env variables to 0.5.x. The following variables have been deprecated"
dokku_log_info2 "DOKKU_SKIP_ALL_CHECKS DOKKU_SKIP_DEFAULT_CHECKS"
dokku_log_info2 "Please use dokku checks:[disable|enable] <app> to control zero downtime functionality"
dokku_log_info2 ""
dokku_log_info2 "Zero downtime checks disabled for app ($app)"
DOKKU_QUIET_OUTPUT=1 config_set --no-restart "$app" DOKKU_CHECKS_ENABLED=0
if [[ "$APP_SKIP_ALL_CHECKS" == "true" ]] || [[ "$GLOBAL_SKIP_ALL_CHECKS" == "true" ]]; then
dokku_log_info1 "Migrating DOKKU_SKIP_ALL_CHECKS to checks disabled property for $app. Use 'dokku checks:set $app disabled <value>' to manage this going forward."
fn-plugin-property-write "checks" "$app" "disabled" "_all_"
fi
if [[ "$APP_SKIP_DEFAULT_CHECKS" == "true" ]] || [[ "$GLOBAL_SKIP_DEFAULT_CHECKS" == "true" ]]; then
dokku_log_info1 "Migrating DOKKU_SKIP_DEFAULT_CHECKS to checks skipped property for $app. Use 'dokku checks:set $app skipped <value>' to manage this going forward."
fn-plugin-property-write "checks" "$app" "skipped" "_all_"
fi
if [[ -n "$APP_SKIP_ALL_CHECKS" ]] || [[ -n "$APP_SKIP_DEFAULT_CHECKS" ]]; then
DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$app" DOKKU_SKIP_ALL_CHECKS DOKKU_SKIP_DEFAULT_CHECKS
@@ -34,7 +32,6 @@ migrate_checks_vars_0_5_0() {
done
if [[ -n "$GLOBAL_SKIP_ALL_CHECKS" ]] || [[ -n "$GLOBAL_SKIP_DEFAULT_CHECKS" ]]; then
dokku_log_info1 "Removing global zero downtime settings"
DOKKU_QUIET_OUTPUT=1 config_unset --global DOKKU_SKIP_ALL_CHECKS DOKKU_SKIP_DEFAULT_CHECKS
fi
}
@@ -42,15 +39,16 @@ migrate_checks_vars_0_5_0() {
migrate_checks_vars_0_6_0() {
declare desc="migrates CHECKS config variables from 0.5.x to support fully-disabled zero-downtime checks"
if ! declare -f -F config_get >/dev/null; then
source "$PLUGIN_AVAILABLE_PATH/config/functions"
fi
for app in $(dokku_apps "false" 2>/dev/null); do
local APP_DOKKU_CHECKS_ENABLED=$(config_get "$app" DOKKU_CHECKS_ENABLED || true)
if [[ $APP_DOKKU_CHECKS_ENABLED ]]; then
dokku_log_info1 "Migrating zero downtime env variables to 0.6.x. The following variables will be migrated"
dokku_log_info2 "DOKKU_CHECKS_ENABLED -> DOKKU_CHECKS_SKIPPED"
if [[ -n "$APP_DOKKU_CHECKS_ENABLED" ]]; then
if [[ "$APP_DOKKU_CHECKS_ENABLED" == "0" ]]; then
dokku_log_info2 ""
dokku_log_info2 "Zero downtime checks disabled for app ($app)"
DOKKU_QUIET_OUTPUT=1 config_set --no-restart "$app" DOKKU_CHECKS_SKIPPED="_all_"
dokku_log_info1 "Migrating DOKKU_CHECKS_ENABLED to checks skipped property for $app. Use 'dokku checks:set $app skipped <value>' to manage this going forward."
fn-plugin-property-write "checks" "$app" "skipped" "_all_"
fi
DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$app" DOKKU_CHECKS_ENABLED || true
fi
@@ -64,7 +62,14 @@ trigger-checks-install() {
fn-plugin-property-setup "checks"
migrate_checks_vars_0_5_0 "$@"
migrate_checks_vars_0_6_0 "$@"
migrate_checks_vars_0_29_0 "$@"
"$PLUGIN_CORE_AVAILABLE_PATH/common/prop" migrate-config-to-property checks wait-to-retire DOKKU_WAIT_TO_RETIRE --global-config-var DOKKU_WAIT_TO_RETIRE
"$PLUGIN_CORE_AVAILABLE_PATH/common/prop" migrate-config-to-property checks disabled DOKKU_CHECKS_DISABLED
"$PLUGIN_CORE_AVAILABLE_PATH/common/prop" migrate-config-to-property checks skipped DOKKU_CHECKS_SKIPPED
"$PLUGIN_CORE_AVAILABLE_PATH/common/prop" migrate-config-to-property checks wait DOKKU_CHECKS_WAIT --global-config-var DOKKU_CHECKS_WAIT
"$PLUGIN_CORE_AVAILABLE_PATH/common/prop" migrate-config-to-property checks timeout DOKKU_CHECKS_TIMEOUT --global-config-var DOKKU_CHECKS_TIMEOUT
"$PLUGIN_CORE_AVAILABLE_PATH/common/prop" migrate-config-to-property checks attempts DOKKU_CHECKS_ATTEMPTS --global-config-var DOKKU_CHECKS_ATTEMPTS
"$PLUGIN_CORE_AVAILABLE_PATH/common/prop" migrate-config-to-property checks default-wait DOKKU_DEFAULT_CHECKS_WAIT --global-config-var DOKKU_DEFAULT_CHECKS_WAIT
}
trigger-checks-install "$@"

View File

@@ -9,9 +9,13 @@ cmd-checks-report() {
declare desc="shows reports for an app"
declare cmd="checks:report"
[[ "$1" == "$cmd" ]] && shift 1
declare APP="$1" INFO_FLAG="$2"
fn-report-parse-args "$@"
set -- "${REPORT_ARGS[@]}"
declare APP="${1:-}" INFO_FLAG="${2:-}"
if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
if [[ "$REPORT_IS_GLOBAL" == "true" ]]; then
APP="--global"
elif [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then
INFO_FLAG="$APP"
APP=""
fi
@@ -20,31 +24,51 @@ cmd-checks-report() {
INFO_FLAG="true"
fi
if [[ -z "$APP" ]]; then
if [[ "$APP" == "--global" ]]; then
cmd-checks-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
elif [[ -z "$APP" ]]; then
for app in $(dokku_apps); do
cmd-checks-report-single "$app" "$INFO_FLAG" | tee || true
cmd-checks-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true
done
else
cmd-checks-report-single "$APP" "$INFO_FLAG"
cmd-checks-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT"
fi
}
cmd-checks-report-single() {
declare APP="$1" INFO_FLAG="$2"
declare APP="$1" INFO_FLAG="$2" FORMAT="${3:-stdout}"
if [[ "$INFO_FLAG" == "true" ]]; then
INFO_FLAG=""
fi
verify_app_name "$APP"
local flag_map=(
"--checks-disabled-list: $(fn-checks-disabled-list "$APP")"
"--checks-skipped-list: $(fn-checks-skipped-list "$APP")"
"--checks-computed-wait-to-retire: $(fn-checks-computed-wait-to-retire "$APP")"
"--checks-global-wait-to-retire: $(fn-checks-global-wait-to-retire "$APP")"
"--checks-wait-to-retire: $(fn-checks-wait-to-retire "$APP")"
)
local flag_map=()
if [[ "$APP" == "--global" ]]; then
flag_map=(
"--checks-global-wait-to-retire: $(fn-checks-global-wait-to-retire "$APP")"
)
else
verify_app_name "$APP"
flag_map=(
"--checks-disabled-list: $(fn-checks-disabled-list "$APP")"
"--checks-skipped-list: $(fn-checks-skipped-list "$APP")"
"--checks-computed-wait-to-retire: $(fn-checks-computed-wait-to-retire "$APP")"
"--checks-global-wait-to-retire: $(fn-checks-global-wait-to-retire "$APP")"
"--checks-wait-to-retire: $(fn-checks-wait-to-retire "$APP")"
)
fi
fn-report-validate-format "$FORMAT" "$INFO_FLAG"
if [[ "$FORMAT" == "json" ]]; then
fn-report-emit-json flag_map "checks"
return
fi
if [[ -z "$INFO_FLAG" ]]; then
dokku_log_info2_quiet "$APP checks information"
if [[ "$APP" == "--global" ]]; then
dokku_log_info2_quiet "global checks information"
else
dokku_log_info2_quiet "$APP checks information"
fi
for flag in "${flag_map[@]}"; do
key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')"
dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")"
@@ -72,7 +96,7 @@ cmd-checks-report-single() {
fn-checks-disabled-list() {
declare APP="$1"
local DOKKU_CHECKS_DISABLED=$(config_get "$APP" DOKKU_CHECKS_DISABLED)
local DOKKU_CHECKS_DISABLED=$(fn-plugin-property-get-default "checks" "$APP" "disabled" "")
DOKKU_CHECKS_DISABLED="${DOKKU_CHECKS_DISABLED:-none}"
echo "$DOKKU_CHECKS_DISABLED"
}
@@ -80,7 +104,7 @@ fn-checks-disabled-list() {
fn-checks-skipped-list() {
declare APP="$1"
local DOKKU_CHECKS_SKIPPED=$(config_get "$APP" DOKKU_CHECKS_SKIPPED)
local DOKKU_CHECKS_SKIPPED=$(fn-plugin-property-get-default "checks" "$APP" "skipped" "")
DOKKU_CHECKS_SKIPPED="${DOKKU_CHECKS_SKIPPED:-none}"
echo "$DOKKU_CHECKS_SKIPPED"
}

View File

@@ -2,7 +2,7 @@
set -eo pipefail
[[ $DOKKU_TRACE ]] && set -x
source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions"
source "$PLUGIN_AVAILABLE_PATH/checks/functions"
source "$PLUGIN_CORE_AVAILABLE_PATH/common/property-functions"
cmd-checks-disable() {
declare desc="disable zero-downtime checks for app/proctypes"
@@ -12,13 +12,13 @@ cmd-checks-disable() {
verify_app_name "$APP"
local PROCTYPES="${2:-_all_}"
local DOKKU_CHECKS_DISABLED=$(config_get "$APP" DOKKU_CHECKS_DISABLED || true)
local DOKKU_CHECKS_SKIPPED=$(config_get "$APP" DOKKU_CHECKS_SKIPPED || true)
local DOKKU_CHECKS_DISABLED=$(fn-plugin-property-get-default "checks" "$APP" "disabled" "")
local DOKKU_CHECKS_SKIPPED=$(fn-plugin-property-get-default "checks" "$APP" "skipped" "")
if [[ "$PROCTYPES" == "_all_" ]]; then
dokku_log_info1 "Disabling zero downtime for app ($APP)"
DOKKU_QUIET_OUTPUT=1 config_set --no-restart "$APP" DOKKU_CHECKS_DISABLED="$PROCTYPES"
DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$APP" DOKKU_CHECKS_SKIPPED
fn-plugin-property-write "checks" "$APP" "disabled" "$PROCTYPES"
fn-plugin-property-delete "checks" "$APP" "skipped"
else
dokku_log_info1 "Disabling zero downtime for app's ($APP) proctypes ($PROCTYPES)"
local PROCTYPE OIFS="$IFS" IFS=,
@@ -31,15 +31,15 @@ cmd-checks-disable() {
done
DOKKU_CHECKS_DISABLED="$(remove_val_from_list "_all_" "$DOKKU_CHECKS_DISABLED")"
if [[ -z "$DOKKU_CHECKS_DISABLED" ]]; then
DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$APP" DOKKU_CHECKS_DISABLED
fn-plugin-property-delete "checks" "$APP" "disabled"
else
DOKKU_QUIET_OUTPUT=1 config_set --no-restart "$APP" DOKKU_CHECKS_DISABLED="$DOKKU_CHECKS_DISABLED"
fn-plugin-property-write "checks" "$APP" "disabled" "$DOKKU_CHECKS_DISABLED"
fi
if [[ -z "$DOKKU_CHECKS_SKIPPED" ]]; then
DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$APP" DOKKU_CHECKS_SKIPPED
fn-plugin-property-delete "checks" "$APP" "skipped"
else
DOKKU_QUIET_OUTPUT=1 config_set --no-restart "$APP" DOKKU_CHECKS_SKIPPED="$DOKKU_CHECKS_SKIPPED"
fn-plugin-property-write "checks" "$APP" "skipped" "$DOKKU_CHECKS_SKIPPED"
fi
fi
}

View File

@@ -2,7 +2,7 @@
set -eo pipefail
[[ $DOKKU_TRACE ]] && set -x
source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions"
source "$PLUGIN_AVAILABLE_PATH/checks/functions"
source "$PLUGIN_CORE_AVAILABLE_PATH/common/property-functions"
cmd-checks-enable() {
declare desc="enable zero-downtime checks for app/proctypes"
@@ -13,13 +13,13 @@ cmd-checks-enable() {
verify_app_name "$APP"
local PROCTYPES="${2:-_all_}"
local DOKKU_CHECKS_DISABLED=$(config_get "$APP" DOKKU_CHECKS_DISABLED || true)
local DOKKU_CHECKS_SKIPPED=$(config_get "$APP" DOKKU_CHECKS_SKIPPED || true)
local DOKKU_CHECKS_DISABLED=$(fn-plugin-property-get-default "checks" "$APP" "disabled" "")
local DOKKU_CHECKS_SKIPPED=$(fn-plugin-property-get-default "checks" "$APP" "skipped" "")
if [[ "$PROCTYPES" == "_all_" ]]; then
dokku_log_info1 "Enabling zero downtime for app's ($APP)"
DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$APP" DOKKU_CHECKS_DISABLED
DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$APP" DOKKU_CHECKS_SKIPPED
fn-plugin-property-delete "checks" "$APP" "disabled"
fn-plugin-property-delete "checks" "$APP" "skipped"
else
dokku_log_info1 "Enabling zero downtime for app's ($APP) proctypes ($PROCTYPES)"
local PROCTYPE OIFS="$IFS" IFS=,
@@ -30,15 +30,15 @@ cmd-checks-enable() {
IFS="$OIFS"
if [[ -z "$DOKKU_CHECKS_DISABLED" ]]; then
DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$APP" DOKKU_CHECKS_DISABLED
fn-plugin-property-delete "checks" "$APP" "disabled"
else
DOKKU_QUIET_OUTPUT=1 config_set --no-restart "$APP" DOKKU_CHECKS_DISABLED="$DOKKU_CHECKS_DISABLED"
fn-plugin-property-write "checks" "$APP" "disabled" "$DOKKU_CHECKS_DISABLED"
fi
if [[ -z "$DOKKU_CHECKS_SKIPPED" ]]; then
DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$APP" DOKKU_CHECKS_SKIPPED
fn-plugin-property-delete "checks" "$APP" "skipped"
else
DOKKU_QUIET_OUTPUT=1 config_set --no-restart "$APP" DOKKU_CHECKS_SKIPPED="$DOKKU_CHECKS_SKIPPED"
fn-plugin-property-write "checks" "$APP" "skipped" "$DOKKU_CHECKS_SKIPPED"
fi
fi
}

View File

@@ -2,7 +2,7 @@
set -eo pipefail
[[ $DOKKU_TRACE ]] && set -x
source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions"
source "$PLUGIN_AVAILABLE_PATH/checks/functions"
source "$PLUGIN_CORE_AVAILABLE_PATH/common/property-functions"
cmd-checks-skip() {
declare desc="skip zero-downtime checks for app/proctypes"
@@ -12,13 +12,13 @@ cmd-checks-skip() {
verify_app_name "$APP"
local PROCTYPES="${2:-_all_}"
local DOKKU_CHECKS_DISABLED=$(config_get "$APP" DOKKU_CHECKS_DISABLED || true)
local DOKKU_CHECKS_SKIPPED=$(config_get "$APP" DOKKU_CHECKS_SKIPPED || true)
local DOKKU_CHECKS_DISABLED=$(fn-plugin-property-get-default "checks" "$APP" "disabled" "")
local DOKKU_CHECKS_SKIPPED=$(fn-plugin-property-get-default "checks" "$APP" "skipped" "")
if [[ "$PROCTYPES" == "_all_" ]]; then
dokku_log_info1 "Skipping zero downtime for app ($APP)"
DOKKU_QUIET_OUTPUT=1 config_set --no-restart "$APP" DOKKU_CHECKS_SKIPPED="$PROCTYPES"
DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$APP" DOKKU_CHECKS_DISABLED
fn-plugin-property-write "checks" "$APP" "skipped" "$PROCTYPES"
fn-plugin-property-delete "checks" "$APP" "disabled"
else
dokku_log_info1 "Skipping zero downtime for app's ($APP) proctypes ($PROCTYPES)"
local PROCTYPE OIFS="$IFS" IFS=,
@@ -31,15 +31,15 @@ cmd-checks-skip() {
done
DOKKU_CHECKS_SKIPPED="$(remove_val_from_list "_all_" "$DOKKU_CHECKS_SKIPPED")"
if [[ -z "$DOKKU_CHECKS_DISABLED" ]]; then
DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$APP" DOKKU_CHECKS_DISABLED
fn-plugin-property-delete "checks" "$APP" "disabled"
else
DOKKU_QUIET_OUTPUT=1 config_set --no-restart "$APP" DOKKU_CHECKS_DISABLED="$DOKKU_CHECKS_DISABLED"
fn-plugin-property-write "checks" "$APP" "disabled" "$DOKKU_CHECKS_DISABLED"
fi
if [[ -z "$DOKKU_CHECKS_SKIPPED" ]]; then
DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$APP" DOKKU_CHECKS_SKIPPED
fn-plugin-property-delete "checks" "$APP" "skipped"
else
DOKKU_QUIET_OUTPUT=1 config_set --no-restart "$APP" DOKKU_CHECKS_SKIPPED="$DOKKU_CHECKS_SKIPPED"
fn-plugin-property-write "checks" "$APP" "skipped" "$DOKKU_CHECKS_SKIPPED"
fi
fi
}

View File

@@ -606,37 +606,12 @@ func GetRunningImageTag(appName string, imageTag string) (string, error) {
// GetDokkuAppShell returns the shell for a given app
func GetDokkuAppShell(appName string) string {
shell := "/bin/bash"
globalShell := ""
appShell := ""
ctx := context.Background()
errs, ctx := errgroup.WithContext(ctx)
errs.Go(func() error {
results, _ := CallPlugnTriggerWithContext(ctx, PlugnTriggerInput{
Trigger: "config-get-global",
Args: []string{"DOKKU_APP_SHELL"},
})
globalShell = results.StdoutContents()
return nil
})
errs.Go(func() error {
results, _ := CallPlugnTriggerWithContext(ctx, PlugnTriggerInput{
Trigger: "config-get",
Args: []string{appName, "DOKKU_APP_SHELL"},
})
appShell = results.StdoutContents()
return nil
})
errs.Wait()
appShell := PropertyGet("scheduler", appName, "shell")
if appShell != "" {
shell = appShell
} else if globalShell != "" {
shell = globalShell
return appShell
}
return shell
return PropertyGetDefault("scheduler", "--global", "shell", "/bin/bash")
}
// DokkuApps returns a list of all local apps
@@ -738,10 +713,18 @@ func GetenvWithDefault(key string, defaultValue string) (val string) {
return
}
// ReportArgs holds the parsed inputs to a :report subcommand
type ReportArgs struct {
OSArgs []string
InfoFlag string
IsGlobal bool
}
// ParseReportArgs splits out flags from non-flags for input into report commands
func ParseReportArgs(pluginName string, arguments []string) ([]string, string, error) {
func ParseReportArgs(pluginName string, arguments []string) (ReportArgs, error) {
osArgs := []string{}
infoFlags := []string{}
isGlobal := false
skipNext := false
for i, argument := range arguments {
if skipNext {
@@ -753,6 +736,10 @@ func ParseReportArgs(pluginName string, arguments []string) ([]string, string, e
skipNext = true
continue
}
if argument == "--global" {
isGlobal = true
continue
}
if strings.HasPrefix(argument, "--") {
infoFlags = append(infoFlags, argument)
} else {
@@ -761,12 +748,12 @@ func ParseReportArgs(pluginName string, arguments []string) ([]string, string, e
}
if len(infoFlags) == 0 {
return osArgs, "", nil
return ReportArgs{OSArgs: osArgs, IsGlobal: isGlobal}, nil
}
if len(infoFlags) == 1 {
return osArgs, infoFlags[0], nil
return ReportArgs{OSArgs: osArgs, InfoFlag: infoFlags[0], IsGlobal: isGlobal}, nil
}
return osArgs, "", fmt.Errorf("%s:report command allows only a single flag", pluginName)
return ReportArgs{OSArgs: osArgs, IsGlobal: isGlobal}, fmt.Errorf("%s:report command allows only a single flag", pluginName)
}
// ParseScaleOutput allows golang plugins to properly parse the output of ps-current-scale

View File

@@ -115,3 +115,81 @@ func TestCommonStripInlineComments(t *testing.T) {
text := StripInlineComments(strings.Join([]string{testEnvLine, "# testing comment"}, " "))
Expect(text).To(Equal(testEnvLine))
}
func TestParseReportArgsEmpty(t *testing.T) {
RegisterTestingT(t)
args, err := ParseReportArgs("scheduler", []string{})
Expect(err).NotTo(HaveOccurred())
Expect(args.IsGlobal).To(BeFalse())
Expect(args.InfoFlag).To(Equal(""))
Expect(args.OSArgs).To(BeEmpty())
}
func TestParseReportArgsAppOnly(t *testing.T) {
RegisterTestingT(t)
args, err := ParseReportArgs("scheduler", []string{"myapp"})
Expect(err).NotTo(HaveOccurred())
Expect(args.IsGlobal).To(BeFalse())
Expect(args.InfoFlag).To(Equal(""))
Expect(args.OSArgs).To(Equal([]string{"myapp"}))
}
func TestParseReportArgsFormatJSON(t *testing.T) {
RegisterTestingT(t)
args, err := ParseReportArgs("scheduler", []string{"myapp", "--format", "json"})
Expect(err).NotTo(HaveOccurred())
Expect(args.IsGlobal).To(BeFalse())
Expect(args.InfoFlag).To(Equal(""))
Expect(args.OSArgs).To(Equal([]string{"myapp", "--format", "json"}))
}
func TestParseReportArgsGlobalAlone(t *testing.T) {
RegisterTestingT(t)
args, err := ParseReportArgs("scheduler", []string{"--global"})
Expect(err).NotTo(HaveOccurred())
Expect(args.IsGlobal).To(BeTrue())
Expect(args.InfoFlag).To(Equal(""))
Expect(args.OSArgs).To(BeEmpty())
}
func TestParseReportArgsGlobalWithFormat(t *testing.T) {
RegisterTestingT(t)
args, err := ParseReportArgs("scheduler", []string{"--global", "--format", "json"})
Expect(err).NotTo(HaveOccurred())
Expect(args.IsGlobal).To(BeTrue())
Expect(args.InfoFlag).To(Equal(""))
Expect(args.OSArgs).To(Equal([]string{"--format", "json"}))
}
func TestParseReportArgsInfoFlag(t *testing.T) {
RegisterTestingT(t)
args, err := ParseReportArgs("scheduler", []string{"myapp", "--scheduler-selected"})
Expect(err).NotTo(HaveOccurred())
Expect(args.IsGlobal).To(BeFalse())
Expect(args.InfoFlag).To(Equal("--scheduler-selected"))
Expect(args.OSArgs).To(Equal([]string{"myapp"}))
}
func TestParseReportArgsAppThenGlobal(t *testing.T) {
RegisterTestingT(t)
args, err := ParseReportArgs("scheduler", []string{"myapp", "--global"})
Expect(err).NotTo(HaveOccurred())
Expect(args.IsGlobal).To(BeTrue())
Expect(args.InfoFlag).To(Equal(""))
Expect(args.OSArgs).To(Equal([]string{"myapp"}))
}
func TestParseReportArgsGlobalWithInfoFlag(t *testing.T) {
RegisterTestingT(t)
args, err := ParseReportArgs("scheduler", []string{"--global", "--scheduler-global-selected"})
Expect(err).NotTo(HaveOccurred())
Expect(args.IsGlobal).To(BeTrue())
Expect(args.InfoFlag).To(Equal("--scheduler-global-selected"))
Expect(args.OSArgs).To(BeEmpty())
}
func TestParseReportArgsMultipleInfoFlags(t *testing.T) {
RegisterTestingT(t)
_, err := ParseReportArgs("scheduler", []string{"--scheduler-selected", "--scheduler-global-selected"})
Expect(err).To(HaveOccurred())
}

View File

@@ -390,23 +390,17 @@ func DockerBin() string {
func DockerCleanup(appName string, forceCleanup bool) error {
if !forceCleanup {
skipCleanup := false
if appName != "" {
triggerName := "config-get"
triggerArgs := []string{appName, "DOKKU_SKIP_CLEANUP"}
if appName == "--global" {
triggerName = "config-get-global"
triggerArgs = []string{"DOKKU_SKIP_CLEANUP"}
}
results, _ := CallPlugnTrigger(PlugnTriggerInput{
Trigger: triggerName,
Args: triggerArgs,
})
if results.StdoutContents() == "true" {
if appName != "" && appName != "--global" {
if PropertyGet("builder", appName, "skip-cleanup") == "true" {
skipCleanup = true
}
}
if !skipCleanup && PropertyGet("builder", "--global", "skip-cleanup") == "true" {
skipCleanup = true
}
// Fallback for .dokkurc compatibility
if skipCleanup || os.Getenv("DOKKU_SKIP_CLEANUP") == "true" {
LogInfo1("DOKKU_SKIP_CLEANUP set. Skipping dokku cleanup")
return nil

View File

@@ -628,30 +628,43 @@ release_and_deploy() {
local APP="$1"
local IMAGE_TAG="${2:-latest}"
local SOURCE="${3:-deploy}"
dokku_setup_build_capture "$APP" "$SOURCE"
local IMAGE=$(get_app_image_name "$APP" "$IMAGE_TAG")
local exit_code=0
if verify_image "$IMAGE"; then
IMAGE_SOURCE_TYPE="$(get_image_builder_type "$IMAGE" "$APP")"
local DOKKU_APP_SKIP_DEPLOY="$(config_get "$APP" DOKKU_SKIP_DEPLOY || true)"
local DOKKU_GLOBAL_SKIP_DEPLOY="$(config_get --global DOKKU_SKIP_DEPLOY || true)"
local DOKKU_APP_SKIP_DEPLOY="$(fn-plugin-property-get-default "ps" "$APP" "skip-deploy" "")"
local DOKKU_GLOBAL_SKIP_DEPLOY="$(fn-plugin-property-get-default "ps" "--global" "skip-deploy" "")"
local DOKKU_SKIP_DEPLOY=${DOKKU_APP_SKIP_DEPLOY:="$DOKKU_GLOBAL_SKIP_DEPLOY"}
dokku_log_info1 "Releasing $APP..."
dokku_release "$APP" "$IMAGE_SOURCE_TYPE" "$IMAGE_TAG"
dokku_release "$APP" "$IMAGE_SOURCE_TYPE" "$IMAGE_TAG" || exit_code="$?"
if [[ "$DOKKU_SKIP_DEPLOY" != "true" ]]; then
if [[ "$exit_code" -eq 0 ]] && [[ "$DOKKU_SKIP_DEPLOY" != "true" ]]; then
local DOKKU_SCHEDULER=$(get_app_scheduler "$APP")
dokku_log_info1 "Deploying $APP via the $DOKKU_SCHEDULER scheduler..."
cmd-deploy "$APP" "$IMAGE_TAG"
dokku_log_info2 "Application deployed:"
plugn trigger domains-urls "$APP" urls | sed "s/^/ /"
else
cmd-deploy "$APP" "$IMAGE_TAG" || exit_code="$?"
if [[ "$exit_code" -eq 0 ]]; then
dokku_log_info2 "Application deployed:"
plugn trigger domains-urls "$APP" urls | sed "s/^/ /"
fi
elif [[ "$exit_code" -eq 0 ]]; then
dokku_log_info1 "Skipping deployment"
fi
echo
fi
if [[ -n "$DOKKU_BUILD_ID" ]]; then
plugn trigger builds-record-finalize "$APP" "$DOKKU_BUILD_ID" "$exit_code" || true
fi
return "$exit_code"
}
dokku_receive() {
@@ -847,6 +860,30 @@ merge_dedupe_list() {
echo "$merged_list"
}
dokku_setup_build_capture() {
declare desc="generate a build-id, write a record, and redirect output to log + journald"
declare APP="$1" SOURCE="${2:-unknown}"
[[ "$DOKKU_REDIRECT_OUTPUT" == "true" ]] && return 0
if [[ -z "$APP" ]]; then
return 0
fi
export DOKKU_REDIRECT_OUTPUT=true
if [[ -z "$DOKKU_BUILD_ID" ]]; then
DOKKU_BUILD_ID="$(plugn trigger builds-generate-id 2>/dev/null)"
if [[ -z "$DOKKU_BUILD_ID" ]]; then
DOKKU_BUILD_ID="pid-${DOKKU_PID}"
fi
export DOKKU_BUILD_ID
fi
export DOKKU_BUILD_SOURCE="$SOURCE"
local DIR="$DOKKU_LIB_ROOT/data/builds/$APP"
local LOG="$DIR/${DOKKU_BUILD_ID}.log"
mkdir -p "$DIR" && : >"$LOG"
export DOKKU_BUILD_LOG_FILE="$LOG"
plugn trigger builds-record-start "$APP" "$DOKKU_BUILD_ID" "$$" "$SOURCE" || true
exec &> >(tee -a "$LOG" >(logger -i -t "dokku-${DOKKU_BUILD_ID}"))
}
acquire_app_deploy_lock() {
declare desc="acquire advisory lock for use in deploys"
local APP="$1"
@@ -855,14 +892,27 @@ acquire_app_deploy_lock() {
local LOCK_WAITING_MSG="$APP currently has a deploy lock in place. Waiting..."
local LOCK_FAILED_MSG="$APP currently has a deploy lock in place. Exiting..."
if [[ -z "$DOKKU_BUILD_ID" ]]; then
DOKKU_BUILD_ID="$(plugn trigger builds-generate-id 2>/dev/null)"
if [[ -z "$DOKKU_BUILD_ID" ]]; then
DOKKU_BUILD_ID="pid-${DOKKU_PID}"
fi
export DOKKU_BUILD_ID
fi
acquire_advisory_lock "$APP_DEPLOY_LOCK_FILE" "$LOCK_TYPE" "$LOCK_WAITING_MSG" "$LOCK_FAILED_MSG"
}
release_app_deploy_lock() {
declare desc="release advisory lock used in deploys"
local APP="$1"
local EXIT_CODE="${2:-0}"
local APP_DEPLOY_LOCK_FILE="$DOKKU_LIB_ROOT/data/apps/$APP/.deploy.lock"
if [[ -n "$DOKKU_BUILD_ID" ]]; then
plugn trigger builds-record-finalize "$APP" "$DOKKU_BUILD_ID" "$EXIT_CODE" || true
fi
release_advisory_lock "$APP_DEPLOY_LOCK_FILE"
}
@@ -890,7 +940,7 @@ acquire_advisory_lock() {
dokku_log_fail "Run 'apps:unlock' to release the existing deploy lock"
fi
fi
echo "$DOKKU_PID" >"$LOCK_FILE"
echo "${DOKKU_BUILD_ID:-$DOKKU_PID}" >"$LOCK_FILE"
}
release_advisory_lock() {
@@ -917,7 +967,7 @@ suppress_output() {
}
fn-migrate-config-to-property() {
declare desc="migrates deprecated config variables to property counterpart"
declare desc="[deprecated] migrates deprecated config variables to property counterpart. Use 'prop migrate-config-to-property' instead."
declare PLUGIN="$1" KEY="$2" CONFIG_VAR="$3" GLOBAL_CONFIG_VAR="$4"
# todo: refactor to remove config_unset usage
@@ -958,3 +1008,62 @@ fn-registry-docker-config-dir() {
echo "$config_dir"
fi
}
fn-report-parse-args() {
declare desc="strips --format and --global flags from a report invocation, populating REPORT_FORMAT, REPORT_IS_GLOBAL, REPORT_ARGS"
REPORT_FORMAT="stdout"
REPORT_IS_GLOBAL="false"
REPORT_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--format)
REPORT_FORMAT="$2"
shift 2
;;
--global)
REPORT_IS_GLOBAL="true"
shift
;;
*)
REPORT_ARGS+=("$1")
shift
;;
esac
done
}
fn-report-emit-json() {
declare desc="serializes a flag_map array (passed by name) as a JSON object, stripping the --<prefix>- prefix from each key"
declare flag_map_name="$1" prefix="$2"
local -n flag_map_ref="$flag_map_name"
local json_output="{}"
for flag in "${flag_map_ref[@]}"; do
local key value
key="$(echo "$flag" | cut -d':' -f1 | sed "s/^--${prefix}-//")"
value="$(echo "$flag" | cut -d':' -f2- | sed 's/^ //')"
json_output="$(echo "$json_output" | jq -c --arg key "$key" --arg value "$value" '. + {($key): $value}')"
done
echo "$json_output"
}
fn-report-filter-global() {
declare desc="filters a flag_map array (passed by name) in place, keeping only entries with -global- in their flag name"
declare flag_map_name="$1"
local -n flag_map_ref="$flag_map_name"
local filtered=()
for flag in "${flag_map_ref[@]}"; do
local key="$(echo "$flag" | cut -d':' -f1)"
if [[ "$key" == *"-global-"* ]]; then
filtered+=("$flag")
fi
done
flag_map_ref=("${filtered[@]}")
}
fn-report-validate-format() {
declare desc="rejects --format json when an info flag is also specified"
declare FORMAT="$1" INFO_FLAG="$2"
if [[ "$FORMAT" != "stdout" ]] && [[ -n "$INFO_FLAG" ]]; then
dokku_log_fail "--format flag cannot be specified when specifying an info flag"
fi
}

View File

@@ -1,13 +1,13 @@
module github.com/dokku/dokku/plugins/common
go 1.25.5
go 1.26.2
require (
github.com/alexellis/go-execute/v2 v2.2.1
github.com/fatih/color v1.19.0
github.com/hashicorp/go-multierror v1.1.1
github.com/melbahja/goph v1.5.0
github.com/onsi/gomega v1.39.1
github.com/onsi/gomega v1.40.0
github.com/otiai10/copy v1.14.1
github.com/ryanuber/columnize v2.1.2+incompatible
github.com/spf13/pflag v1.0.10
@@ -19,6 +19,7 @@ require (
github.com/google/go-cmp v0.7.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/otiai10/mint v1.6.3 // indirect

View File

@@ -1,35 +1,30 @@
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/alexellis/go-execute/v2 v2.2.1 h1:4Ye3jiCKQarstODOEmqDSRCqxMHLkC92Bhse743RdOI=
github.com/alexellis/go-execute/v2 v2.2.1/go.mod h1:FMdRnUTiFAmYXcv23txrp3VYZfLo24nMpiIneWgKHTQ=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/melbahja/goph v1.5.0 h1:RQUBpLvfg3i7fjfG8rTcSWyMjVRfdhwrrfQhjYee4dQ=
github.com/melbahja/goph v1.5.0/go.mod h1:dDwo+44cmvfDLdiVpc6fJxexf5BA5yEDUeE5YgtuDO4=
github.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc=
github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc=
github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8=
github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I=
github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs=
@@ -40,6 +35,8 @@ github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk=
github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
@@ -50,8 +47,6 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
@@ -63,9 +58,8 @@ golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -541,6 +541,10 @@ func PropertySetup(pluginName string) error {
}
func PropertySetupApp(pluginName string, appName string) error {
if err := PropertySetup(pluginName); err != nil {
return err
}
if err := makePluginAppPropertyPath(pluginName, appName); err != nil {
return fmt.Errorf("Unable to create %s config directory for %s: %s", pluginName, appName, err.Error())
}
@@ -548,6 +552,121 @@ func PropertySetupApp(pluginName string, appName string) error {
return nil
}
// MigrateConfigEntry describes a single config-to-property migration
type MigrateConfigEntry struct {
// ConfigVar is the per-app environment variable name (e.g. "DOKKU_APP_PROXY_TYPE")
ConfigVar string
// GlobalConfigVar is the global environment variable name (e.g. "DOKKU_PROXY_TYPE"), empty if none
GlobalConfigVar string
// Property is the target property name (e.g. "type")
Property string
// Transform is an optional value transformation function applied before writing
Transform func(value string) string
// ListProperty indicates the value should be written as a list property via PropertyListWrite
ListProperty bool
}
// MigrateConfigToProperties migrates config variables to properties for a given plugin
// across all apps and optionally globally. It is idempotent: if the property already
// exists, the migration is skipped for that app/entry.
func MigrateConfigToProperties(pluginName string, entries []MigrateConfigEntry) error {
apps, err := UnfilteredDokkuApps()
if err != nil && !errors.Is(err, NoAppsExist) {
return nil
}
for _, entry := range entries {
if entry.GlobalConfigVar != "" {
if err := migrateConfigEntry(pluginName, "--global", entry.GlobalConfigVar, entry); err != nil {
return err
}
}
for _, appName := range apps {
if entry.ConfigVar == "" {
continue
}
if err := migrateConfigEntry(pluginName, appName, entry.ConfigVar, entry); err != nil {
return err
}
}
}
return nil
}
// migrateConfigEntry migrates a single config variable to a property for a given app
func migrateConfigEntry(pluginName string, appName string, configVar string, entry MigrateConfigEntry) error {
if entry.ListProperty {
if exists, _ := PropertyListLength(pluginName, appName, entry.Property); exists > 0 {
return nil
}
} else if PropertyExists(pluginName, appName, entry.Property) {
return nil
}
triggerName := "config-get"
triggerArgs := []string{appName, configVar}
if appName == "--global" {
triggerName = "config-get-global"
triggerArgs = []string{configVar}
}
results, _ := CallPlugnTrigger(PlugnTriggerInput{
Trigger: triggerName,
Args: triggerArgs,
})
value := results.StdoutContents()
if value == "" {
return nil
}
if entry.Transform != nil {
value = entry.Transform(value)
}
if appName == "--global" {
LogInfo1(fmt.Sprintf("Migrating deprecated global %s to %s %s property. Use 'dokku %s:set --global %s <value>' to manage this going forward.", configVar, pluginName, entry.Property, pluginName, entry.Property))
} else {
LogInfo1(fmt.Sprintf("Migrating deprecated %s to %s %s property for %s. Use 'dokku %s:set %s %s <value>' to manage this going forward.", configVar, pluginName, entry.Property, appName, pluginName, appName, entry.Property))
}
if entry.ListProperty {
values := strings.Split(value, " ")
if err := PropertyListWrite(pluginName, appName, entry.Property, values); err != nil {
return err
}
} else {
if err := PropertyWrite(pluginName, appName, entry.Property, value); err != nil {
return err
}
}
unsetTrigger := "config-unset"
unsetArgs := []string{appName, configVar}
if appName == "--global" {
unsetArgs = []string{"--global", configVar}
}
_, err := CallPlugnTrigger(PlugnTriggerInput{
Trigger: unsetTrigger,
Args: unsetArgs,
Env: map[string]string{
"DOKKU_QUIET_OUTPUT": "1",
},
})
if err != nil {
LogWarn(err.Error())
}
return nil
}
func getPropertyPath(pluginName string, appName string, property string) string {
pluginAppConfigRoot := getPluginAppPropertyPath(pluginName, appName)
return filepath.Join(pluginAppConfigRoot, property)

View File

@@ -192,6 +192,76 @@ func main() {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
case "migrate-config-to-property":
property := flag.Arg(2)
configVar := flag.Arg(3)
globalConfigVar := ""
transformName := ""
listProperty := false
for i := 4; i < flag.NArg(); i++ {
arg := flag.Arg(i)
switch {
case arg == "--list":
listProperty = true
case arg == "--global-config-var" && i+1 < flag.NArg():
i++
globalConfigVar = flag.Arg(i)
case arg == "--transform" && i+1 < flag.NArg():
i++
transformName = flag.Arg(i)
}
}
var transform func(string) string
switch transformName {
case "restore-bool":
transform = func(value string) string {
if value == "0" {
return "false"
}
return "true"
}
case "non-empty-to-true":
transform = func(value string) string {
if value != "" {
return "true"
}
return value
}
case "skip-all-checks-to-disabled":
transform = func(value string) string {
if value == "true" {
return "_all_"
}
return ""
}
case "checks-enabled-to-skipped":
transform = func(value string) string {
if value == "0" {
return "_all_"
}
return ""
}
case "":
// no transform
default:
fmt.Fprintf(os.Stderr, "Unknown transform: %s\n", transformName)
os.Exit(1)
}
entry := common.MigrateConfigEntry{
ConfigVar: configVar,
GlobalConfigVar: globalConfigVar,
Property: property,
Transform: transform,
ListProperty: listProperty,
}
if err := common.MigrateConfigToProperties(pluginName, []common.MigrateConfigEntry{entry}); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
case "set":
appName := flag.Arg(2)
property := flag.Arg(3)

View File

@@ -5,3 +5,4 @@
/config-*
/config_sub
/post-*
/install

View File

@@ -1,6 +1,6 @@
GOARCH ?= amd64
SUBCOMMANDS = subcommands/bundle subcommands/clear subcommands/export subcommands/get subcommands/import subcommands/keys subcommands/show subcommands/set subcommands/unset
TRIGGERS = triggers/config-export triggers/config-get triggers/config-get-global triggers/config-unset triggers/post-app-clone-setup triggers/post-app-rename-setup
TRIGGERS = triggers/config-export triggers/config-get triggers/config-get-global triggers/install triggers/config-set triggers/config-unset triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-create triggers/post-delete
BUILD = commands config_sub subcommands triggers
PLUGIN_NAME = config

View File

@@ -62,7 +62,7 @@ func SetMany(appName string, entries map[string]string, replace bool, restart bo
})
triggerUpdate(appName, "set", keys)
}
if !global && restart && env.GetBoolDefault("DOKKU_APP_RESTORE", true) {
if !global && restart && shouldRestart(appName) {
triggerRestart(appName)
}
return
@@ -98,7 +98,7 @@ func UnsetMany(appName string, keys []string, restart bool) (err error) {
})
triggerUpdate(appName, "unset", keys)
}
if !global && restart && env.GetBoolDefault("DOKKU_APP_RESTORE", true) {
if !global && restart && shouldRestart(appName) {
triggerRestart(appName)
}
return
@@ -125,12 +125,20 @@ func UnsetAll(appName string, restart bool) (err error) {
})
triggerUpdate(appName, "clear", []string{})
}
if !global && restart && env.GetBoolDefault("DOKKU_APP_RESTORE", true) {
if !global && restart && shouldRestart(appName) {
triggerRestart(appName)
}
return
}
func shouldRestart(appName string) bool {
results, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "ps-get-property",
Args: []string{appName, "restore"},
})
return results.StdoutContents() != "false"
}
func triggerRestart(appName string) {
if !common.IsDeployed(appName) {
return
@@ -139,7 +147,7 @@ func triggerRestart(appName string) {
common.LogInfo1(fmt.Sprintf("Restarting app %s", appName))
_, err := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "release-and-deploy",
Args: []string{appName},
Args: []string{appName, "", "config-redeploy"},
StreamStdio: true,
})
if err != nil {

View File

@@ -2,6 +2,7 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -13,8 +14,10 @@ import (
var (
testAppName = "test-app-1"
dokkuRoot = common.MustGetEnv("DOKKU_ROOT")
testAppDir = strings.Join([]string{dokkuRoot, testAppName}, "/")
globalConfigFile = strings.Join([]string{dokkuRoot, "ENV"}, "/")
dokkuLibRoot = common.MustGetEnv("DOKKU_LIB_ROOT")
testAppRoot = filepath.Join(dokkuRoot, testAppName)
testAppDir = filepath.Join(dokkuLibRoot, "config", testAppName)
globalConfigFile = filepath.Join(dokkuLibRoot, "config", "--global", "ENV")
)
func setupTests() (err error) {
@@ -26,12 +29,14 @@ func setupTests() (err error) {
}
func setupTestApp() (err error) {
Expect(os.MkdirAll(testAppRoot, 0766)).To(Succeed())
Expect(os.MkdirAll(testAppDir, 0766)).To(Succeed())
b := []byte("export testKey=TESTING\n")
if err = os.WriteFile(strings.Join([]string{testAppDir, "/ENV"}, ""), b, 0644); err != nil {
return
}
Expect(os.MkdirAll(filepath.Dir(globalConfigFile), 0766)).To(Succeed())
b = []byte("export testKey=GLOBAL_TESTING\nexport globalKey=GLOBAL_VALUE")
if err = os.WriteFile(globalConfigFile, b, 0644); err != nil {
return
@@ -40,6 +45,7 @@ func setupTestApp() (err error) {
}
func teardownTestApp() {
os.RemoveAll(testAppRoot)
os.RemoveAll(testAppDir)
}

Some files were not shown because too many files have changed in this diff Show More