diff --git a/.github/workflows/dependency-updates.yml b/.github/workflows/dependency-updates.yml new file mode 100644 index 000000000..9573646e7 --- /dev/null +++ b/.github/workflows/dependency-updates.yml @@ -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/(?[^/]+/[^/]+)/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 ' + author: 'Dokku Bot ' + 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 diff --git a/common.mk b/common.mk index a5dc687d9..0160634aa 100644 --- a/common.mk +++ b/common.mk @@ -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) diff --git a/contrib/dependencies.json b/contrib/dependencies.json index 7152bc043..12a04aeab 100644 --- a/contrib/dependencies.json +++ b/contrib/dependencies.json @@ -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" } } ] diff --git a/debian/config b/debian/config index 99bba1b23..2a7b4576a 100755 --- a/debian/config +++ b/debian/config @@ -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 diff --git a/debian/postinst b/debian/postinst index 8bef386ac..06860f8d0 100755 --- a/debian/postinst +++ b/debian/postinst @@ -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 ;; *) diff --git a/debian/templates b/debian/templates index 93e7a9768..51403a24e 100644 --- a/debian/templates +++ b/debian/templates @@ -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 diff --git a/docs/README.md b/docs/README.md index 08ce6096d..00e8acd4a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/_build/requirements.txt b/docs/_build/requirements.txt index bca72adbd..bc27a969b 100644 --- a/docs/_build/requirements.txt +++ b/docs/_build/requirements.txt @@ -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 diff --git a/docs/advanced-usage/builds.md b/docs/advanced-usage/builds.md new file mode 100644 index 000000000..0a3fc16d9 --- /dev/null +++ b/docs/advanced-usage/builds.md @@ -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 # Cancel a running build for an app +builds:info [--format json] # Show details for a single build +builds:list [] [--format json] [--kind ...] [--status ...] + # List builds (running across all apps, or running + history for one) +builds:output [|current] # Show build output (tail for live, cat for finished) +builds:prune [--all-apps] # Reap abandoned records and apply retention +builds:report [] [] # Display a build report +builds:set [--global|] [] # 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//.json` - the structured record +- `$DOKKU_LIB_ROOT/data/builds//.log` - the captured stdout/stderr of the deploy + +Output is also tagged into syslog as `dokku-` so `journalctl -t dokku-` 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-`. + +## 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. diff --git a/docs/advanced-usage/docker-options.md b/docs/advanced-usage/docker-options.md index 24b24a00c..0d70721f2 100644 --- a/docs/advanced-usage/docker-options.md +++ b/docs/advanced-usage/docker-options.md @@ -4,10 +4,11 @@ > New as of 0.3.17 ``` -docker-options:add OPTION # Add Docker option to app for phase (comma-separated phase list) -docker-options:clear [...] # Clear a docker options from app -docker-options:remove OPTION # Remove Docker option from app for phase (comma-separated phase list) -docker-options:report [] [] # Displays a docker options report for one or more apps +docker-options:add [--process PROC...] OPTION # Add Docker option to app for phase +docker-options:clear [--process PROC...] [...] # Clear docker options from app +docker-options:list [--process PROC] --phase PHASE # List docker options for one process+phase pair +docker-options:remove [--process PROC...] OPTION # Remove Docker option from app for phase +docker-options:report [] [] [--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.`: + +```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 +``` diff --git a/docs/advanced-usage/persistent-storage.md b/docs/advanced-usage/persistent-storage.md index d2289976a..c033e734e 100644 --- a/docs/advanced-usage/persistent-storage.md +++ b/docs/advanced-usage/persistent-storage.md @@ -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] # Creates a persistent storage directory in the recommended storage path -storage:list [--format text|json] # List bind mounts for app's container(s) (host:container) -storage:mount # Create a new bind mount -storage:report [] [] # Displays a checks report for one or more apps -storage:unmount # Remove an existing bind mount +storage:create [] [flags] # Register a named storage entry +storage:destroy # Remove a named storage entry (must be unmounted from every app first) +storage:ensure-directory [--chown option] # [DEPRECATED] use storage:create instead +storage:exec [-- ...] # Run a command (or shell) in a temporary container that mounts the entry +storage:info [--format text|json] # Show details for one storage entry +storage:list [--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 --container-dir [flags] # Mount a named entry into an app +storage:mount # [LEGACY] colon-form mount, docker-local only +storage:report [] [] # Display a storage report for one or more apps +storage:report --global # Display a cluster-wide entry inventory +storage:set [flags] # Update a storage entry in place +storage:unmount [--container-dir ] # Remove an attachment +storage:wait # 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 :` 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-` 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 diff --git a/docs/advanced-usage/resource-management.md b/docs/advanced-usage/resource-management.md index e24b56b32..804397489 100644 --- a/docs/advanced-usage/resource-management.md +++ b/docs/advanced-usage/resource-management.md @@ -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. diff --git a/docs/appendices/0.38.0-migration-guide.md b/docs/appendices/0.38.0-migration-guide.md new file mode 100644 index 000000000..96f0bfbfd --- /dev/null +++ b/docs/appendices/0.38.0-migration-guide.md @@ -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 :` colon form continues to work on docker-local apps but is deprecated; on k3s apps it is rejected. Existing colon-form mounts are migrated automatically the first time the new storage plugin runs (during the install trigger) - they appear as `legacy-` entries in `storage:list-entries`. The migration is idempotent and tied to a per-app flag file at `$DOKKU_LIB_ROOT/config/storage/.migrated/`; deleting that file forces a re-scan on the next install. The `storage:ensure-directory` command keeps working but now emits a deprecation warning - prefer `storage:create []` (the path defaults to the same `$DOKKU_LIB_ROOT/data/storage/` location). Storage entry names must now be DNS-1123 labels of 45 characters or less so they can be used verbatim as Helm release and Kubernetes resource names; underscores and uppercase characters that the older `ensure-directory` validator accepted are rejected for new names. The migration synthesizer always uses lowercase hex hashes so existing data is never locked out. + +### 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. diff --git a/docs/appendices/file-formats/app-json.md b/docs/appendices/file-formats/app-json.md index 53924fabc..6a0cf767b 100644 --- a/docs/appendices/file-formats/app-json.md +++ b/docs/appendices/file-formats/app-json.md @@ -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 diff --git a/docs/appendices/file-formats/nginx-conf-sigil.md b/docs/appendices/file-formats/nginx-conf-sigil.md index 8f97fa58f..3cb67eecf 100644 --- a/docs/appendices/file-formats/nginx-conf-sigil.md +++ b/docs/appendices/file-formats/nginx-conf-sigil.md @@ -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. diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index dd805bd0f..d862ba640 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -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`
| Deprecated: Whether to keep `dokku run` containers around or not. | | `DOKKU_TRACE` | none | `dokku trace:on`
`dokku trace:off`
`--trace` flag | Turn on very verbose debugging. | -| `DOKKU_APP_PROXY_TYPE` | `nginx` | `dokku proxy:set` | | -| `DOKKU_APP_RESTORE` | `1` | `dokku config:set`
`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`
`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`
`~dokku/.dokkurc`
`~dokku/.dokkurc/*`
`dokku config:set` | | -| `DOKKU_PROXY_SSL_PORT` | automatically assigned | `/etc/environment`
`~dokku/.dokkurc`
`~dokku/.dokkurc/*`
`dokku config:set` | | -| `DOKKU_SKIP_ALL_CHECKS` | none | `dokku config:set` | | -| `DOKKU_SKIP_CLEANUP` | | `/etc/environment`
`~dokku/.dokkurc`
`~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`
`~dokku/.dokkurc`
`~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`
`~dokku/.dokkurc`
`~dokku/.dokkurc/*` | System group to chown files as. | | `DOKKU_SYSTEM_USER` | `dokku` | `/etc/environment`
`~dokku/.dokkurc`
`~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 type ` | +| `DOKKU_APP_RESTORE` | `dokku ps:set restore ` | +| `DOKKU_APP_SHELL` | `dokku scheduler:set shell ` | +| `DOKKU_CHECKS_DISABLED` | `dokku checks:disable [proctypes]` | +| `DOKKU_CHECKS_ENABLED` | `dokku checks:enable [proctypes]` | +| `DOKKU_CHECKS_SKIPPED` | `dokku checks:skip [proctypes]` | +| `DOKKU_CHECKS_WAIT` | `dokku checks:set wait ` | +| `DOKKU_CHECKS_TIMEOUT` | `dokku checks:set timeout ` | +| `DOKKU_CHECKS_ATTEMPTS` | `dokku checks:set attempts ` | +| `DOKKU_DEFAULT_CHECKS_WAIT` | `dokku checks:set --global default-wait ` | +| `DOKKU_DISABLE_APP_AUTOCREATION` | `dokku apps:set --global disable-autocreation ` | +| `DOKKU_DISABLE_PROXY` | `dokku proxy:disable ` / `dokku proxy:enable ` | +| `DOKKU_DOCKERFILE_START_CMD` | `dokku ps:set dockerfile-start-cmd ` | +| `DOKKU_PARALLEL_ARGUMENTS` | Removed. No longer supported. | +| `DOKKU_PROXY_PORT` | `dokku proxy:set proxy-port ` | +| `DOKKU_PROXY_SSL_PORT` | `dokku proxy:set proxy-ssl-port ` | +| `DOKKU_SKIP_ALL_CHECKS` | `dokku checks:disable ` | +| `DOKKU_SKIP_CLEANUP` | `dokku builder:set skip-cleanup ` | +| `DOKKU_SKIP_DEFAULT_CHECKS` | `dokku checks:skip ` | +| `DOKKU_SKIP_DEPLOY` | `dokku ps:set skip-deploy ` | +| `DOKKU_START_CMD` | `dokku ps:set start-cmd ` | diff --git a/docs/deployment/application-deployment.md b/docs/deployment/application-deployment.md index 3c61a2c90..9f45c4d40 100644 --- a/docs/deployment/application-deployment.md +++ b/docs/deployment/application-deployment.md @@ -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 diff --git a/docs/deployment/builders/buildpack-management.md b/docs/deployment/builders/buildpack-management.md index c577e26c9..a2b876b9f 100644 --- a/docs/deployment/builders/buildpack-management.md +++ b/docs/deployment/builders/buildpack-management.md @@ -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: diff --git a/docs/deployment/methods/git.md b/docs/deployment/methods/git.md index b27371a07..77b3139a9 100644 --- a/docs/deployment/methods/git.md +++ b/docs/deployment/methods/git.md @@ -6,6 +6,7 @@ ``` git:allow-host # Adds a host to known_hosts git:auth [ ] # Configures netrc authentication for a given git server +git:auth-status [ ] # Reports whether the netrc entry matches the requested state git:from-archive [--archive-type ARCHIVE_TYPE] [ ] # Updates an app's git repository with a given archive file git:from-image [--build-dir DIRECTORY] [ ] # 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] [ [] # Clone or fetch an app from remote git repo git:initialize # Initialize a git repository for an app git:public-key # Outputs the dokku public deploy key -git:report [] [] # Displays a git report for one or more apps +git:report [] [|--format json] # Displays a git report for one or more apps git:set () # Set or clear a git property for an app git:status # 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. diff --git a/docs/deployment/schedulers/k3s.md b/docs/deployment/schedulers/k3s.md index 975c51061..b3fa64432 100644 --- a/docs/deployment/schedulers/k3s.md +++ b/docs/deployment/schedulers/k3s.md @@ -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 --container-dir ` 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 `` 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 :` 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 diff --git a/docs/deployment/zero-downtime-deploys.md b/docs/deployment/zero-downtime-deploys.md index e769b2193..56bb1cf23 100644 --- a/docs/deployment/zero-downtime-deploys.md +++ b/docs/deployment/zero-downtime-deploys.md @@ -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 `. -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 wait `. +- `timeout`: (default: `30`) Wait this many seconds for each response before marking it as a failure. Set via `dokku checks:set timeout `. +- `attempts`: (default: `5`) Number of retries for to run for a specific check before marking it as a failure. Set via `dokku checks:set attempts `. ## Skipping and Disabling Checks diff --git a/docs/development/plugin-triggers.md b/docs/development/plugin-triggers.md index 35e809137..dc2399ace 100644 --- a/docs/development/plugin-triggers.md +++ b/docs/development/plugin-triggers.md @@ -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_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 `` 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 ` (override `entry.Chown`). ### `traefik-template-source` diff --git a/docs/getting-started/upgrading/index.md b/docs/getting-started/upgrading/index.md index 7c3ae989d..1fe40c8bd 100644 --- a/docs/getting-started/upgrading/index.md +++ b/docs/getting-started/upgrading/index.md @@ -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 ``` - diff --git a/docs/networking/proxies/nginx.md b/docs/networking/proxies/nginx.md index 330f5fa0f..473f7a408 100644 --- a/docs/networking/proxies/nginx.md +++ b/docs/networking/proxies/nginx.md @@ -5,7 +5,8 @@ Dokku uses nginx as its server for routing requests to specific applications. By ``` nginx:access-logs [-t] # Show the nginx access logs for an application (-t follows) nginx:error-logs [-t] # Show the nginx error logs for an application (-t follows) -nginx:report [] [] # Displays a nginx report for one or more apps +nginx:reload # Reloads the nginx server config +nginx:report [] [|--format json] # Displays a nginx report for one or more apps nginx:set () # Set or clear an nginx property for an app nginx:show-config # 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 diff --git a/docs/template.html b/docs/template.html index 0f3049701..9747d3bbc 100644 --- a/docs/template.html +++ b/docs/template.html @@ -188,6 +188,7 @@ Advanced Usage Backup and Recovery + Build Tracking Deployment Tasks Docker Container Options Docker Registry Management @@ -222,6 +223,17 @@ Community Contributions Clients Plugins + + File Formats + app.json + .buildpack + Dockerfile + lambda.yml + nginx.conf.sigil + nixpacks.toml + Procfile + project.toml + railpack.json
diff --git a/dokku b/dokku index 60caaa6e2..495e7aa9f 100755 --- a/dokku +++ b/dokku @@ -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 diff --git a/go.work b/go.work index 42c43feaf..bf66069d0 100644 --- a/go.work +++ b/go.work @@ -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 ) diff --git a/plugins/app-json/appjson.go b/plugins/app-json/appjson.go index 3133330c1..2aff5c217 100644 --- a/plugins/app-json/appjson.go +++ b/plugins/app-json/appjson.go @@ -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 diff --git a/plugins/app-json/functions.go b/plugins/app-json/functions.go index 55111a5de..5beeb3bda 100644 --- a/plugins/app-json/functions.go +++ b/plugins/app-json/functions.go @@ -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(), ¤tConfig); 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") +} diff --git a/plugins/app-json/go.mod b/plugins/app-json/go.mod index 19649d0f3..86f70509d 100644 --- a/plugins/app-json/go.mod +++ b/plugins/app-json/go.mod @@ -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 diff --git a/plugins/app-json/go.sum b/plugins/app-json/go.sum index 1db1549b6..55ec4581f 100644 --- a/plugins/app-json/go.sum +++ b/plugins/app-json/go.sum @@ -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= diff --git a/plugins/app-json/report.go b/plugins/app-json/report.go index 511f12302..5d472a4d2 100644 --- a/plugins/app-json/report.go +++ b/plugins/app-json/report.go @@ -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{} diff --git a/plugins/app-json/src/subcommands/subcommands.go b/plugins/app-json/src/subcommands/subcommands.go index e4879e02e..61a58edab 100644 --- a/plugins/app-json/src/subcommands/subcommands.go +++ b/plugins/app-json/src/subcommands/subcommands.go @@ -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) diff --git a/plugins/app-json/triggers.go b/plugins/app-json/triggers.go index 7f23273e9..93a512a7b 100644 --- a/plugins/app-json/triggers.go +++ b/plugins/app-json/triggers.go @@ -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") diff --git a/plugins/apps/Makefile b/plugins/apps/Makefile index 7bf928361..ff5fb0725 100644 --- a/plugins/apps/Makefile +++ b/plugins/apps/Makefile @@ -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 diff --git a/plugins/apps/apps.go b/plugins/apps/apps.go index 3259c6d4c..394bf803a 100644 --- a/plugins/apps/apps.go +++ b/plugins/apps/apps.go @@ -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, } ) diff --git a/plugins/apps/functions.go b/plugins/apps/functions.go index 96a3e04f0..f146dec1f 100644 --- a/plugins/apps/functions.go +++ b/plugins/apps/functions.go @@ -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) diff --git a/plugins/apps/go.mod b/plugins/apps/go.mod index 06adecc06..ffd57e1cf 100644 --- a/plugins/apps/go.mod +++ b/plugins/apps/go.mod @@ -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 diff --git a/plugins/apps/go.sum b/plugins/apps/go.sum index c97fc9665..e973ec8a5 100644 --- a/plugins/apps/go.sum +++ b/plugins/apps/go.sum @@ -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= diff --git a/plugins/apps/report.go b/plugins/apps/report.go index bce46ff17..1de1069c8 100644 --- a/plugins/apps/report.go +++ b/plugins/apps/report.go @@ -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{} diff --git a/plugins/apps/src/commands/commands.go b/plugins/apps/src/commands/commands.go index 3f6d37859..8cc38f158 100644 --- a/plugins/apps/src/commands/commands.go +++ b/plugins/apps/src/commands/commands.go @@ -28,6 +28,7 @@ Additional commands:` apps:locked , Checks if an app is locked for deployment apps:rename , Rename an app apps:report [] [], Display report about an app + apps:set |--global , Set or clear an apps property apps:unlock , Unlocks an app for deployment` ) diff --git a/plugins/apps/src/subcommands/subcommands.go b/plugins/apps/src/subcommands/subcommands.go index 0874f7084..29c0fe4c9 100644 --- a/plugins/apps/src/subcommands/subcommands.go +++ b/plugins/apps/src/subcommands/subcommands.go @@ -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:]) diff --git a/plugins/apps/subcommands.go b/plugins/apps/subcommands.go index 40291c2c8..8c02d33bc 100644 --- a/plugins/apps/subcommands.go +++ b/plugins/apps/subcommands.go @@ -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 { diff --git a/plugins/apps/triggers.go b/plugins/apps/triggers.go index 32bfbd515..78526e8a8 100644 --- a/plugins/apps/triggers.go +++ b/plugins/apps/triggers.go @@ -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") { diff --git a/plugins/builder-dockerfile/builder-build b/plugins/builder-dockerfile/builder-build index 751a4c2e6..158c55a1c 100755 --- a/plugins/builder-dockerfile/builder-build +++ b/plugins/builder-dockerfile/builder-build @@ -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) diff --git a/plugins/builder-dockerfile/internal-functions b/plugins/builder-dockerfile/internal-functions index 28438710f..cce54dae7 100755 --- a/plugins/builder-dockerfile/internal-functions +++ b/plugins/builder-dockerfile/internal-functions @@ -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#*: }")" diff --git a/plugins/builder-herokuish/builder-build b/plugins/builder-herokuish/builder-build index 4c75fcdac..7f955a06b 100755 --- a/plugins/builder-herokuish/builder-build +++ b/plugins/builder-herokuish/builder-build @@ -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 diff --git a/plugins/builder-herokuish/internal-functions b/plugins/builder-herokuish/internal-functions index 912b13f2e..a5b16af24 100755 --- a/plugins/builder-herokuish/internal-functions +++ b/plugins/builder-herokuish/internal-functions @@ -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#*: }")" diff --git a/plugins/builder-lambda/internal-functions b/plugins/builder-lambda/internal-functions index 7a4738bfc..832b156a5 100755 --- a/plugins/builder-lambda/internal-functions +++ b/plugins/builder-lambda/internal-functions @@ -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#*: }")" diff --git a/plugins/builder-nixpacks/internal-functions b/plugins/builder-nixpacks/internal-functions index d2c60a133..58ee7eea8 100755 --- a/plugins/builder-nixpacks/internal-functions +++ b/plugins/builder-nixpacks/internal-functions @@ -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#*: }")" diff --git a/plugins/builder-pack/docker-args-process-deploy b/plugins/builder-pack/docker-args-process-deploy index ae7d28ba6..da39a2a64 100755 --- a/plugins/builder-pack/docker-args-process-deploy +++ b/plugins/builder-pack/docker-args-process-deploy @@ -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" diff --git a/plugins/builder-pack/internal-functions b/plugins/builder-pack/internal-functions index e1d6ecbb3..74c806e48 100755 --- a/plugins/builder-pack/internal-functions +++ b/plugins/builder-pack/internal-functions @@ -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#*: }")" diff --git a/plugins/builder-railpack/internal-functions b/plugins/builder-railpack/internal-functions index 96dab71bc..b9c3fbf33 100755 --- a/plugins/builder-railpack/internal-functions +++ b/plugins/builder-railpack/internal-functions @@ -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#*: }")" diff --git a/plugins/builder/builder.go b/plugins/builder/builder.go index 928d1a01c..65f5877f9 100644 --- a/plugins/builder/builder.go +++ b/plugins/builder/builder.go @@ -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, } ) diff --git a/plugins/builder/go.mod b/plugins/builder/go.mod index 7f2691246..c0c910e10 100644 --- a/plugins/builder/go.mod +++ b/plugins/builder/go.mod @@ -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 diff --git a/plugins/builder/go.sum b/plugins/builder/go.sum index c97fc9665..e973ec8a5 100644 --- a/plugins/builder/go.sum +++ b/plugins/builder/go.sum @@ -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= diff --git a/plugins/builder/report.go b/plugins/builder/report.go index 3a755d01e..bedf9c39d 100644 --- a/plugins/builder/report.go +++ b/plugins/builder/report.go @@ -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{} diff --git a/plugins/builder/src/subcommands/subcommands.go b/plugins/builder/src/subcommands/subcommands.go index e6d692691..6ebc420f9 100644 --- a/plugins/builder/src/subcommands/subcommands.go +++ b/plugins/builder/src/subcommands/subcommands.go @@ -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) diff --git a/plugins/builder/triggers.go b/plugins/builder/triggers.go index a47c48a78..6db974f36 100644 --- a/plugins/builder/triggers.go +++ b/plugins/builder/triggers.go @@ -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", }) diff --git a/plugins/buildpacks/buildpacks_test.go b/plugins/buildpacks/buildpacks_test.go new file mode 100644 index 000000000..30fe99dae --- /dev/null +++ b/plugins/buildpacks/buildpacks_test.go @@ -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()) +} diff --git a/plugins/buildpacks/functions.go b/plugins/buildpacks/functions.go index 47acbbc78..89beebc3a 100644 --- a/plugins/buildpacks/functions.go +++ b/plugins/buildpacks/functions.go @@ -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) diff --git a/plugins/buildpacks/go.mod b/plugins/buildpacks/go.mod index 54905d796..2a72d8ec6 100644 --- a/plugins/buildpacks/go.mod +++ b/plugins/buildpacks/go.mod @@ -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 diff --git a/plugins/buildpacks/go.sum b/plugins/buildpacks/go.sum index c97fc9665..2477a51c0 100644 --- a/plugins/buildpacks/go.sum +++ b/plugins/buildpacks/go.sum @@ -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= diff --git a/plugins/buildpacks/report.go b/plugins/buildpacks/report.go index cec28af2a..dc6b0f1c1 100644 --- a/plugins/buildpacks/report.go +++ b/plugins/buildpacks/report.go @@ -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{} diff --git a/plugins/buildpacks/src/subcommands/subcommands.go b/plugins/buildpacks/src/subcommands/subcommands.go index 9062e80fa..428c0859b 100644 --- a/plugins/buildpacks/src/subcommands/subcommands.go +++ b/plugins/buildpacks/src/subcommands/subcommands.go @@ -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) diff --git a/plugins/buildpacks/triggers.go b/plugins/buildpacks/triggers.go index 37f1c33d7..fce9ecd5d 100644 --- a/plugins/buildpacks/triggers.go +++ b/plugins/buildpacks/triggers.go @@ -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 } diff --git a/plugins/builds/.gitignore b/plugins/builds/.gitignore new file mode 100644 index 000000000..ec242ac8a --- /dev/null +++ b/plugins/builds/.gitignore @@ -0,0 +1,10 @@ +/commands +/subcommands/* +/triggers/* +/triggers +/builds-generate-id +/builds-record-finalize +/builds-record-start +/install +/post-app-rename-setup +/post-delete diff --git a/plugins/builds/Makefile b/plugins/builds/Makefile new file mode 100644 index 000000000..f1ce6dc1f --- /dev/null +++ b/plugins/builds/Makefile @@ -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 diff --git a/plugins/builds/builds.go b/plugins/builds/builds.go new file mode 100644 index 000000000..80b0bed4b --- /dev/null +++ b/plugins/builds/builds.go @@ -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)) + } +} diff --git a/plugins/builds/builds_test.go b/plugins/builds/builds_test.go new file mode 100644 index 000000000..235a1315c --- /dev/null +++ b/plugins/builds/builds_test.go @@ -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)) + } +} diff --git a/plugins/builds/go.mod b/plugins/builds/go.mod new file mode 100644 index 000000000..c8e11b445 --- /dev/null +++ b/plugins/builds/go.mod @@ -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 diff --git a/plugins/builds/go.sum b/plugins/builds/go.sum new file mode 100644 index 000000000..37855810c --- /dev/null +++ b/plugins/builds/go.sum @@ -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= diff --git a/plugins/builds/plugin.toml b/plugins/builds/plugin.toml new file mode 100644 index 000000000..a92c9a547 --- /dev/null +++ b/plugins/builds/plugin.toml @@ -0,0 +1,4 @@ +[plugin] +description = "dokku core builds plugin" +version = "0.34.0" +[plugin.config] diff --git a/plugins/builds/report.go b/plugins/builds/report.go new file mode 100644 index 000000000..629111680 --- /dev/null +++ b/plugins/builds/report.go @@ -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)) +} diff --git a/plugins/builds/set.go b/plugins/builds/set.go new file mode 100644 index 000000000..edcf1a3a7 --- /dev/null +++ b/plugins/builds/set.go @@ -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 +} diff --git a/plugins/builds/src/commands/commands.go b/plugins/builds/src/commands/commands.go new file mode 100644 index 000000000..321d14a64 --- /dev/null +++ b/plugins/builds/src/commands/commands.go @@ -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 , Cancel a running build for an app + builds:info [--format json|stdout], Show details for a single build + builds:list [] [--format json] [--kind build|deploy] [--status ], List builds + builds:output [|current], Show build output + builds:prune [--all-apps], Prune build records to retention + builds:report [] [], Display a build report for one or more apps + builds:set [--global|] [], 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) +} diff --git a/plugins/builds/src/subcommands/subcommands.go b/plugins/builds/src/subcommands/subcommands.go new file mode 100644 index 000000000..cb9f68a74 --- /dev/null +++ b/plugins/builds/src/subcommands/subcommands.go @@ -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) + } +} diff --git a/plugins/builds/src/triggers/triggers.go b/plugins/builds/src/triggers/triggers.go new file mode 100644 index 000000000..9f283bd4e --- /dev/null +++ b/plugins/builds/src/triggers/triggers.go @@ -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) + } +} diff --git a/plugins/builds/subcommands.go b/plugins/builds/subcommands.go new file mode 100644 index 000000000..390d0d97d --- /dev/null +++ b/plugins/builds/subcommands.go @@ -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) +} diff --git a/plugins/builds/triggers.go b/plugins/builds/triggers.go new file mode 100644 index 000000000..2b44a16bf --- /dev/null +++ b/plugins/builds/triggers.go @@ -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: +// +// 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: +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) +} diff --git a/plugins/caddy-vhosts/command-functions b/plugins/caddy-vhosts/command-functions index 56817212e..231c5a4ad 100755 --- a/plugins/caddy-vhosts/command-functions +++ b/plugins/caddy-vhosts/command-functions @@ -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#*: }")" diff --git a/plugins/certs/internal-functions b/plugins/certs/internal-functions index 923f3faa3..cfda209bc 100755 --- a/plugins/certs/internal-functions +++ b/plugins/certs/internal-functions @@ -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#*: }")" diff --git a/plugins/checks/functions b/plugins/checks/functions index 05f94327b..ceebd61fe 100755 --- a/plugins/checks/functions +++ b/plugins/checks/functions @@ -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 diff --git a/plugins/checks/install b/plugins/checks/install index 801d8dd6f..4678db572 100755 --- a/plugins/checks/install +++ b/plugins/checks/install @@ -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] 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 ' 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 ' 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 ' 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 "$@" diff --git a/plugins/checks/internal-functions b/plugins/checks/internal-functions index ae9e21fe9..30dfe4c69 100755 --- a/plugins/checks/internal-functions +++ b/plugins/checks/internal-functions @@ -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" } diff --git a/plugins/checks/subcommands/disable b/plugins/checks/subcommands/disable index 8f6058cc2..6f0971ed6 100755 --- a/plugins/checks/subcommands/disable +++ b/plugins/checks/subcommands/disable @@ -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 } diff --git a/plugins/checks/subcommands/enable b/plugins/checks/subcommands/enable index 9431aca0b..858bf389c 100755 --- a/plugins/checks/subcommands/enable +++ b/plugins/checks/subcommands/enable @@ -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 } diff --git a/plugins/checks/subcommands/skip b/plugins/checks/subcommands/skip index ff8719a1f..7a242b6c9 100755 --- a/plugins/checks/subcommands/skip +++ b/plugins/checks/subcommands/skip @@ -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 } diff --git a/plugins/common/common.go b/plugins/common/common.go index a5045d67f..a3ec11acd 100644 --- a/plugins/common/common.go +++ b/plugins/common/common.go @@ -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 diff --git a/plugins/common/common_test.go b/plugins/common/common_test.go index e6f0faee5..f6fa98123 100644 --- a/plugins/common/common_test.go +++ b/plugins/common/common_test.go @@ -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()) +} diff --git a/plugins/common/docker.go b/plugins/common/docker.go index 11a0c1a39..b01514163 100644 --- a/plugins/common/docker.go +++ b/plugins/common/docker.go @@ -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 diff --git a/plugins/common/functions b/plugins/common/functions index d62d452f1..a3ed2c9ef 100755 --- a/plugins/common/functions +++ b/plugins/common/functions @@ -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 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 +} diff --git a/plugins/common/go.mod b/plugins/common/go.mod index a419d124b..440cb2d79 100644 --- a/plugins/common/go.mod +++ b/plugins/common/go.mod @@ -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 diff --git a/plugins/common/go.sum b/plugins/common/go.sum index f356a46b2..5caf40a68 100644 --- a/plugins/common/go.sum +++ b/plugins/common/go.sum @@ -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= diff --git a/plugins/common/properties.go b/plugins/common/properties.go index c6c6e3b3e..a91f8d5e7 100644 --- a/plugins/common/properties.go +++ b/plugins/common/properties.go @@ -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 ' 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 ' 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) diff --git a/plugins/common/src/prop/prop.go b/plugins/common/src/prop/prop.go index f5b788dc3..c95ff8c1d 100644 --- a/plugins/common/src/prop/prop.go +++ b/plugins/common/src/prop/prop.go @@ -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) diff --git a/plugins/config/.gitignore b/plugins/config/.gitignore index 3b7e2f512..63f3adbe8 100644 --- a/plugins/config/.gitignore +++ b/plugins/config/.gitignore @@ -5,3 +5,4 @@ /config-* /config_sub /post-* +/install diff --git a/plugins/config/Makefile b/plugins/config/Makefile index 1f1be8b3c..c1f6c067f 100644 --- a/plugins/config/Makefile +++ b/plugins/config/Makefile @@ -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 diff --git a/plugins/config/config.go b/plugins/config/config.go index 18d4d9403..5b898370d 100644 --- a/plugins/config/config.go +++ b/plugins/config/config.go @@ -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 { diff --git a/plugins/config/config_test.go b/plugins/config/config_test.go index 9eb0d0358..e512fc42c 100644 --- a/plugins/config/config_test.go +++ b/plugins/config/config_test.go @@ -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) } diff --git a/plugins/config/environment.go b/plugins/config/environment.go index 6e341dd62..3e9d5313d 100644 --- a/plugins/config/environment.go +++ b/plugins/config/environment.go @@ -392,9 +392,9 @@ func loadFromFileJSON(name string, filename string) (env *Env, err error) { }, nil } func getAppFile(appName string) (string, error) { - return filepath.Join(common.MustGetEnv("DOKKU_ROOT"), appName, "ENV"), nil + return filepath.Join(common.MustGetEnv("DOKKU_LIB_ROOT"), "config", appName, "ENV"), nil } func getGlobalFile() string { - return filepath.Join(common.MustGetEnv("DOKKU_ROOT"), "ENV") + return filepath.Join(common.MustGetEnv("DOKKU_LIB_ROOT"), "config", "--global", "ENV") } diff --git a/plugins/config/functions b/plugins/config/functions index fa3b4e0e3..51167487b 100755 --- a/plugins/config/functions +++ b/plugins/config/functions @@ -65,3 +65,14 @@ config_bundle() { declare desc="export tar bundle of config" config_sub bundle "$@" } + +fn-config-set() { + declare desc="set config values via trigger (supports --no-restart)" + declare APP="$1" NO_RESTART="$2" + shift 2 + if [[ "$NO_RESTART" == "true" ]]; then + plugn trigger config-set --no-restart "$APP" "$@" + else + plugn trigger config-set "$APP" "$@" + fi +} diff --git a/plugins/config/go.mod b/plugins/config/go.mod index 6a88197d9..0eaeea7b2 100644 --- a/plugins/config/go.mod +++ b/plugins/config/go.mod @@ -1,11 +1,11 @@ module github.com/dokku/dokku/plugins/config -go 1.25.5 +go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 github.com/joho/godotenv v1.5.1 - github.com/onsi/gomega v1.39.1 + github.com/onsi/gomega v1.40.0 github.com/ryanuber/columnize v2.1.2+incompatible github.com/spf13/pflag v1.0.10 ) diff --git a/plugins/config/go.sum b/plugins/config/go.sum index e1f322867..0bc4bdee8 100644 --- a/plugins/config/go.sum +++ b/plugins/config/go.sum @@ -1,19 +1,11 @@ -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/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= @@ -22,16 +14,18 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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= @@ -42,6 +36,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= @@ -52,8 +48,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= @@ -65,9 +59,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= diff --git a/plugins/config/src/triggers/triggers.go b/plugins/config/src/triggers/triggers.go index 96e95d455..dd5f1da77 100644 --- a/plugins/config/src/triggers/triggers.go +++ b/plugins/config/src/triggers/triggers.go @@ -15,6 +15,7 @@ func main() { parts := strings.Split(os.Args[0], "/") trigger := parts[len(parts)-1] global := flag.Bool("global", false, "--global: Whether global or app-specific") + noRestart := flag.Bool("no-restart", false, "--no-restart: Whether to skip restart") flag.Parse() var err error @@ -36,6 +37,10 @@ func main() { case "config-get-global": key := flag.Arg(0) err = config.TriggerConfigGetGlobal(key) + case "config-set": + appName := flag.Arg(0) + pairs := flag.Args()[1:] + err = config.TriggerConfigSet(appName, *noRestart, pairs...) case "config-unset": appName := flag.Arg(0) key := flag.Arg(1) @@ -46,6 +51,8 @@ func main() { restart = flag.Arg(1) } err = config.TriggerConfigUnset(appName, key, common.ToBool(restart)) + case "install": + err = config.TriggerInstall() case "post-app-clone-setup": oldAppName := flag.Arg(0) newAppName := flag.Arg(1) @@ -54,6 +61,12 @@ func main() { oldAppName := flag.Arg(0) newAppName := flag.Arg(1) err = config.TriggerPostAppRenameSetup(oldAppName, newAppName) + case "post-create": + appName := flag.Arg(0) + err = config.TriggerPostCreate(appName) + case "post-delete": + appName := flag.Arg(0) + err = config.TriggerPostDelete(appName) default: err = fmt.Errorf("Invalid plugin trigger call: %s", trigger) } diff --git a/plugins/config/triggers.go b/plugins/config/triggers.go index b0c3a57f3..724aa348d 100644 --- a/plugins/config/triggers.go +++ b/plugins/config/triggers.go @@ -2,7 +2,11 @@ package config import ( "fmt" + "os" + "path/filepath" "strconv" + + "github.com/dokku/dokku/plugins/common" ) // TriggerConfigExport returns a global config value by key @@ -45,12 +49,141 @@ func TriggerConfigGetGlobal(key string) error { return nil } +// TriggerConfigSet sets config values for an app +func TriggerConfigSet(appName string, noRestart bool, pairs ...string) error { + return SubSet(appName, pairs, noRestart, false) +} + // TriggerConfigUnset unsets an app config value by key func TriggerConfigUnset(appName string, key string, restart bool) error { UnsetMany(appName, []string{key}, restart) return nil } +func setupAppConfigDir(appName string) error { + appFile, err := getAppFile(appName) + if err != nil { + return err + } + + appConfigDir := filepath.Dir(appFile) + if err := os.MkdirAll(appConfigDir, 0755); err != nil { + return err + } + + return common.SetPermissions(common.SetPermissionInput{ + Filename: appConfigDir, + Mode: os.FileMode(0755), + }) +} + +func migrateGlobalEnv() error { + if err := common.PropertySetup("--global"); err != nil { + return fmt.Errorf("Unable to setup global environment: %s", err.Error()) + } + + oldGlobalEnvFile := filepath.Join(common.MustGetEnv("DOKKU_ROOT"), "ENV") + isGlobalMigrated := common.PropertyGetDefault("config", "--global", "env-migrated", "") + if isGlobalMigrated == "true" { + return nil + } + + oldGlobalEnv, err := loadFromFile("--global", oldGlobalEnvFile) + if err != nil { + return fmt.Errorf("Unable to load old global environment: %s", err.Error()) + } + + globalEnv, err := LoadGlobalEnv() + if err != nil { + return fmt.Errorf("Unable to load global environment: %s", err.Error()) + } + + globalEnv.Merge(oldGlobalEnv) + if err := globalEnv.Write(); err != nil { + return fmt.Errorf("Unable to write global environment: %s", err.Error()) + } + + if err := common.SetPermissions(common.SetPermissionInput{ + Filename: globalEnv.Filename(), + Mode: os.FileMode(0600), + }); err != nil { + return fmt.Errorf("Unable to set permissions on global environment: %s", err.Error()) + } + + if err := common.PropertyWrite("config", "--global", "env-migrated", "true"); err != nil { + return fmt.Errorf("Unable to set env-migrated property: %s", err.Error()) + } + + return nil +} + +// TriggerInstall runs the install step for the config plugin +func TriggerInstall() error { + if err := common.PropertySetup("config"); err != nil { + return fmt.Errorf("Unable to install the config plugin: %s", err.Error()) + } + + if err := migrateGlobalEnv(); err != nil { + return fmt.Errorf("Unable to migrate global environment: %s", err.Error()) + } + + apps, err := common.UnfilteredDokkuApps() + if err != nil { + return nil + } + + // migrate all app ENV files to config path + for _, appName := range apps { + if err := common.PropertySetupApp("config", appName); err != nil { + return fmt.Errorf("Unable to setup app environment: %s", err.Error()) + } + + if err := setupAppConfigDir(appName); err != nil { + return fmt.Errorf("Unable to setup app config directory: %s", err.Error()) + } + + oldEnvFile := filepath.Join(common.AppRoot(appName), "ENV") + isMigrated := common.PropertyGetDefault("config", appName, "env-migrated", "") + // delete the old file on the next install + if isMigrated == "true" { + if err := os.RemoveAll(oldEnvFile); err != nil { + return fmt.Errorf("Unable to remove old ENV file: %s", err.Error()) + } + continue + } + + // skip if the file doesn't exist + if _, err := os.Stat(oldEnvFile); err != nil { + if err := common.PropertyWrite("config", appName, "env-migrated", "true"); err != nil { + return fmt.Errorf("Unable to set env-migrated property: %s", err.Error()) + } + continue + } + + // merge in the old env into the new env + oldEnv, err := loadFromFile(appName, oldEnvFile) + if err != nil { + return fmt.Errorf("Unable to load old environment: %s", err.Error()) + } + + env, err := LoadAppEnv(appName) + if err != nil { + return fmt.Errorf("Unable to load app environment: %s", err.Error()) + } + + env.Merge(oldEnv) + if err := env.Write(); err != nil { + return fmt.Errorf("Unable to write app environment: %s", err.Error()) + } + + if err := common.PropertyWrite("config", appName, "env-migrated", "true"); err != nil { + return fmt.Errorf("Unable to set env-migrated property: %s", err.Error()) + } + } + + return nil +} + // TriggerPostAppCloneSetup creates new buildpacks files func TriggerPostAppCloneSetup(oldAppName string, newAppName string) error { oldEnv, err := LoadAppEnv(oldAppName) @@ -90,3 +223,22 @@ func TriggerPostAppRenameSetup(oldAppName string, newAppName string) error { return nil } + +// TriggerPostCreate ensures apps have the correct config structure +func TriggerPostCreate(appName string) error { + if err := common.PropertySetupApp("config", appName); err != nil { + return err + } + + return setupAppConfigDir(appName) +} + +// TriggerPostDelete destroys the config data for a given app container +func TriggerPostDelete(appName string) error { + appFile, err := getAppFile(appName) + if err == nil { + os.RemoveAll(filepath.Dir(appFile)) + } + + return common.PropertyDestroy("config", appName) +} diff --git a/plugins/cron/go.mod b/plugins/cron/go.mod index 39c53ee55..f814ea222 100644 --- a/plugins/cron/go.mod +++ b/plugins/cron/go.mod @@ -1,6 +1,6 @@ module github.com/dokku/dokku/plugins/cron -go 1.25.5 +go 1.26.2 require ( github.com/dokku/dokku/plugins/app-json v0.0.0-00010101000000-000000000000 diff --git a/plugins/cron/go.sum b/plugins/cron/go.sum index e061e0969..caf492d5a 100644 --- a/plugins/cron/go.sum +++ b/plugins/cron/go.sum @@ -28,8 +28,8 @@ 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.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= diff --git a/plugins/cron/report.go b/plugins/cron/report.go index 6577b0a9a..0c4ae3a91 100644 --- a/plugins/cron/report.go +++ b/plugins/cron/report.go @@ -10,22 +10,33 @@ import ( // ReportSingleApp is an internal function that displays the cron 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{ - "--cron-mailfrom": reportMailfrom, - "--cron-mailto": reportMailto, - "--cron-task-count": reportTasks, - "--cron-global-maintenance": reportGlobalMaintenance, - "--cron-computed-maintenance": reportComputedMaintenance, - "--cron-maintenance": reportMaintenance, - } + var flags map[string]common.ReportFunc + if appName == "--global" { + flags = map[string]common.ReportFunc{ + "--cron-mailfrom": reportMailfrom, + "--cron-mailto": reportMailto, + "--cron-global-maintenance": reportGlobalMaintenance, + } + } else { + flags = map[string]common.ReportFunc{ + "--cron-mailfrom": reportMailfrom, + "--cron-mailto": reportMailto, + "--cron-task-count": reportTasks, + "--cron-global-maintenance": reportGlobalMaintenance, + "--cron-computed-maintenance": reportComputedMaintenance, + "--cron-maintenance": reportMaintenance, + } - extraFlags := addCronMaintenanceFlags(appName, infoFlag) - for flag, fn := range extraFlags { - flags[flag] = fn + extraFlags := addCronMaintenanceFlags(appName, infoFlag) + for flag, fn := range extraFlags { + flags[flag] = fn + } } flagKeys := []string{} diff --git a/plugins/cron/src/subcommands/subcommands.go b/plugins/cron/src/subcommands/subcommands.go index c9452ee05..ff5f16be4 100644 --- a/plugins/cron/src/subcommands/subcommands.go +++ b/plugins/cron/src/subcommands/subcommands.go @@ -31,11 +31,14 @@ func main() { case "report": args := flag.NewFlagSet("cron:report", flag.ExitOnError) format := args.String("format", "stdout", "format: [ stdout | json ]") - osArgs, infoFlag, flagErr := common.ParseReportArgs("cron", os.Args[2:]) + reportArgs, flagErr := common.ParseReportArgs("cron", os.Args[2:]) if flagErr == nil { - args.Parse(osArgs) + args.Parse(reportArgs.OSArgs) appName := args.Arg(0) - err = cron.CommandReport(appName, *format, infoFlag) + if reportArgs.IsGlobal { + appName = "--global" + } + err = cron.CommandReport(appName, *format, reportArgs.InfoFlag) } case "resume": args := flag.NewFlagSet("cron:resume", flag.ExitOnError) diff --git a/plugins/docker-options/.gitignore b/plugins/docker-options/.gitignore new file mode 100644 index 000000000..a9f8b6be4 --- /dev/null +++ b/plugins/docker-options/.gitignore @@ -0,0 +1,11 @@ +/commands +/subcommands/* +/triggers/* +/triggers +/install +/post-* +/report +/docker-args-build +/docker-args-deploy +/docker-args-process-deploy +/docker-args-run diff --git a/plugins/docker-options/Makefile b/plugins/docker-options/Makefile new file mode 100644 index 000000000..6a6807d81 --- /dev/null +++ b/plugins/docker-options/Makefile @@ -0,0 +1,6 @@ +SUBCOMMANDS = subcommands/add subcommands/clear subcommands/list subcommands/remove subcommands/report +TRIGGERS = triggers/docker-args-build triggers/docker-args-deploy triggers/docker-args-process-deploy triggers/docker-args-run triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-delete triggers/report +BUILD = commands subcommands triggers +PLUGIN_NAME = docker-options + +include ../../common.mk diff --git a/plugins/docker-options/commands b/plugins/docker-options/commands deleted file mode 100755 index f404d4a31..000000000 --- a/plugins/docker-options/commands +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -[[ " help docker-options:help " == *" $1 "* ]] || exit "$DOKKU_NOT_IMPLEMENTED_EXIT" -source "$PLUGIN_AVAILABLE_PATH/docker-options/help-functions" -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x - -case "$1" in - help | docker-options:help) - cmd-docker-options-help "$@" - ;; - - *) - exit "$DOKKU_NOT_IMPLEMENTED_EXIT" - ;; - -esac diff --git a/plugins/docker-options/docker-args-build b/plugins/docker-options/docker-args-build deleted file mode 120000 index 68dea362a..000000000 --- a/plugins/docker-options/docker-args-build +++ /dev/null @@ -1 +0,0 @@ -docker-args-deploy \ No newline at end of file diff --git a/plugins/docker-options/docker-args-deploy b/plugins/docker-options/docker-args-deploy deleted file mode 100755 index 35ea4bc97..000000000 --- a/plugins/docker-options/docker-args-deploy +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x - -trigger-docker-options-docker-args() { - declare desc="docker args plugin trigger" - declare trigger="$0" - declare APP="$1" IMAGE_SOURCE_TYPE="$2" - local STDIN=$(cat) - - case "$0" in - *docker-args-build) - local PHASE=BUILD - ;; - *docker-args-deploy) - local PHASE=DEPLOY - ;; - *docker-args-run) - local PHASE=RUN - ;; - esac - - local FILE_PREFIX="DOCKER_OPTIONS_" - local PHASE_FILE_PATH="${DOKKU_ROOT}/${APP}/${FILE_PREFIX}${PHASE}" - - local output="" - - if [[ -f "$PHASE_FILE_PATH" ]]; then - local DONE=false - until $DONE; do - local line - read -r line || local DONE=true - - [[ -z "$line" ]] && continue - - case "$line" in - \#*) - continue - ;; - - --restart*) - if [[ "$PHASE" == "DEPLOY" ]]; then - local output="$output $line" - fi - continue - ;; - - *) - case "$IMAGE_SOURCE_TYPE" in - dockerfile | nixpacks | railpack) - case "$line" in - --link* | -v* | --volume*) - continue - ;; - - *) - local output="$output $line" - ;; - esac - ;; - - herokuish) - case "$line" in - --file* | --build-args*) - continue - ;; - - *) - local output="$output $line" - ;; - esac - ;; - - *) - local output="$output $line" - ;; - esac - ;; - esac - done <"$PHASE_FILE_PATH" - fi - - echo -n "$STDIN$output" -} - -trigger-docker-options-docker-args "$@" diff --git a/plugins/docker-options/docker-args-run b/plugins/docker-options/docker-args-run deleted file mode 120000 index 68dea362a..000000000 --- a/plugins/docker-options/docker-args-run +++ /dev/null @@ -1 +0,0 @@ -docker-args-deploy \ No newline at end of file diff --git a/plugins/docker-options/dockeroptions.go b/plugins/docker-options/dockeroptions.go index bbe79bbad..8d48d293e 100644 --- a/plugins/docker-options/dockeroptions.go +++ b/plugins/docker-options/dockeroptions.go @@ -1,119 +1,264 @@ package dockeroptions import ( - "bufio" "fmt" - "os" "sort" "strings" "github.com/dokku/dokku/plugins/common" + "mvdan.cc/sh/v3/shell" ) -// SetDockerOptionForPhases sets an option to specified phases -func SetDockerOptionForPhases(appName string, phases []string, name string, value string) error { - for _, phase := range phases { - if err := touchPhaseFile(appName, phase); err != nil { - return err - } +// DefaultProcessType is the sentinel process-type key used for options that +// apply to every container in an app (i.e. options not scoped to a specific +// Procfile process type). +const DefaultProcessType = "_default_" - options, err := GetDockerOptionsForPhase(appName, phase) - if err != nil { - return err - } - - newOptions := []string{} - for _, option := range options { - if strings.HasPrefix(option, fmt.Sprintf("--%s=", name)) { - continue - } - - newOptions = append(newOptions, option) - } - - newOptions = append(newOptions, fmt.Sprintf("--%s=%s", name, value)) - sort.Strings(newOptions) - if err = writeDockerOptionsForPhase(appName, phase, newOptions); err != nil { - return err - } - } - return nil -} - -// AddDockerOptionToPhases adds docker option to specified phases -func AddDockerOptionToPhases(appName string, phases []string, option string) error { - for _, phase := range phases { - if err := touchPhaseFile(appName, phase); err != nil { - return err - } - - options, err := GetDockerOptionsForPhase(appName, phase) - if err != nil { - return err - } - - options = append(options, option) - sort.Strings(options) - if err = writeDockerOptionsForPhase(appName, phase, options); err != nil { - return err - } - } - return nil -} - -// GetDockerOptionsForPhase returns the docker options for the specified phase -func GetDockerOptionsForPhase(appName string, phase string) ([]string, error) { - options := []string{} - - if err := touchPhaseFile(appName, phase); err != nil { - return options, err +// SplitOptionString shell-tokenizes input, groups tokens on flag boundaries, +// and returns one re-serialized option per group. A docker-options subcommand +// flag (currently just --process) that lands inside the option content - +// because the user typed it after the app name, where pflag's +// SetInterspersed(false) hands it back as positional - is lifted into the +// returned processes slice rather than stored as a docker option. The caller +// merges those processes with whatever pflag already captured. Empty or +// whitespace-only input returns empty slices. +func SplitOptionString(input string) (options []string, processes []string, err error) { + if strings.TrimSpace(input) == "" { + return nil, nil, nil } - phaseFilePath := getPhaseFilePath(appName, phase) - file, err := os.Open(phaseFilePath) + fields, err := shell.Fields(input, func(string) string { return "" }) if err != nil { - return options, fmt.Errorf("Unable to open docker options phase file %s.%s: %s", appName, appName, err.Error()) - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - options = append(options, strings.TrimSpace(scanner.Text())) + return nil, nil, fmt.Errorf("Unable to parse docker option: %s", err.Error()) } - if err = scanner.Err(); err != nil { - return options, fmt.Errorf("Unable to read docker options phase file %s.%s: %s", appName, appName, err.Error()) - } - - return options, nil -} - -// RemoveDockerOptionFromPhases removes a docker option from specified phases -func RemoveDockerOptionFromPhases(appName string, phases []string, option string) error { - for _, phase := range phases { - options, err := GetDockerOptionsForPhase(appName, phase) - if err != nil { - return err + var current []string + flush := func() error { + if len(current) == 0 { + return nil } + head := current[0] + if head == "--process" { + if len(current) < 2 { + return fmt.Errorf("--process requires a value") + } + if len(current) > 2 { + return fmt.Errorf("--process accepts a single value, got %d", len(current)-1) + } + processes = append(processes, current[1]) + current = nil + return nil + } + if strings.HasPrefix(head, "--process=") { + if len(current) > 1 { + return fmt.Errorf("--process=value cannot be followed by additional tokens") + } + processes = append(processes, head[len("--process="):]) + current = nil + return nil + } + options = append(options, joinShellTokens(current)) + current = nil + return nil + } - newOptions := []string{} - for _, opt := range options { - if opt != option { - newOptions = append(newOptions, opt) + for _, tok := range fields { + if isFlagToken(tok) && len(current) > 0 { + if err := flush(); err != nil { + return nil, nil, err } } + current = append(current, tok) + } + if err := flush(); err != nil { + return nil, nil, err + } - sort.Strings(newOptions) - if err = writeDockerOptionsForPhase(appName, phase, newOptions); err != nil { - return err + return options, processes, nil +} + +// isFlagToken reports whether tok looks like a CLI flag (long or short) rather +// than a value. Treating any token that begins with `-` and has more than one +// character as a flag matches docker's flag conventions and avoids the need +// for a per-flag whitelist. +func isFlagToken(tok string) bool { + return len(tok) > 1 && tok[0] == '-' +} + +// joinShellTokens joins tokens into a single string suitable for storage, +// shell-quoting any token that contains characters the bash scheduler's +// `eval` re-tokenization would interpret. The stored line round-trips through +// `eval set -- "$line"` back to the original token slice. +func joinShellTokens(tokens []string) string { + parts := make([]string, len(tokens)) + for i, tok := range tokens { + parts[i] = quoteShellArg(tok) + } + return strings.Join(parts, " ") +} + +// quoteShellArg returns s wrapped in single quotes when it contains characters +// the shell would otherwise interpret (whitespace, quotes, expansion sigils, +// globs, redirections, etc.). Embedded single quotes are escaped with the +// standard `'\''` close-escape-open sequence. Tokens free of such characters +// are returned verbatim so the stored representation stays human-readable for +// the common case. +func quoteShellArg(s string) string { + if s == "" { + return "''" + } + if !needsShellQuoting(s) { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func needsShellQuoting(s string) bool { + for _, r := range s { + switch r { + case ' ', '\t', '\n', '"', '\'', '$', '`', '\\', + '*', '?', '[', ']', '<', '>', '|', '&', ';', + '(', ')', '{', '}', '!', '#', '~': + return true + } + } + return false +} + +func propertyKey(processType, phase string) string { + if processType == "" { + processType = DefaultProcessType + } + return fmt.Sprintf("%s.%s", processType, phase) +} + +// SetDockerOptionForPhases sets a `--name=value` option in the default scope +// for the specified phases, replacing any existing entry with the same name. +func SetDockerOptionForPhases(appName string, phases []string, name string, value string) error { + return SetDockerOptionForProcessPhases(appName, []string{DefaultProcessType}, phases, name, value) +} + +// SetDockerOptionForProcessPhases sets a `--name=value` option for the specified +// process types and phases, replacing any existing entry with the same name. +func SetDockerOptionForProcessPhases(appName string, processTypes []string, phases []string, name string, value string) error { + if len(processTypes) == 0 { + processTypes = []string{DefaultProcessType} + } + for _, processType := range processTypes { + for _, phase := range phases { + options, err := GetDockerOptionsForProcessPhase(appName, processType, phase) + if err != nil { + return err + } + + newOptions := []string{} + for _, option := range options { + if strings.HasPrefix(option, fmt.Sprintf("--%s=", name)) { + continue + } + newOptions = append(newOptions, option) + } + + newOptions = append(newOptions, fmt.Sprintf("--%s=%s", name, value)) + sort.Strings(newOptions) + if err := writeDockerOptionsForProcessPhase(appName, processType, phase, newOptions); err != nil { + return err + } } } return nil } -// GetSpecifiedDockerOptionsForPhase returns the docker options for the specified phase that are in the desiredOptions list -// It expects desiredOptions to be a list of docker options that are in the format "--option" -// And will retrieve any lines that start with the desired option +// AddDockerOptionToPhases adds an option to the default scope for the specified phases. +func AddDockerOptionToPhases(appName string, phases []string, option string) error { + return AddDockerOptionToProcessPhases(appName, []string{DefaultProcessType}, phases, option) +} + +// AddDockerOptionToProcessPhases adds an option to the specified process types and phases. +func AddDockerOptionToProcessPhases(appName string, processTypes []string, phases []string, option string) error { + if len(processTypes) == 0 { + processTypes = []string{DefaultProcessType} + } + for _, processType := range processTypes { + for _, phase := range phases { + options, err := GetDockerOptionsForProcessPhase(appName, processType, phase) + if err != nil { + return err + } + + options = append(options, option) + sort.Strings(options) + if err := writeDockerOptionsForProcessPhase(appName, processType, phase, options); err != nil { + return err + } + } + } + return nil +} + +// GetDockerOptionsForPhase returns the docker options stored under the default +// scope for the specified phase. +func GetDockerOptionsForPhase(appName string, phase string) ([]string, error) { + return GetDockerOptionsForProcessPhase(appName, DefaultProcessType, phase) +} + +// GetDockerOptionsForProcessPhase returns the docker options stored under the +// given process-type scope for the specified phase. An empty processType is +// treated as the default scope. +func GetDockerOptionsForProcessPhase(appName, processType, phase string) ([]string, error) { + options, err := common.PropertyListGet("docker-options", appName, propertyKey(processType, phase)) + if err != nil { + return nil, fmt.Errorf("Unable to read docker options for %s.%s.%s: %s", appName, processType, phase, err.Error()) + } + + trimmed := make([]string, 0, len(options)) + for _, option := range options { + option = strings.TrimSpace(option) + if option == "" { + continue + } + trimmed = append(trimmed, option) + } + return trimmed, nil +} + +// RemoveDockerOptionFromPhases removes an option from the default scope for the specified phases. +func RemoveDockerOptionFromPhases(appName string, phases []string, option string) error { + return RemoveDockerOptionFromProcessPhases(appName, []string{DefaultProcessType}, phases, option) +} + +// RemoveDockerOptionFromProcessPhases removes an option from the specified process types and phases. +func RemoveDockerOptionFromProcessPhases(appName string, processTypes []string, phases []string, option string) error { + if len(processTypes) == 0 { + processTypes = []string{DefaultProcessType} + } + for _, processType := range processTypes { + for _, phase := range phases { + options, err := GetDockerOptionsForProcessPhase(appName, processType, phase) + if err != nil { + return err + } + + newOptions := []string{} + for _, opt := range options { + if opt != option { + newOptions = append(newOptions, opt) + } + } + + sort.Strings(newOptions) + if err := writeDockerOptionsForProcessPhase(appName, processType, phase, newOptions); err != nil { + return err + } + } + } + return nil +} + +// GetSpecifiedDockerOptionsForPhase returns the docker options for the specified +// phase (default scope) that are in the desiredOptions list. It expects +// desiredOptions entries in the form "--option" and matches against options +// stored as "--option", "--option=value", or "--option value". func GetSpecifiedDockerOptionsForPhase(appName string, phase string, desiredOptions []string) (map[string][]string, error) { foundOptions := map[string][]string{} options, err := GetDockerOptionsForPhase(appName, phase) @@ -128,7 +273,6 @@ func GetSpecifiedDockerOptionsForPhase(appName string, phase string, desiredOpti break } - // match options that are in the format "--option=value" if strings.HasPrefix(option, fmt.Sprintf("%s=", desiredOption)) { if _, ok := foundOptions[desiredOption]; !ok { foundOptions[desiredOption] = []string{} @@ -144,7 +288,6 @@ func GetSpecifiedDockerOptionsForPhase(appName string, phase string, desiredOpti break } - // match options that are in the format "--option value" if strings.HasPrefix(option, fmt.Sprintf("%s ", desiredOption)) { if _, ok := foundOptions[desiredOption]; !ok { foundOptions[desiredOption] = []string{} @@ -164,3 +307,48 @@ func GetSpecifiedDockerOptionsForPhase(appName string, phase string, desiredOpti return foundOptions, nil } + +// ListProcessTypesWithOptions returns the sorted list of process types that +// have at least one option configured, excluding DefaultProcessType. +func ListProcessTypesWithOptions(appName string) ([]string, error) { + properties, err := common.PropertyGetAll("docker-options", appName) + if err != nil { + return nil, err + } + + seen := map[string]bool{} + for key := range properties { + processType, _, ok := splitPropertyKey(key) + if !ok { + continue + } + if processType == DefaultProcessType { + continue + } + seen[processType] = true + } + + processTypes := make([]string, 0, len(seen)) + for processType := range seen { + processTypes = append(processTypes, processType) + } + sort.Strings(processTypes) + return processTypes, nil +} + +func splitPropertyKey(key string) (processType, phase string, ok bool) { + idx := strings.LastIndex(key, ".") + if idx <= 0 || idx == len(key)-1 { + return "", "", false + } + processType = key[:idx] + phase = key[idx+1:] + if !isValidPhase(phase) { + return "", "", false + } + return processType, phase, true +} + +func writeDockerOptionsForProcessPhase(appName, processType, phase string, options []string) error { + return common.PropertyListWrite("docker-options", appName, propertyKey(processType, phase), options) +} diff --git a/plugins/docker-options/dockeroptions_test.go b/plugins/docker-options/dockeroptions_test.go new file mode 100644 index 000000000..5237c75c3 --- /dev/null +++ b/plugins/docker-options/dockeroptions_test.go @@ -0,0 +1,156 @@ +package dockeroptions + +import ( + "testing" +) + +func TestSplitOptionString(t *testing.T) { + cases := []struct { + name string + input string + wantOptions []string + wantProcesses []string + wantErr bool + }{ + { + name: "single flag with value", + input: "--link foo", + wantOptions: []string{"--link foo"}, + }, + { + name: "single flag with equals value", + input: "--build-arg FOO=bar", + wantOptions: []string{"--build-arg FOO=bar"}, + }, + { + name: "two link flags split", + input: "--link a --link b", + wantOptions: []string{"--link a", "--link b"}, + }, + { + name: "build-arg followed by two link flags", + input: "--build-arg X=Y --link a --link b", + wantOptions: []string{"--build-arg X=Y", "--link a", "--link b"}, + }, + { + name: "boolean flag then key+value flag", + input: "--rm --link foo", + wantOptions: []string{"--rm", "--link foo"}, + }, + { + name: "short flag with value", + input: "-v /tmp", + wantOptions: []string{"-v /tmp"}, + }, + { + name: "restart with embedded equals and colon", + input: "--restart=on-failure:5", + wantOptions: []string{"--restart=on-failure:5"}, + }, + { + name: "empty input", + input: "", + }, + { + name: "whitespace only", + input: " \t ", + }, + { + name: "value with whitespace gets shell-quoted", + input: `--label "foo bar"`, + wantOptions: []string{"--label 'foo bar'"}, + }, + { + name: "equals value with whitespace gets shell-quoted", + input: `--label key="hello world"`, + wantOptions: []string{"--label 'key=hello world'"}, + }, + { + name: "process flag in option content is lifted", + input: "--process web", + wantProcesses: []string{"web"}, + }, + { + name: "process equals form is lifted", + input: "--process=web", + wantProcesses: []string{"web"}, + }, + { + name: "process lifted from middle of multi-flag input", + input: "--link foo --process web --link bar", + wantOptions: []string{"--link foo", "--link bar"}, + wantProcesses: []string{"web"}, + }, + { + name: "multiple processes lifted", + input: "--process web --process worker", + wantProcesses: []string{"web", "worker"}, + }, + { + name: "process without value errors", + input: "--process", + wantErr: true, + }, + { + name: "unbalanced quote errors", + input: `--build-arg FOO="bar`, + wantErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotOptions, gotProcesses, err := SplitOptionString(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("SplitOptionString(%q) succeeded, want error", tc.input) + } + return + } + if err != nil { + t.Fatalf("SplitOptionString(%q): %v", tc.input, err) + } + if !equalStringSlice(gotOptions, tc.wantOptions) { + t.Errorf("options = %q, want %q", gotOptions, tc.wantOptions) + } + if !equalStringSlice(gotProcesses, tc.wantProcesses) { + t.Errorf("processes = %q, want %q", gotProcesses, tc.wantProcesses) + } + }) + } +} + +func TestQuoteShellArg(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"", "''"}, + {"plain", "plain"}, + {"FOO=bar", "FOO=bar"}, + {"hello world", "'hello world'"}, + {"it's", `'it'\''s'`}, + {"$VAR", "'$VAR'"}, + {"a|b", "'a|b'"}, + } + for _, tc := range cases { + if got := quoteShellArg(tc.in); got != tc.want { + t.Errorf("quoteShellArg(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func equalStringSlice(a, b []string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/plugins/docker-options/functions b/plugins/docker-options/functions deleted file mode 100755 index e28e78f47..000000000 --- a/plugins/docker-options/functions +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x -source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" - -AVAILABLE_PHASES=(build deploy run) - -fn-get-phase-file-path() { - declare desc="return docker-options config file path for specified phase" - local APP="$1" PHASE="$2" - local PHASE_FILE="${DOKKU_ROOT}/${APP}/DOCKER_OPTIONS_${PHASE^^}" - echo "$PHASE_FILE" -} - -get_phase_file_path() { - declare desc="return docker-options config file path for specified phase" - local phase_file_prefix="DOCKER_OPTIONS_" - local phase=$1 - [[ "$APP" && "$phase" ]] || dokku_log_fail "Error: phase_file_path is incomplete." - fn-get-phase-file-path "$APP" "$phase" -} - -get_phases() { - declare desc="returns array of passed passes if all are in allowed array" - local passed_phases_list="$1" - # shellcheck disable=SC2001 - local -r phases_allowed=$(sed -e 's/ /\|/g' <<<"${AVAILABLE_PHASES[@]}") - local phase - local passed_phases - if [[ -n "$passed_phases_list" ]]; then - OIFS=$IFS - IFS=',' - read -ra passed_phases <<<"$passed_phases_list" - IFS=$OIFS - for phase in "${passed_phases[@]}"; do - [[ "$phase" == @($phases_allowed) ]] || dokku_log_fail "Phase(s) must be one of [${AVAILABLE_PHASES[*]}]" - done - echo "${passed_phases[@]}" - fi -} - -create_phase_file_if_required() { - declare desc="create phase file" - local phase_file_path="$1" - [[ -f "$phase_file_path" ]] || { - touch "$phase_file_path" - } -} - -display_phase_options() { - declare desc="print docker-options for specified phase" - local phase="$1" - local phase_file_path="$2" - echo "${phase^} options:" - sed -e 's/^/ /' "$phase_file_path" -} - -display_all_phases_options() { - declare desc="print docker-options for all phases" - local phases=("${AVAILABLE_PHASES[@]}") - for phase in "${phases[@]}"; do - local phase_file_path="$(get_phase_file_path "$phase")" - if [[ -s "$phase_file_path" ]]; then - display_phase_options "$phase" "$phase_file_path" - fi - done -} - -display_passed_phases_options() { - declare desc="print docker options for list of specified phases" - local passed_phases=("${!1}") - local phase - for phase in "${passed_phases[@]}"; do - local phase_file_path="$(get_phase_file_path "$phase")" - if [[ ! -s "$phase_file_path" ]]; then - echo "${phase^} options: none" - else - display_phase_options "$phase" "$phase_file_path" - fi - done -} - -add_passed_docker_option() { - declare desc="adds docker option to specified phases" - local passed_phases=("${!1}") - shift - local passed_option_string="$*" - local phase - for phase in "${passed_phases[@]}"; do - local phase_file_path="$(get_phase_file_path "$phase")" - create_phase_file_if_required "$phase_file_path" - echo "${passed_option_string}" >>"$phase_file_path" - local all_phase_options="$(<"$phase_file_path")" - echo -e "${all_phase_options}" | sed '/^$/d' | sort -u >"$phase_file_path" - done -} - -remove_passed_docker_option() { - declare desc="removes docker option from specified phases" - local passed_phases=("${!1}") - shift - local passed_option_string="$*" - local phase - for phase in "${passed_phases[@]}"; do - local phase_file_path="$(get_phase_file_path "$phase")" - [[ ! -s "$phase_file_path" ]] || { - local all_phase_options="$(<"$phase_file_path")" - local all_phase_options=$(echo -e "${all_phase_options}" | sed "s#^${passed_option_string}\$##") - echo -e "${all_phase_options}" | sed '/^$/d' | sort -u >"$phase_file_path" - } - done -} diff --git a/plugins/docker-options/functions.go b/plugins/docker-options/functions.go index f00fa5546..27535f977 100644 --- a/plugins/docker-options/functions.go +++ b/plugins/docker-options/functions.go @@ -1,6 +1,7 @@ package dockeroptions import ( + "errors" "fmt" "os" "path/filepath" @@ -9,32 +10,311 @@ import ( "github.com/dokku/dokku/plugins/common" ) -func getPhaseFilePath(appName string, phase string) string { - return filepath.Join(common.AppRoot(appName), "DOCKER_OPTIONS_"+strings.ToUpper(phase)) -} - -func touchPhaseFile(appName string, phase string) error { - phaseFilePath := getPhaseFilePath(appName, phase) - - _, err := os.Stat(phaseFilePath) - if !os.IsNotExist(err) { - return nil +// CommandAdd adds a docker option to the specified phases for an app. +// When processes is empty the option is added to the default scope (apply +// to every container in the app); otherwise it is added to each named +// process type. The default and process flows are mutually exclusive +// because process scoping is only valid for the deploy phase. +// +// The option string is split on flag boundaries via SplitOptionString so a +// single invocation may carry multiple flags (e.g. `--build-arg X=Y --link a +// --link b`); each split flag becomes a separate stored option. A misplaced +// `--process` inside the option content is lifted into the process slice +// rather than stored as a docker option. +func CommandAdd(appName string, processes []string, phasesArg string, option string) error { + if err := common.VerifyAppName(appName); err != nil { + return err } - file, err := os.Create(phaseFilePath) + phases, err := parsePhases(phasesArg) if err != nil { - return fmt.Errorf("Unable to create docker options phase file %s.%s: %s", appName, phase, err.Error()) + return err + } + + options, extractedProcesses, err := SplitOptionString(option) + if err != nil { + return err + } + + if len(options) == 0 { + return errors.New("Please specify docker options to add to the phase") + } + + processes = dedupeProcesses(append(processes, extractedProcesses...)) + + if err := ValidateProcessFlag(processes, phases); err != nil { + return err + } + + for _, processType := range processes { + WarnIfProcessNotInProcfile(appName, processType) + } + + for _, opt := range options { + if len(processes) == 0 { + if err := AddDockerOptionToPhases(appName, phases, opt); err != nil { + return err + } + continue + } + if err := AddDockerOptionToProcessPhases(appName, processes, phases, opt); err != nil { + return err + } } - defer file.Close() return nil } -func writeDockerOptionsForPhase(appName string, phase string, options []string) error { - phaseFilePath := getPhaseFilePath(appName, phase) - return common.WriteSliceToFile(common.WriteSliceToFileInput{ - Filename: phaseFilePath, - Lines: options, - Mode: os.FileMode(0600), - }) +// CommandRemove removes a docker option from the specified phases for an app. +// Process-flag handling matches CommandAdd, including splitting a multi-flag +// option string and lifting a misplaced `--process` into the process slice. +func CommandRemove(appName string, processes []string, phasesArg string, option string) error { + if err := common.VerifyAppName(appName); err != nil { + return err + } + + phases, err := parsePhases(phasesArg) + if err != nil { + return err + } + + options, extractedProcesses, err := SplitOptionString(option) + if err != nil { + return err + } + + if len(options) == 0 { + return errors.New("Please specify docker options to remove from the phase") + } + + processes = dedupeProcesses(append(processes, extractedProcesses...)) + + if err := ValidateProcessFlag(processes, phases); err != nil { + return err + } + + for _, opt := range options { + if len(processes) == 0 { + if err := RemoveDockerOptionFromPhases(appName, phases, opt); err != nil { + return err + } + continue + } + if err := RemoveDockerOptionFromProcessPhases(appName, processes, phases, opt); err != nil { + return err + } + } + + return nil +} + +// dedupeProcesses preserves order while collapsing repeated process names, +// which can occur when --process is specified both before the app (captured +// by pflag) and after (lifted by SplitOptionString). +func dedupeProcesses(processes []string) []string { + seen := map[string]bool{} + result := make([]string, 0, len(processes)) + for _, p := range processes { + if seen[p] { + continue + } + seen[p] = true + result = append(result, p) + } + return result +} + +// CommandClear removes all docker options for an app, optionally limited to +// a list of phases and/or specific process types. With no flags it clears +// the default scope across all phases; with --process flags it clears each +// named process type for the supplied (deploy-only) phases. +func CommandClear(appName string, processes []string, phasesArg string) error { + if err := common.VerifyAppName(appName); err != nil { + return err + } + + if len(processes) == 0 { + return clearDefaultScope(appName, phasesArg) + } + + phases, err := parsePhases(phasesArg) + if err != nil { + return err + } + + if len(phases) == 0 { + phases = []string{"deploy"} + } + + if err := ValidateProcessFlag(processes, phases); err != nil { + return err + } + + for _, processType := range processes { + for _, phase := range phases { + common.LogInfo1(fmt.Sprintf("Clearing docker-options for %s on phase %s for process %s", appName, phase, processType)) + if err := common.PropertyDelete("docker-options", appName, propertyKey(processType, phase)); err != nil { + return err + } + } + } + + return nil +} + +func clearDefaultScope(appName string, phasesArg string) error { + if phasesArg == "" { + common.LogInfo1(fmt.Sprintf("Clearing docker-options for %s on all phases", appName)) + for _, phase := range availablePhases { + if err := common.PropertyDelete("docker-options", appName, propertyKey(DefaultProcessType, phase)); err != nil { + return err + } + } + return nil + } + + phases, err := parsePhases(phasesArg) + if err != nil { + return err + } + + for _, phase := range phases { + common.LogInfo1(fmt.Sprintf("Clearing docker-options for %s on phase %s", appName, phase)) + if err := common.PropertyDelete("docker-options", appName, propertyKey(DefaultProcessType, phase)); err != nil { + return err + } + } + + return nil +} + +// CommandList prints the docker options stored for a given process+phase, one +// option per line. An empty processType means the default scope. +func CommandList(appName, processType, phase string) error { + if err := common.VerifyAppName(appName); err != nil { + return err + } + + if phase == "" { + return errors.New("--phase is required") + } + + if !isValidPhase(phase) { + return fmt.Errorf("Phase must be one of [%s]", strings.Join(availablePhases, " ")) + } + + if processType != "" { + if processType == DefaultProcessType { + return fmt.Errorf("%q is reserved and cannot be used as a --process value", DefaultProcessType) + } + if !processScopedPhases[phase] { + return fmt.Errorf("--process is only supported for the deploy phase, got %q", phase) + } + } + + options, err := GetDockerOptionsForProcessPhase(appName, processType, phase) + if err != nil { + return err + } + + for _, option := range options { + fmt.Println(option) + } + return nil +} + +// CommandReport displays a docker-options report for one or more apps +func CommandReport(appName string, format string, 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 _, name := range apps { + if err := ReportSingleApp(name, format, infoFlag); err != nil { + return err + } + } + return nil + } + + return ReportSingleApp(appName, format, infoFlag) +} + +// migrateLegacyDockerOptionsFiles converts pre-properties DOCKER_OPTIONS_* +// files into property lists. It is gated by a single global marker so it never +// re-runs - even if a user later restores a DOCKER_OPTIONS_* file by hand. +func migrateLegacyDockerOptionsFiles() error { + if common.PropertyExists("docker-options", "--global", "migrated-from-files") { + return nil + } + + apps, err := common.DokkuApps() + if err != nil { + if errors.Is(err, common.NoAppsExist) { + return common.PropertyWrite("docker-options", "--global", "migrated-from-files", "true") + } + return err + } + + for _, appName := range apps { + for _, phase := range availablePhases { + legacyPath := filepath.Join(common.AppRoot(appName), "DOCKER_OPTIONS_"+strings.ToUpper(phase)) + if !common.FileExists(legacyPath) { + continue + } + + lines, err := readLegacyOptionsFile(legacyPath) + if err != nil { + return err + } + + if err := common.PropertyListWrite("docker-options", appName, propertyKey(DefaultProcessType, phase), lines); err != nil { + return err + } + + migratedPath := legacyPath + ".migrated" + if err := os.Rename(legacyPath, migratedPath); err != nil { + return fmt.Errorf("Unable to rename migrated file %s: %s", legacyPath, err.Error()) + } + + common.LogInfo1(fmt.Sprintf("Migrated %s to docker-options properties", legacyPath)) + } + } + + return common.PropertyWrite("docker-options", "--global", "migrated-from-files", "true") +} + +func readLegacyOptionsFile(path string) ([]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("Unable to read %s: %s", path, err.Error()) + } + + var lines []string + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "#") { + continue + } + lines = append(lines, trimmed) + } + return lines, nil +} + +// removeMigratedLegacyFiles deletes any leftover DOCKER_OPTIONS_*.migrated +// files for an app. Called from post-delete so we don't leak files into a +// directory that is about to be torn down anyway. +func removeMigratedLegacyFiles(appName string) { + for _, phase := range availablePhases { + path := filepath.Join(common.AppRoot(appName), "DOCKER_OPTIONS_"+strings.ToUpper(phase)+".migrated") + _ = os.Remove(path) + } } diff --git a/plugins/docker-options/go.mod b/plugins/docker-options/go.mod index 5505a65ae..07380bc87 100644 --- a/plugins/docker-options/go.mod +++ b/plugins/docker-options/go.mod @@ -1,8 +1,12 @@ module github.com/dokku/dokku/plugins/docker-options -go 1.25.5 +go 1.26.2 -require github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 +require ( + github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 + github.com/spf13/pflag v1.0.10 + mvdan.cc/sh/v3 v3.13.1 +) require ( github.com/alexellis/go-execute/v2 v2.2.1 // indirect diff --git a/plugins/docker-options/go.sum b/plugins/docker-options/go.sum index d7b54471f..c2a979717 100644 --- a/plugins/docker-options/go.sum +++ b/plugins/docker-options/go.sum @@ -4,6 +4,8 @@ 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-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= 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= @@ -12,14 +14,18 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l 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/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,8 +36,12 @@ 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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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= @@ -51,3 +61,5 @@ 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= +mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= +mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/plugins/docker-options/help-functions b/plugins/docker-options/help-functions deleted file mode 100755 index 71d8c427a..000000000 --- a/plugins/docker-options/help-functions +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x - -cmd-docker-options-help() { - declare desc="help command" - declare CMD="$1" - local plugin_name="docker-options" - local plugin_description="Manage docker options for an app" - - if [[ "$CMD" == "${plugin_name}:help" ]]; then - echo -e "Usage: dokku ${plugin_name}[:COMMAND]" - echo '' - echo "$plugin_description" - echo '' - echo 'Additional commands:' - fn-help-content | sort | column -c2 -t -s, - elif [[ $(ps -o command= $PPID) == *"--all"* ]]; then - fn-help-content - else - cat < OPTION, Add Docker option to app for phase (comma separated phase list) - docker-options:clear [phase(s)], Clear a docker options from application with an optional phase (comma separated phase list) - docker-options:remove OPTION, Remove Docker option from app for phase (comma separated phase list) - docker-options:report [] [], Displays a docker options report for one or more apps -help_content -} diff --git a/plugins/docker-options/internal-functions b/plugins/docker-options/internal-functions deleted file mode 100755 index 5634f4f28..000000000 --- a/plugins/docker-options/internal-functions +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash -source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" -source "$PLUGIN_AVAILABLE_PATH/docker-options/functions" -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x - -cmd-docker-options-report() { - declare desc="displays a docker options report for one or more apps" - declare cmd="docker-options:report" - [[ "$1" == "$cmd" ]] && shift 1 - declare APP="$1" INFO_FLAG="$2" - - if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then - INFO_FLAG="$APP" - APP="" - fi - - if [[ -z "$APP" ]] && [[ -z "$INFO_FLAG" ]]; then - INFO_FLAG="true" - fi - - if [[ -z "$APP" ]]; then - for app in $(dokku_apps); do - cmd-docker-options-report-single "$app" "$INFO_FLAG" | tee || true - done - else - cmd-docker-options-report-single "$APP" "$INFO_FLAG" - fi -} - -cmd-docker-options-report-single() { - declare APP="$1" INFO_FLAG="$2" - if [[ "$INFO_FLAG" == "true" ]]; then - INFO_FLAG="" - fi - verify_app_name "$APP" - local flag_map=( - "--docker-options-build: $(fn-docker-options "$APP" build)" - "--docker-options-deploy: $(fn-docker-options "$APP" deploy)" - "--docker-options-run: $(fn-docker-options "$APP" run)" - ) - - if [[ -z "$INFO_FLAG" ]]; then - dokku_log_info2_quiet "$APP docker options information" - for flag in "${flag_map[@]}"; do - key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')" - dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")" - done - else - local match=false - local value_exists=false - for flag in "${flag_map[@]}"; do - valid_flags="${valid_flags} $(echo "$flag" | cut -d':' -f1)" - if [[ "$flag" == "${INFO_FLAG}:"* ]]; then - value=${flag#*: } - size="${#value}" - if [[ "$size" -ne 0 ]]; then - echo "$value" && match=true && value_exists=true - else - match=true - fi - fi - done - [[ "$match" == "true" ]] || dokku_log_fail "Invalid flag passed, valid flags:${valid_flags}" - echo "" - fi -} - -fn-docker-options() { - declare APP="$1" PHASE="$2" - local PHASE_FILE="$(fn-get-phase-file-path "$APP" "$PHASE")" - if [[ -r "$PHASE_FILE" ]]; then - tr '\n' ' ' <"$PHASE_FILE" - fi -} diff --git a/plugins/docker-options/internal-functions.go b/plugins/docker-options/internal-functions.go new file mode 100644 index 000000000..480d110a9 --- /dev/null +++ b/plugins/docker-options/internal-functions.go @@ -0,0 +1,97 @@ +package dockeroptions + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + + "github.com/dokku/dokku/plugins/common" +) + +var availablePhases = []string{"build", "deploy", "run"} + +// processScopedPhases lists the phases that may be scoped via --process. +// Only deploy is scoped: build runs once per app (no process-type concept) and +// run is invoked for ad-hoc commands and cron tasks where the caller does not +// supply a Procfile process type. +var processScopedPhases = map[string]bool{ + "deploy": true, +} + +func isValidPhase(phase string) bool { + for _, p := range availablePhases { + if p == phase { + return true + } + } + return false +} + +func parsePhases(phasesArg string) ([]string, error) { + if phasesArg == "" { + return nil, nil + } + + phases := strings.Split(phasesArg, ",") + for _, phase := range phases { + if !isValidPhase(phase) { + return nil, fmt.Errorf("Phase(s) must be one of [%s]", strings.Join(availablePhases, " ")) + } + } + + return phases, nil +} + +// ValidateProcessFlag rejects invalid combinations of --process and the +// supplied phases. When processes is empty the call applies to the default +// scope and any phase is allowed. +func ValidateProcessFlag(processes []string, phases []string) error { + if len(processes) == 0 { + return nil + } + + for _, processType := range processes { + if processType == "" { + return errors.New("--process value must not be empty") + } + if processType == DefaultProcessType { + return fmt.Errorf("%q is reserved and cannot be used as a --process value", DefaultProcessType) + } + } + + for _, phase := range phases { + if !processScopedPhases[phase] { + return fmt.Errorf("--process is only supported for the deploy phase, got %q", phase) + } + } + + return nil +} + +// WarnIfProcessNotInProcfile emits a warning (without failing) when the named +// process type is not present in the app's current Procfile. The check is +// best-effort: if the Procfile or procfile-util are unavailable, the function +// returns silently. +func WarnIfProcessNotInProcfile(appName, processType string) { + procfilePath := filepath.Join(common.GetAppDataDirectory("ps", appName), "Procfile") + if !common.FileExists(procfilePath) { + return + } + + result, err := common.CallExecCommand(common.ExecCommandInput{ + Command: "procfile-util", + Args: []string{"list", "--procfile", procfilePath}, + }) + if err != nil || result.ExitCode != 0 { + return + } + + for _, line := range strings.Split(strings.TrimSpace(result.StdoutContents()), "\n") { + if strings.TrimSpace(line) == processType { + return + } + } + + common.LogWarn(fmt.Sprintf("Process type %q is not declared in the Procfile for %s", processType, appName)) +} diff --git a/plugins/docker-options/migration_test.go b/plugins/docker-options/migration_test.go new file mode 100644 index 000000000..d3a1595fe --- /dev/null +++ b/plugins/docker-options/migration_test.go @@ -0,0 +1,148 @@ +package dockeroptions + +import ( + "os" + "os/user" + "path/filepath" + "testing" + + "github.com/dokku/dokku/plugins/common" +) + +// setupMigrationEnv points the dokku env at temporary directories and tells the +// permission helpers to chown files to the current user (a no-op) so the test +// works without root. +func setupMigrationEnv(t *testing.T) (dokkuRoot string) { + t.Helper() + + libRoot := t.TempDir() + dokkuRoot = t.TempDir() + + t.Setenv("DOKKU_LIB_ROOT", libRoot) + t.Setenv("DOKKU_ROOT", dokkuRoot) + t.Setenv("PLUGIN_PATH", filepath.Join(libRoot, "plugins")) + + current, err := user.Current() + if err != nil { + t.Fatalf("user.Current: %v", err) + } + group, err := user.LookupGroupId(current.Gid) + if err != nil { + t.Fatalf("user.LookupGroupId: %v", err) + } + t.Setenv("DOKKU_SYSTEM_USER", current.Username) + t.Setenv("DOKKU_SYSTEM_GROUP", group.Name) + + return dokkuRoot +} + +func writeLegacyDockerOptionsFile(t *testing.T, dokkuRoot, app, phase, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dokkuRoot, app), 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + path := filepath.Join(dokkuRoot, app, "DOCKER_OPTIONS_"+phase) + if err := os.WriteFile(path, []byte(contents), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } +} + +func TestMigrateLegacyDockerOptionsFiles_MigratesAndIsIdempotent(t *testing.T) { + dokkuRoot := setupMigrationEnv(t) + + writeLegacyDockerOptionsFile(t, dokkuRoot, "alpha", "DEPLOY", "-v /var/log:/log\n# a comment\n\n--restart=on-failure:5\n") + writeLegacyDockerOptionsFile(t, dokkuRoot, "alpha", "BUILD", "--build-arg FOO=bar\n") + writeLegacyDockerOptionsFile(t, dokkuRoot, "beta", "DEPLOY", "-p 8080:5000\n") + + if err := migrateLegacyDockerOptionsFiles(); err != nil { + t.Fatalf("first migration: %v", err) + } + + deploy, err := common.PropertyListGet("docker-options", "alpha", "_default_.deploy") + if err != nil { + t.Fatalf("PropertyListGet alpha deploy: %v", err) + } + wantDeploy := []string{"-v /var/log:/log", "--restart=on-failure:5"} + if !equalStrings(deploy, wantDeploy) { + t.Errorf("alpha deploy = %v, want %v", deploy, wantDeploy) + } + + build, err := common.PropertyListGet("docker-options", "alpha", "_default_.build") + if err != nil { + t.Fatalf("PropertyListGet alpha build: %v", err) + } + wantBuild := []string{"--build-arg FOO=bar"} + if !equalStrings(build, wantBuild) { + t.Errorf("alpha build = %v, want %v", build, wantBuild) + } + + betaDeploy, err := common.PropertyListGet("docker-options", "beta", "_default_.deploy") + if err != nil { + t.Fatalf("PropertyListGet beta deploy: %v", err) + } + wantBetaDeploy := []string{"-p 8080:5000"} + if !equalStrings(betaDeploy, wantBetaDeploy) { + t.Errorf("beta deploy = %v, want %v", betaDeploy, wantBetaDeploy) + } + + if !common.PropertyExists("docker-options", "--global", "migrated-from-files") { + t.Errorf("migrated-from-files marker not set after first migration") + } + + for _, app := range []string{"alpha", "beta"} { + for _, phase := range []string{"BUILD", "DEPLOY", "RUN"} { + legacy := filepath.Join(dokkuRoot, app, "DOCKER_OPTIONS_"+phase) + migrated := legacy + ".migrated" + if app == "alpha" && (phase == "BUILD" || phase == "DEPLOY") || app == "beta" && phase == "DEPLOY" { + if _, err := os.Stat(migrated); err != nil { + t.Errorf("expected %s to exist: %v", migrated, err) + } + if _, err := os.Stat(legacy); !os.IsNotExist(err) { + t.Errorf("expected %s to be gone, got err=%v", legacy, err) + } + } else { + if _, err := os.Stat(legacy); !os.IsNotExist(err) { + t.Errorf("did not expect %s to exist, got err=%v", legacy, err) + } + } + } + } + + sneakyPath := filepath.Join(dokkuRoot, "alpha", "DOCKER_OPTIONS_DEPLOY") + sneakyContents := "-v /tmp/sneaky:/sneaky\n" + if err := os.WriteFile(sneakyPath, []byte(sneakyContents), 0644); err != nil { + t.Fatalf("re-create legacy file: %v", err) + } + + if err := migrateLegacyDockerOptionsFiles(); err != nil { + t.Fatalf("second migration: %v", err) + } + + deployAfter, err := common.PropertyListGet("docker-options", "alpha", "_default_.deploy") + if err != nil { + t.Fatalf("PropertyListGet alpha deploy (post re-run): %v", err) + } + if !equalStrings(deployAfter, wantDeploy) { + t.Errorf("alpha deploy after rerun = %v, want %v (idempotency violated)", deployAfter, wantDeploy) + } + + gotSneaky, err := os.ReadFile(sneakyPath) + if err != nil { + t.Fatalf("expected sneaky legacy file untouched, got err=%v", err) + } + if string(gotSneaky) != sneakyContents { + t.Errorf("sneaky legacy file modified: %q", gotSneaky) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/plugins/docker-options/post-app-clone-setup b/plugins/docker-options/post-app-clone-setup deleted file mode 100755 index 9b36b329d..000000000 --- a/plugins/docker-options/post-app-clone-setup +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x -source "$PLUGIN_AVAILABLE_PATH/docker-options/functions" - -trigger-docker-options-post-app-clone-setup() { - declare desc="copies docker options over" - declare trigger="post-app-clone-setup" - declare OLD_APP="$1" NEW_APP="$2" - - declare -a phases - phases=("build" "deploy" "run") - for phase in "${phases[@]}"; do - if [[ -f "$(fn-get-phase-file-path "$OLD_APP" "$phase")" ]]; then - cp "$(fn-get-phase-file-path "$OLD_APP" "$phase")" "$(fn-get-phase-file-path "$NEW_APP" "$phase")" - fi - done -} - -trigger-docker-options-post-app-clone-setup "$@" diff --git a/plugins/docker-options/post-app-rename-setup b/plugins/docker-options/post-app-rename-setup deleted file mode 100755 index 0688252a6..000000000 --- a/plugins/docker-options/post-app-rename-setup +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x -source "$PLUGIN_AVAILABLE_PATH/docker-options/functions" - -trigger-docker-options-post-app-rename-setup() { - declare desc="copies docker options over" - declare trigger="post-app-rename-setup" - declare OLD_APP="$1" NEW_APP="$2" - - declare -a phases - phases=("build" "deploy" "run") - for phase in "${phases[@]}"; do - if [[ -f "$(fn-get-phase-file-path "$OLD_APP" "$phase")" ]]; then - cp "$(fn-get-phase-file-path "$OLD_APP" "$phase")" "$(fn-get-phase-file-path "$NEW_APP" "$phase")" - fi - done -} - -trigger-docker-options-post-app-rename-setup "$@" diff --git a/plugins/docker-options/post-delete b/plugins/docker-options/post-delete deleted file mode 100755 index 3505fb6a8..000000000 --- a/plugins/docker-options/post-delete +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x -source "$PLUGIN_AVAILABLE_PATH/docker-options/functions" - -trigger-docker-options-post-delete() { - declare desc="deletes docker options files for an app" - declare trigger="post-delete" - declare APP="$1" - - rm -f "$(fn-get-phase-file-path "$APP" "build")" "$(fn-get-phase-file-path "$APP" "deploy")" "$(fn-get-phase-file-path "$APP" "run")" -} - -trigger-docker-options-post-delete "$@" diff --git a/plugins/docker-options/report b/plugins/docker-options/report deleted file mode 100755 index 722271960..000000000 --- a/plugins/docker-options/report +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -source "$PLUGIN_AVAILABLE_PATH/docker-options/internal-functions" -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x - -cmd-docker-options-report-single "$@" diff --git a/plugins/docker-options/report.go b/plugins/docker-options/report.go new file mode 100644 index 000000000..656ff92a3 --- /dev/null +++ b/plugins/docker-options/report.go @@ -0,0 +1,73 @@ +package dockeroptions + +import ( + "fmt" + "strings" + + "github.com/dokku/dokku/plugins/common" +) + +// ReportSingleApp displays the docker options report for a single app. +// Default-scope options are reported under fixed keys for each phase. +// Process-scoped options surface as dynamic per-process keys, one per +// configured process+phase combination. +func ReportSingleApp(appName string, format string, 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{} + } else { + flags = map[string]common.ReportFunc{ + "--docker-options-build": reportBuildOptions, + "--docker-options-deploy": reportDeployOptions, + "--docker-options-run": reportRunOptions, + } + } + + processTypes, err := ListProcessTypesWithOptions(appName) + if err != nil { + return err + } + for _, processType := range processTypes { + processType := processType + flagName := fmt.Sprintf("--docker-options-deploy.%s", processType) + flags[flagName] = func(app string) string { + return joinProcessPhaseOptions(app, processType, "deploy") + } + } + + flagKeys := []string{} + for flagKey := range flags { + flagKeys = append(flagKeys, flagKey) + } + + trimPrefix := false + uppercaseFirstCharacter := true + infoFlags := common.CollectReport(appName, infoFlag, flags) + return common.ReportSingleApp("docker options", appName, infoFlag, infoFlags, flagKeys, format, trimPrefix, uppercaseFirstCharacter) +} + +func reportBuildOptions(appName string) string { + return joinProcessPhaseOptions(appName, DefaultProcessType, "build") +} + +func reportDeployOptions(appName string) string { + return joinProcessPhaseOptions(appName, DefaultProcessType, "deploy") +} + +func reportRunOptions(appName string) string { + return joinProcessPhaseOptions(appName, DefaultProcessType, "run") +} + +func joinProcessPhaseOptions(appName, processType, phase string) string { + options, err := GetDockerOptionsForProcessPhase(appName, processType, phase) + if err != nil || len(options) == 0 { + return "" + } + return strings.Join(options, " ") +} diff --git a/plugins/docker-options/src/commands/commands.go b/plugins/docker-options/src/commands/commands.go new file mode 100644 index 000000000..c8c85c6bb --- /dev/null +++ b/plugins/docker-options/src/commands/commands.go @@ -0,0 +1,57 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strconv" + "strings" + + "github.com/dokku/dokku/plugins/common" +) + +const ( + helpHeader = `Usage: dokku docker-options[:COMMAND] + +Manage docker options for an app + +Additional commands:` + + helpContent = ` + docker-options:add OPTION, Add Docker option to app for phase (comma separated phase list) + docker-options:clear [phase(s)], Clear a docker options from application with an optional phase (comma separated phase list) + docker-options:remove OPTION, Remove Docker option from app for phase (comma separated phase list) + docker-options:report [] [], Displays a docker options report for one or more apps` +) + +func main() { + flag.Usage = usage + flag.Parse() + + cmd := flag.Arg(0) + switch cmd { + case "docker-options", "docker-options: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 docker-options, Manage docker options for an app\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) +} diff --git a/plugins/docker-options/src/subcommands/subcommands.go b/plugins/docker-options/src/subcommands/subcommands.go new file mode 100644 index 000000000..7eedc7241 --- /dev/null +++ b/plugins/docker-options/src/subcommands/subcommands.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/dokku/dokku/plugins/common" + dockeroptions "github.com/dokku/dokku/plugins/docker-options" + + flag "github.com/spf13/pflag" +) + +func main() { + parts := strings.Split(os.Args[0], "/") + subcommand := parts[len(parts)-1] + + var err error + switch subcommand { + case "add": + args := flag.NewFlagSet("docker-options:add", flag.ExitOnError) + args.SetInterspersed(false) + processes := args.StringSlice("process", []string{}, "process types to scope this option to (deploy phase only)") + args.Parse(os.Args[2:]) + + positional := args.Args() + appName := "" + phases := "" + var optionParts []string + if len(positional) > 0 { + appName = positional[0] + } + if len(positional) > 1 { + phases = positional[1] + } + if len(positional) > 2 { + optionParts = positional[2:] + } + err = dockeroptions.CommandAdd(appName, *processes, phases, strings.Join(optionParts, " ")) + case "remove": + args := flag.NewFlagSet("docker-options:remove", flag.ExitOnError) + args.SetInterspersed(false) + processes := args.StringSlice("process", []string{}, "process types to scope this option to (deploy phase only)") + args.Parse(os.Args[2:]) + + positional := args.Args() + appName := "" + phases := "" + var optionParts []string + if len(positional) > 0 { + appName = positional[0] + } + if len(positional) > 1 { + phases = positional[1] + } + if len(positional) > 2 { + optionParts = positional[2:] + } + err = dockeroptions.CommandRemove(appName, *processes, phases, strings.Join(optionParts, " ")) + case "clear": + args := flag.NewFlagSet("docker-options:clear", flag.ExitOnError) + args.SetInterspersed(false) + processes := args.StringSlice("process", []string{}, "process types to scope this option to (deploy phase only)") + args.Parse(os.Args[2:]) + + positional := args.Args() + appName := "" + phases := "" + if len(positional) > 0 { + appName = positional[0] + } + if len(positional) > 1 { + phases = positional[1] + } + err = dockeroptions.CommandClear(appName, *processes, phases) + case "list": + args := flag.NewFlagSet("docker-options:list", flag.ExitOnError) + processType := args.String("process", "", "process type to query (omit for the default scope)") + phase := args.String("phase", "", "phase to query [build|deploy|run] (required)") + args.Parse(os.Args[2:]) + + appName := args.Arg(0) + err = dockeroptions.CommandList(appName, *processType, *phase) + case "report": + args := flag.NewFlagSet("docker-options:report", flag.ExitOnError) + format := args.String("format", "stdout", "format: [ stdout | json ]") + reportArgs, flagErr := common.ParseReportArgs("docker-options", os.Args[2:]) + if flagErr == nil { + args.Parse(reportArgs.OSArgs) + appName := args.Arg(0) + if reportArgs.IsGlobal { + appName = "--global" + } + err = dockeroptions.CommandReport(appName, *format, reportArgs.InfoFlag) + } else { + err = flagErr + } + default: + err = fmt.Errorf("Invalid plugin subcommand call: %s", subcommand) + } + + if err != nil { + common.LogFailWithError(err) + } +} diff --git a/plugins/docker-options/src/triggers/triggers.go b/plugins/docker-options/src/triggers/triggers.go new file mode 100644 index 000000000..59361a245 --- /dev/null +++ b/plugins/docker-options/src/triggers/triggers.go @@ -0,0 +1,60 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "github.com/dokku/dokku/plugins/common" + dockeroptions "github.com/dokku/dokku/plugins/docker-options" +) + +func main() { + parts := strings.Split(os.Args[0], "/") + trigger := parts[len(parts)-1] + flag.Parse() + + var err error + switch trigger { + case "install": + err = dockeroptions.TriggerInstall() + case "docker-args-build": + appName := flag.Arg(0) + imageSourceType := flag.Arg(1) + err = dockeroptions.TriggerDockerArgs("build", appName, imageSourceType, "") + case "docker-args-deploy": + appName := flag.Arg(0) + imageSourceType := flag.Arg(1) + err = dockeroptions.TriggerDockerArgs("deploy", appName, imageSourceType, "") + case "docker-args-run": + appName := flag.Arg(0) + imageSourceType := flag.Arg(1) + err = dockeroptions.TriggerDockerArgs("run", appName, imageSourceType, "") + case "docker-args-process-deploy": + appName := flag.Arg(0) + imageSourceType := flag.Arg(1) + processType := flag.Arg(3) + err = dockeroptions.TriggerDockerArgsProcessDeploy(appName, imageSourceType, processType) + case "post-app-clone-setup": + oldAppName := flag.Arg(0) + newAppName := flag.Arg(1) + err = dockeroptions.TriggerPostAppCloneSetup(oldAppName, newAppName) + case "post-app-rename-setup": + oldAppName := flag.Arg(0) + newAppName := flag.Arg(1) + err = dockeroptions.TriggerPostAppRenameSetup(oldAppName, newAppName) + case "post-delete": + appName := flag.Arg(0) + err = dockeroptions.TriggerPostDelete(appName) + case "report": + appName := flag.Arg(0) + err = dockeroptions.ReportSingleApp(appName, "", "") + default: + err = fmt.Errorf("Invalid plugin trigger call: %s", trigger) + } + + if err != nil { + common.LogFailWithError(err) + } +} diff --git a/plugins/docker-options/subcommands/add b/plugins/docker-options/subcommands/add deleted file mode 100755 index 98233ad1a..000000000 --- a/plugins/docker-options/subcommands/add +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x -source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" -source "$PLUGIN_AVAILABLE_PATH/docker-options/functions" - -cmd-docker-options-add() { - declare desc="Add a docker option to application" - declare cmd="docker-options:add" - [[ "$1" == "$cmd" ]] && shift 1 - declare APP="$1" - - verify_app_name "$APP" - read -ra passed_phases <<<"$(get_phases "$2")" - shift 2 # everything else passed is the docker option - local passed_docker_option="$*" - [[ -z "$passed_docker_option" ]] && dokku_log_fail "Please specify docker options to add to the phase" - add_passed_docker_option passed_phases[@] "${passed_docker_option[@]}" -} - -cmd-docker-options-add "$@" diff --git a/plugins/docker-options/subcommands/clear b/plugins/docker-options/subcommands/clear deleted file mode 100755 index 83c0501dd..000000000 --- a/plugins/docker-options/subcommands/clear +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x -source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" -source "$PLUGIN_AVAILABLE_PATH/docker-options/functions" - -cmd-docker-options-clear() { - declare desc="clear a docker options from application" - declare cmd="docker-options:clear" - [[ "$1" == "$cmd" ]] && shift 1 - declare APP="$1" PHASES="$2" - - verify_app_name "$APP" - - if [[ -z "$PHASES" ]]; then - dokku_log_info1 "Clearing docker-options for $APP on all phases" - rm -f "$(fn-get-phase-file-path "$APP" "build")" "$(fn-get-phase-file-path "$APP" "deploy")" "$(fn-get-phase-file-path "$APP" "run")" - return - fi - - read -ra passed_phases <<<"$(get_phases "$PHASES")" - for phase in "${passed_phases[@]}"; do - dokku_log_info1 "Clearing docker-options for $APP on phase $phase" - rm -f "$(fn-get-phase-file-path "$APP" "$phase")" - done -} - -cmd-docker-options-clear "$@" diff --git a/plugins/docker-options/subcommands/default b/plugins/docker-options/subcommands/default deleted file mode 100755 index 409079c9b..000000000 --- a/plugins/docker-options/subcommands/default +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x -source "$PLUGIN_AVAILABLE_PATH/docker-options/help-functions" - -cmd-docker-options-help "docker-options:help" diff --git a/plugins/docker-options/subcommands/remove b/plugins/docker-options/subcommands/remove deleted file mode 100755 index bddbbcdbd..000000000 --- a/plugins/docker-options/subcommands/remove +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x -source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" -source "$PLUGIN_AVAILABLE_PATH/docker-options/functions" - -cmd-docker-options-remove() { - declare desc="Remove a docker option from application" - declare cmd="docker-options:remove" - [[ "$1" == "$cmd" ]] && shift 1 - declare APP="$1" - - verify_app_name "$APP" - read -ra passed_phases <<<"$(get_phases "$2")" - shift 2 # everything else passed is the docker option - [[ -z ${passed_docker_option="$@"} ]] && dokku_log_fail "Please specify docker options to remove from the phase" - remove_passed_docker_option passed_phases[@] "${passed_docker_option[@]}" -} - -cmd-docker-options-remove "$@" diff --git a/plugins/docker-options/subcommands/report b/plugins/docker-options/subcommands/report deleted file mode 100755 index 5985f4add..000000000 --- a/plugins/docker-options/subcommands/report +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -source "$PLUGIN_AVAILABLE_PATH/docker-options/internal-functions" -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x - -cmd-docker-options-report "$@" diff --git a/plugins/docker-options/triggers.go b/plugins/docker-options/triggers.go new file mode 100644 index 000000000..de9292454 --- /dev/null +++ b/plugins/docker-options/triggers.go @@ -0,0 +1,141 @@ +package dockeroptions + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/dokku/dokku/plugins/common" +) + +// TriggerInstall sets up the docker-options property directory and migrates +// any pre-existing DOCKER_OPTIONS_* files into property lists. +func TriggerInstall() error { + if err := common.PropertySetup("docker-options"); err != nil { + return fmt.Errorf("Unable to install the docker-options plugin: %v", err) + } + + if err := migrateLegacyDockerOptionsFiles(); err != nil { + return fmt.Errorf("Unable to migrate legacy docker-options files: %v", err) + } + + return nil +} + +// TriggerPostAppCloneSetup copies docker option properties from the source app +// to the cloned app. +func TriggerPostAppCloneSetup(oldAppName string, newAppName string) error { + return common.PropertyClone("docker-options", oldAppName, newAppName) +} + +// TriggerPostAppRenameSetup moves docker option properties from the old app +// name to the new one. +func TriggerPostAppRenameSetup(oldAppName string, newAppName string) error { + if err := common.PropertyClone("docker-options", oldAppName, newAppName); err != nil { + return err + } + return common.PropertyDestroy("docker-options", oldAppName) +} + +// TriggerPostDelete removes the docker option properties for an app and +// cleans up any leftover migrated legacy files. +func TriggerPostDelete(appName string) error { + if err := common.PropertyDestroy("docker-options", appName); err != nil { + return err + } + removeMigratedLegacyFiles(appName) + return nil +} + +// TriggerDockerArgs implements the legacy docker-args-{build,deploy,run} +// triggers. It echoes stdin verbatim, then appends the default-scope options +// for the given phase after applying image-source-type filtering. +func TriggerDockerArgs(phase, appName, imageSourceType, processType string) error { + stdin, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + + if _, err := os.Stdout.Write(stdin); err != nil { + return err + } + + options, err := GetDockerOptionsForProcessPhase(appName, DefaultProcessType, phase) + if err != nil { + return err + } + + emitFilteredOptions(phase, imageSourceType, options) + return nil +} + +// TriggerDockerArgsProcessDeploy implements docker-args-process-deploy. It +// echoes stdin verbatim, then appends the deploy-phase options for the +// process-specific scope (filtered by image source type). Default-scope +// options are emitted by TriggerDockerArgs (docker-args-deploy), which the +// scheduler invokes alongside the per-process trigger. +func TriggerDockerArgsProcessDeploy(appName, imageSourceType, processType string) error { + stdin, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + + if _, err := os.Stdout.Write(stdin); err != nil { + return err + } + + if processType == "" || processType == DefaultProcessType { + return nil + } + + options, err := GetDockerOptionsForProcessPhase(appName, processType, "deploy") + if err != nil { + return err + } + + emitFilteredOptions("deploy", imageSourceType, options) + return nil +} + +// emitFilteredOptions writes filtered docker options to stdout, prefixed with +// a single space so concatenation with prior output stays well-formed. It +// reproduces the filtering historically performed by the bash docker-args-* +// triggers. +func emitFilteredOptions(phase, imageSourceType string, options []string) { + for _, option := range options { + option = strings.TrimSpace(option) + if option == "" || strings.HasPrefix(option, "#") { + continue + } + + if strings.HasPrefix(option, "--restart") { + if phase == "deploy" { + fmt.Printf(" %s", option) + } + continue + } + + switch imageSourceType { + case "dockerfile", "nixpacks", "railpack": + if hasAnyPrefix(option, "--link", "-v", "--volume") { + continue + } + case "herokuish": + if hasAnyPrefix(option, "--file", "--build-args") { + continue + } + } + + fmt.Printf(" %s", option) + } +} + +func hasAnyPrefix(s string, prefixes ...string) bool { + for _, prefix := range prefixes { + if s == prefix || strings.HasPrefix(s, prefix+"=") || strings.HasPrefix(s, prefix+" ") { + return true + } + } + return false +} diff --git a/plugins/domains/domains-enable b/plugins/domains/domains-enable index 5e3127ba9..1070b8c06 100755 --- a/plugins/domains/domains-enable +++ b/plugins/domains/domains-enable @@ -9,9 +9,9 @@ trigger-domains-domains-enable() { declare APP="$1" RESTART_APP="$2" if [[ "$RESTART_APP" == "true" ]]; then - enable_app_vhost "$APP" + domains_enable "$APP" else - enable_app_vhost "$APP" --no-restart + domains_enable "$APP" --no-restart fi } diff --git a/plugins/domains/internal-functions b/plugins/domains/internal-functions index efc644d10..f53335fba 100755 --- a/plugins/domains/internal-functions +++ b/plugins/domains/internal-functions @@ -8,14 +8,15 @@ cmd-domains-report() { declare desc="displays a domains report for one or more apps" declare cmd="domains: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 [[ "$APP" == "--global" ]]; then - cmd-domains-report-single "$APP" "$INFO_FLAG" - return - fi - - if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then + if [[ "$REPORT_IS_GLOBAL" == "true" ]]; then + APP="--global" + elif [[ "$APP" == "--global" ]]; then + : + elif [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then INFO_FLAG="$APP" APP="" fi @@ -24,17 +25,19 @@ cmd-domains-report() { INFO_FLAG="true" fi - if [[ -z "$APP" ]]; then + if [[ "$APP" == "--global" ]]; then + cmd-domains-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" + elif [[ -z "$APP" ]]; then for app in $(dokku_apps); do - cmd-domains-report-single "$app" "$INFO_FLAG" | tee || true + cmd-domains-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true done else - cmd-domains-report-single "$APP" "$INFO_FLAG" + cmd-domains-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" fi } cmd-domains-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 @@ -55,6 +58,13 @@ cmd-domains-report-single() { flag_map=("${app_flags[@]}" "${global_flags[@]}") + fn-report-validate-format "$FORMAT" "$INFO_FLAG" + + if [[ "$FORMAT" == "json" ]]; then + fn-report-emit-json flag_map "domains" + return + fi + if [[ -z "$INFO_FLAG" ]]; then if [[ "$APP" == "--global" ]]; then dokku_log_info2_quiet "Global domains information" diff --git a/plugins/domains/post-app-rename-setup b/plugins/domains/post-app-rename-setup index 3ec256a9c..c754fe2f1 100755 --- a/plugins/domains/post-app-rename-setup +++ b/plugins/domains/post-app-rename-setup @@ -7,11 +7,20 @@ trigger-domains-post-app-rename-setup() { declare trigger="post-app-rename-setup" declare OLD_APP="$1" NEW_APP="$2" - if [[ -f "$DOKKU_ROOT/$OLD_APP/VHOST" ]]; then - while read -r VHOST || [[ -n "$VHOST" ]]; do - sed -e "s/$OLD_APP.$VHOST/$NEW_APP.$VHOST/g" "$DOKKU_ROOT/$OLD_APP/VHOST" >"$DOKKU_ROOT/$NEW_APP/VHOST" - done <"$DOKKU_ROOT/VHOST" + if [[ ! -f "$DOKKU_ROOT/$OLD_APP/VHOST" ]]; then + return fi + + cp -f "$DOKKU_ROOT/$OLD_APP/VHOST" "$DOKKU_ROOT/$NEW_APP/VHOST" + + if [[ ! -f "$DOKKU_ROOT/VHOST" ]]; then + return + fi + + while read -r VHOST || [[ -n "$VHOST" ]]; do + [[ -z "$VHOST" ]] && continue + sed -i -e "s/${OLD_APP}\.${VHOST}/${NEW_APP}.${VHOST}/g" "$DOKKU_ROOT/$NEW_APP/VHOST" + done <"$DOKKU_ROOT/VHOST" } trigger-domains-post-app-rename-setup "$@" diff --git a/plugins/git/functions b/plugins/git/functions index 9de24288d..764942437 100755 --- a/plugins/git/functions +++ b/plugins/git/functions @@ -142,7 +142,7 @@ git_receive_app() { local exit_code=0 acquire_app_deploy_lock "$APP" "exclusive" git_build "$APP" "$REV" || exit_code="$?" - release_app_deploy_lock "$APP" + release_app_deploy_lock "$APP" "$exit_code" return "$exit_code" fi } diff --git a/plugins/git/help-functions b/plugins/git/help-functions index 34d116c51..0ef115c4c 100755 --- a/plugins/git/help-functions +++ b/plugins/git/help-functions @@ -29,6 +29,7 @@ fn-help-content() { cat <, Adds a host to known_hosts git:auth [ ], Configures netrc authentication for a given git server + git:auth-status [ ], Reports whether the netrc entry matches the requested state (exit 0 if matches) git:from-archive [ ], Updates an app's git repository with a given archive file git:from-image [ ], Updates an app's git repository with a given docker image git:load-image [ ], Updates an app's git repository with a docker image loaded from stdin diff --git a/plugins/git/internal-functions b/plugins/git/internal-functions index 18c2f651b..eee315f7b 100755 --- a/plugins/git/internal-functions +++ b/plugins/git/internal-functions @@ -52,6 +52,7 @@ cmd-git-from-archive() { dokku_log_fail "Invalid archive type specified, valid archive types include: tar, tar.gz, zip" fi + dokku_setup_build_capture "$APP" "git:from-archive" plugn trigger git-from-archive "$APP" "$ARCHIVE_URL" "$ARCHIVE_TYPE" "$USER_NAME" "$USER_EMAIL" plugn trigger deploy-source-set "$APP" "$ARCHIVE_TYPE" "$ARCHIVE_URL" } @@ -76,6 +77,8 @@ cmd-git-auth() { declare HOST="$1" USERNAME="$2" PASSWORD="$3" [[ -z "$HOST" ]] && dokku_log_fail "Please supply a git host" + PASSWORD="$(fn-git-auth-read-password "$USERNAME" "$PASSWORD")" + if [[ -n "$USERNAME" ]] && [[ -z "$PASSWORD" ]]; then dokku_log_fail "Missing password for netrc auth entry" fi @@ -91,6 +94,42 @@ cmd-git-auth() { netrc set "$HOST" "$USERNAME" "$PASSWORD" } +cmd-git-auth-status() { + declare desc="reports whether netrc authentication matches the requested state" + local cmd="git:auth-status" + [[ "$1" == "$cmd" ]] && shift 1 + declare HOST="$1" USERNAME="$2" PASSWORD="$3" + [[ -z "$HOST" ]] && dokku_log_fail "Please supply a git host" + + PASSWORD="$(fn-git-auth-read-password "$USERNAME" "$PASSWORD")" + + if [[ -n "$USERNAME" ]] && [[ -z "$PASSWORD" ]]; then + dokku_log_fail "Missing password for netrc auth entry" + fi + + local current + current="$(netrc get "$HOST" 2>/dev/null)" || current="" + + if [[ -z "$USERNAME" ]]; then + [[ -z "$current" ]] && return 0 + return 1 + fi + + [[ "$current" == "${USERNAME}:${PASSWORD}" ]] && return 0 + return 1 +} + +fn-git-auth-read-password() { + declare desc="reads a netrc password from stdin when not provided as an argument" + declare USERNAME="$1" PASSWORD="$2" + + if [[ -n "$USERNAME" ]] && [[ -z "$PASSWORD" ]] && [[ -p /dev/stdin ]]; then + IFS= read -r PASSWORD || true + fi + + echo -n "$PASSWORD" +} + cmd-git-load-image() { declare desc="updates an app's git repository with a docker image loaded from stdin" local cmd="git:load-image" @@ -124,6 +163,7 @@ cmd-git-load-image() { [[ -z "$DOCKER_IMAGE" ]] && dokku_log_fail "Please specify a docker image" [[ ! -t 0 ]] || dokku_log_fail "Expecting tar archive containing docker image on STDIN" + dokku_setup_build_capture "$APP" "git:load-image" cat | docker load if ! verify_image "$DOCKER_IMAGE"; then @@ -177,6 +217,8 @@ cmd-git-from-image() { export DOKKU_APPS_FORCE_DELETE=1 fi + dokku_setup_build_capture "$APP" "git:from-image" + if ! plugn trigger git-from-image "$APP" "$DOCKER_IMAGE" "$BUILD_DIR" "$USER_NAME" "$USER_EMAIL"; then return 1 fi @@ -225,6 +267,8 @@ cmd-git-sync() { DOKKU_DEPLOY_BRANCH="$(fn-git-deploy-branch "$APP")" CURRENT_REF="$(fn-git-cmd "$APP_ROOT" rev-parse "$DOKKU_DEPLOY_BRANCH" 2>/dev/null || true)" + dokku_setup_build_capture "$APP" "git:sync" + if ! fn-git-cmd "$APP_ROOT" rev-parse "$DOKKU_DEPLOY_BRANCH" &>/dev/null; then dokku_log_info1_quiet "Cloning $APP from $GIT_REMOTE#$GIT_REF" fn-git-clone "$APP" "$GIT_REMOTE" "$GIT_REF" "$SKIP_DEPLOY_BRANCH" @@ -278,9 +322,14 @@ cmd-git-report() { declare desc="displays a git report for one or more apps" declare cmd="git:report" [[ "$1" == "$cmd" ]] && shift 1 - declare APP="$1" INFO_FLAG="$2" + fn-report-parse-args "$@" + set -- "${REPORT_ARGS[@]}" - if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then + declare APP="${1:-}" INFO_FLAG="${2:-}" + + if [[ "$REPORT_IS_GLOBAL" == "true" ]]; then + APP="--global" + elif [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then INFO_FLAG="$APP" APP="" fi @@ -289,34 +338,54 @@ cmd-git-report() { INFO_FLAG="true" fi - if [[ -z "$APP" ]]; then + if [[ "$APP" == "--global" ]]; then + cmd-git-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" + elif [[ -z "$APP" ]]; then for app in $(dokku_apps); do - cmd-git-report-single "$app" "$INFO_FLAG" | tee || true + cmd-git-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true done else - cmd-git-report-single "$APP" "$INFO_FLAG" + cmd-git-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" fi } cmd-git-report-single() { - declare APP="$1" INFO_FLAG="$2" + declare APP="$1" INFO_FLAG="$2" FORMAT="${3:-stdout}" local APP_ROOT="$DOKKU_ROOT/$APP" if [[ "$INFO_FLAG" == "true" ]]; then INFO_FLAG="" fi - verify_app_name "$APP" - local flag_map=( - "--git-deploy-branch: $(fn-plugin-property-get "git" "$APP" "deploy-branch" "master")" - "--git-global-deploy-branch: $(fn-plugin-property-get "git" "--global" "deploy-branch" "master")" - "--git-keep-git-dir: $(fn-plugin-property-get "git" "$APP" "keep-git-dir" "false")" - "--git-rev-env-var: $(fn-plugin-property-get "git" "$APP" "rev-env-var" "GIT_REV")" - "--git-sha: $(fn-git-cmd "$APP_ROOT" rev-parse HEAD 2>/dev/null || false)" - "--git-source-image: $(fn-git-source-image "$APP")" - "--git-last-updated-at: $(fn-git-last-updated-at "$APP")" - ) + local flag_map=() + if [[ "$APP" == "--global" ]]; then + flag_map=( + "--git-global-deploy-branch: $(fn-plugin-property-get "git" "--global" "deploy-branch" "master")" + ) + else + verify_app_name "$APP" + flag_map=( + "--git-deploy-branch: $(fn-plugin-property-get "git" "$APP" "deploy-branch" "master")" + "--git-global-deploy-branch: $(fn-plugin-property-get "git" "--global" "deploy-branch" "master")" + "--git-keep-git-dir: $(fn-plugin-property-get "git" "$APP" "keep-git-dir" "false")" + "--git-rev-env-var: $(fn-plugin-property-get "git" "$APP" "rev-env-var" "GIT_REV")" + "--git-sha: $(fn-git-cmd "$APP_ROOT" rev-parse HEAD 2>/dev/null || false)" + "--git-source-image: $(fn-git-source-image "$APP")" + "--git-last-updated-at: $(fn-git-last-updated-at "$APP")" + ) + fi + + fn-report-validate-format "$FORMAT" "$INFO_FLAG" + + if [[ "$FORMAT" == "json" ]]; then + fn-report-emit-json flag_map "git" + return + fi if [[ -z "$INFO_FLAG" ]]; then - dokku_log_info2_quiet "${APP} git information" + if [[ "$APP" == "--global" ]]; then + dokku_log_info2_quiet "global git information" + else + dokku_log_info2_quiet "${APP} git information" + fi for flag in "${flag_map[@]}"; do key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')" dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")" diff --git a/plugins/git/receive-app b/plugins/git/receive-app index ef7f59af1..74316595c 100755 --- a/plugins/git/receive-app +++ b/plugins/git/receive-app @@ -7,6 +7,8 @@ trigger-git-receive-app() { declare desc="builds the app from the local git repository" declare trigger="receive-app" + dokku_setup_build_capture "$1" "git-hook" + git_receive_app "$@" return $? } diff --git a/plugins/git/subcommands/auth-status b/plugins/git/subcommands/auth-status new file mode 100755 index 000000000..8c6f9e4ac --- /dev/null +++ b/plugins/git/subcommands/auth-status @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +source "$PLUGIN_AVAILABLE_PATH/git/internal-functions" +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x + +cmd-git-auth-status "$@" diff --git a/plugins/haproxy-vhosts/command-functions b/plugins/haproxy-vhosts/command-functions index 0f907bb08..9644f5356 100755 --- a/plugins/haproxy-vhosts/command-functions +++ b/plugins/haproxy-vhosts/command-functions @@ -33,9 +33,13 @@ cmd-haproxy-report() { declare desc="displays a haproxy report for one or more apps" declare cmd="haproxy: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,21 +48,25 @@ cmd-haproxy-report() { INFO_FLAG="true" fi - if [[ -z "$APP" ]]; then + if [[ "$APP" == "--global" ]]; then + cmd-haproxy-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" + elif [[ -z "$APP" ]]; then for app in $(dokku_apps); do - cmd-haproxy-report-single "$app" "$INFO_FLAG" | tee || true + cmd-haproxy-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true done else - cmd-haproxy-report-single "$APP" "$INFO_FLAG" + cmd-haproxy-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" fi } cmd-haproxy-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" + if [[ "$APP" != "--global" ]]; then + verify_app_name "$APP" + fi local flag_map=( "--haproxy-image: $(fn-haproxy-image)" "--haproxy-letsencrypt-email: $(fn-haproxy-letsencrypt-email)" @@ -66,8 +74,19 @@ cmd-haproxy-report-single() { "--haproxy-log-level: $(fn-haproxy-log-level)" ) + fn-report-validate-format "$FORMAT" "$INFO_FLAG" + + if [[ "$FORMAT" == "json" ]]; then + fn-report-emit-json flag_map "haproxy" + return + fi + if [[ -z "$INFO_FLAG" ]]; then - dokku_log_info2_quiet "${APP} haproxy information" + if [[ "$APP" == "--global" ]]; then + dokku_log_info2_quiet "global haproxy information" + else + dokku_log_info2_quiet "${APP} haproxy information" + fi for flag in "${flag_map[@]}"; do key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')" dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")" diff --git a/plugins/logs/go.mod b/plugins/logs/go.mod index a3257e947..beefb8026 100644 --- a/plugins/logs/go.mod +++ b/plugins/logs/go.mod @@ -1,6 +1,6 @@ module github.com/dokku/dokku/plugins/logs -go 1.25.5 +go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 @@ -26,6 +26,7 @@ require ( golang.org/x/crypto v0.50.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect + mvdan.cc/sh/v3 v3.13.1 // indirect ) replace github.com/dokku/dokku/plugins/common => ../common diff --git a/plugins/logs/go.sum b/plugins/logs/go.sum index ae82beeb5..ff9894aa5 100644 --- a/plugins/logs/go.sum +++ b/plugins/logs/go.sum @@ -6,6 +6,8 @@ github.com/fastfishio/qson v1.0.1 h1:w+QEKeOZOXPsAJSGPxF1/V5+NhHycvPog8D9xnAhSJM github.com/fastfishio/qson v1.0.1/go.mod h1:u2Poc2D8P8hB2pgZTv+l5UDd8QgVVKPZfED/04nAuFs= 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-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= 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= @@ -14,14 +16,18 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l 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/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= @@ -32,6 +38,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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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= @@ -55,3 +63,5 @@ 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= +mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= +mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/plugins/logs/report.go b/plugins/logs/report.go index a18402473..714f09470 100644 --- a/plugins/logs/report.go +++ b/plugins/logs/report.go @@ -9,22 +9,34 @@ import ( // ReportSingleApp is an internal function that displays the logs 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 + } } os.Setenv("DOKKU_REPORT_FORMAT", format) os.Setenv("DOKKU_REPORT_FLAG", infoFlag) - flags := map[string]common.ReportFunc{ - "--logs-computed-app-label-alias": reportComputedAppLabelAlias, - "--logs-computed-max-size": reportComputedMaxSize, - "--logs-global-app-label-alias": reportGlobalAppLabelAlias, - "--logs-global-max-size": reportGlobalMaxSize, - "--logs-global-vector-sink": reportGlobalVectorSink, - "--logs-app-label-alias": reportAppLabelAlias, - "--logs-max-size": reportMaxSize, - "--logs-vector-global-image": reportVectorGlobalImage, - "--logs-vector-sink": reportVectorSink, + var flags map[string]common.ReportFunc + if appName == "--global" { + flags = map[string]common.ReportFunc{ + "--logs-global-app-label-alias": reportGlobalAppLabelAlias, + "--logs-global-max-size": reportGlobalMaxSize, + "--logs-global-vector-sink": reportGlobalVectorSink, + "--logs-vector-global-image": reportVectorGlobalImage, + } + } else { + flags = map[string]common.ReportFunc{ + "--logs-computed-app-label-alias": reportComputedAppLabelAlias, + "--logs-computed-max-size": reportComputedMaxSize, + "--logs-global-app-label-alias": reportGlobalAppLabelAlias, + "--logs-global-max-size": reportGlobalMaxSize, + "--logs-global-vector-sink": reportGlobalVectorSink, + "--logs-app-label-alias": reportAppLabelAlias, + "--logs-max-size": reportMaxSize, + "--logs-vector-global-image": reportVectorGlobalImage, + "--logs-vector-sink": reportVectorSink, + } } flagKeys := []string{} diff --git a/plugins/logs/src/subcommands/subcommands.go b/plugins/logs/src/subcommands/subcommands.go index a9f56604f..5dde88b50 100644 --- a/plugins/logs/src/subcommands/subcommands.go +++ b/plugins/logs/src/subcommands/subcommands.go @@ -27,11 +27,14 @@ func main() { case "report": args := flag.NewFlagSet("logs:report", flag.ExitOnError) format := args.String("format", "stdout", "format: [ stdout | json ]") - osArgs, infoFlag, flagErr := common.ParseReportArgs("logs", os.Args[2:]) + reportArgs, flagErr := common.ParseReportArgs("logs", os.Args[2:]) if flagErr == nil { - args.Parse(osArgs) + args.Parse(reportArgs.OSArgs) appName := args.Arg(0) - err = logs.CommandReport(appName, *format, infoFlag) + if reportArgs.IsGlobal { + appName = "--global" + } + err = logs.CommandReport(appName, *format, reportArgs.InfoFlag) } case "set": args := flag.NewFlagSet("logs:set", flag.ExitOnError) diff --git a/plugins/network/go.mod b/plugins/network/go.mod index 0d1afb6e2..1d17561c9 100644 --- a/plugins/network/go.mod +++ b/plugins/network/go.mod @@ -1,6 +1,6 @@ module github.com/dokku/dokku/plugins/network -go 1.25.5 +go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 diff --git a/plugins/network/go.sum b/plugins/network/go.sum index c97fc9665..e973ec8a5 100644 --- a/plugins/network/go.sum +++ b/plugins/network/go.sum @@ -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= diff --git a/plugins/network/report.go b/plugins/network/report.go index fb2847a4f..9ddfcd67b 100644 --- a/plugins/network/report.go +++ b/plugins/network/report.go @@ -8,28 +8,41 @@ import ( // ReportSingleApp is an internal function that displays the network 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{ - "--network-bind-all-interfaces": reportBindAllInterfaces, - "--network-attach-post-create": reportAttachPostCreate, - "--network-attach-post-deploy": reportAttachPostDeploy, - "--network-computed-attach-post-create": reportComputedAttachPostCreate, - "--network-computed-attach-post-deploy": reportComputedAttachPostDeploy, - "--network-computed-bind-all-interfaces": reportComputedBindAllInterfaces, - "--network-computed-initial-network": reportComputedInitialNetwork, - "--network-computed-tld": reportComputedTld, - "--network-global-attach-post-create": reportGlobalAttachPostCreate, - "--network-global-attach-post-deploy": reportGlobalAttachPostDeploy, - "--network-global-bind-all-interfaces": reportGlobalBindAllInterfaces, - "--network-global-initial-network": reportGlobalInitialNetwork, - "--network-global-tld": reportGlobalTld, - "--network-initial-network": reportInitialNetwork, - "--network-static-web-listener": reportStaticWebListener, - "--network-tld": reportTld, - "--network-web-listeners": reportWebListeners, + var flags map[string]common.ReportFunc + if appName == "--global" { + flags = map[string]common.ReportFunc{ + "--network-global-attach-post-create": reportGlobalAttachPostCreate, + "--network-global-attach-post-deploy": reportGlobalAttachPostDeploy, + "--network-global-bind-all-interfaces": reportGlobalBindAllInterfaces, + "--network-global-initial-network": reportGlobalInitialNetwork, + "--network-global-tld": reportGlobalTld, + } + } else { + flags = map[string]common.ReportFunc{ + "--network-bind-all-interfaces": reportBindAllInterfaces, + "--network-attach-post-create": reportAttachPostCreate, + "--network-attach-post-deploy": reportAttachPostDeploy, + "--network-computed-attach-post-create": reportComputedAttachPostCreate, + "--network-computed-attach-post-deploy": reportComputedAttachPostDeploy, + "--network-computed-bind-all-interfaces": reportComputedBindAllInterfaces, + "--network-computed-initial-network": reportComputedInitialNetwork, + "--network-computed-tld": reportComputedTld, + "--network-global-attach-post-create": reportGlobalAttachPostCreate, + "--network-global-attach-post-deploy": reportGlobalAttachPostDeploy, + "--network-global-bind-all-interfaces": reportGlobalBindAllInterfaces, + "--network-global-initial-network": reportGlobalInitialNetwork, + "--network-global-tld": reportGlobalTld, + "--network-initial-network": reportInitialNetwork, + "--network-static-web-listener": reportStaticWebListener, + "--network-tld": reportTld, + "--network-web-listeners": reportWebListeners, + } } flagKeys := []string{} diff --git a/plugins/network/src/subcommands/subcommands.go b/plugins/network/src/subcommands/subcommands.go index 359a3d37a..02e4cc7d2 100644 --- a/plugins/network/src/subcommands/subcommands.go +++ b/plugins/network/src/subcommands/subcommands.go @@ -57,11 +57,14 @@ func main() { case "report": args := flag.NewFlagSet("network:report", flag.ExitOnError) format := args.String("format", "stdout", "format: [ stdout | json ]") - osArgs, infoFlag, flagErr := common.ParseReportArgs("network", os.Args[2:]) + reportArgs, flagErr := common.ParseReportArgs("network", os.Args[2:]) if flagErr == nil { - args.Parse(osArgs) + args.Parse(reportArgs.OSArgs) appName := args.Arg(0) - err = network.CommandReport(appName, *format, infoFlag) + if reportArgs.IsGlobal { + appName = "--global" + } + err = network.CommandReport(appName, *format, reportArgs.InfoFlag) } case "set": args := flag.NewFlagSet("network:set", flag.ExitOnError) diff --git a/plugins/nginx-vhosts/command-functions b/plugins/nginx-vhosts/command-functions index 84490ec8a..b75eb711d 100755 --- a/plugins/nginx-vhosts/command-functions +++ b/plugins/nginx-vhosts/command-functions @@ -10,9 +10,14 @@ cmd-nginx-report() { declare desc="displays a nginx report for one or more apps" declare cmd="nginx:report" [[ "$1" == "$cmd" ]] && shift 1 - declare APP="$1" INFO_FLAG="$2" + fn-report-parse-args "$@" + set -- "${REPORT_ARGS[@]}" - if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then + declare APP="${1:-}" INFO_FLAG="${2:-}" + + if [[ "$REPORT_IS_GLOBAL" == "true" ]]; then + APP="--global" + elif [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then INFO_FLAG="$APP" APP="" fi @@ -21,21 +26,25 @@ cmd-nginx-report() { INFO_FLAG="true" fi - if [[ -z "$APP" ]]; then + if [[ "$APP" == "--global" ]]; then + cmd-nginx-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" + elif [[ -z "$APP" ]]; then for app in $(dokku_apps); do - cmd-nginx-report-single "$app" "$INFO_FLAG" | tee || true + cmd-nginx-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true done else - cmd-nginx-report-single "$APP" "$INFO_FLAG" + cmd-nginx-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" fi } cmd-nginx-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" + if [[ "$APP" != "--global" ]]; then + verify_app_name "$APP" + fi local flag_map=( "--nginx-access-log-format: $(fn-nginx-access-log-format "$APP")" "--nginx-computed-access-log-format: $(fn-nginx-computed-access-log-format "$APP")" @@ -130,8 +139,23 @@ cmd-nginx-report-single() { "--nginx-global-x-forwarded-ssl: $(fn-nginx-global-x-forwarded-ssl "$APP")" ) + if [[ "$APP" == "--global" ]]; then + fn-report-filter-global flag_map + fi + + fn-report-validate-format "$FORMAT" "$INFO_FLAG" + + if [[ "$FORMAT" == "json" ]]; then + fn-report-emit-json flag_map "nginx" + return + fi + if [[ -z "$INFO_FLAG" ]]; then - dokku_log_info2_quiet "${APP} nginx information" + if [[ "$APP" == "--global" ]]; then + dokku_log_info2_quiet "global nginx information" + else + dokku_log_info2_quiet "${APP} nginx information" + fi for flag in "${flag_map[@]}"; do key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')" dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")" @@ -203,6 +227,29 @@ cmd-nginx-stop() { fn-nginx-vhosts-nginx-init-cmd "disable" } +cmd-nginx-reload() { + declare desc="reloads the nginx server config" + declare cmd="nginx:reload" + [[ "$1" == "$cmd" ]] && shift 1 + + local NGINX_LOCATION EXIT_CODE + NGINX_LOCATION=$(get_nginx_location) + if [[ -z "$NGINX_LOCATION" ]]; then + exit 1 + fi + + set +e + sudo "$NGINX_LOCATION" -t &>/dev/null + EXIT_CODE=$? + set -e + if [[ "$EXIT_CODE" -ne "0" ]]; then + sudo "$NGINX_LOCATION" -t + exit "$EXIT_CODE" + fi + + fn-nginx-vhosts-nginx-init-cmd "reload" +} + cmd-nginx-validate-config() { declare desc="validates and optionally cleans up invalid nginx configurations" declare cmd="nginx:validate-config" diff --git a/plugins/nginx-vhosts/core-post-extract b/plugins/nginx-vhosts/core-post-extract index 04cd81dac..036facb9b 100755 --- a/plugins/nginx-vhosts/core-post-extract +++ b/plugins/nginx-vhosts/core-post-extract @@ -1,6 +1,7 @@ #!/usr/bin/env bash source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" source "$PLUGIN_AVAILABLE_PATH/nginx-vhosts/internal-functions" +source "$PLUGIN_AVAILABLE_PATH/nginx-vhosts/functions" set -eo pipefail [[ $DOKKU_TRACE ]] && set -x @@ -49,6 +50,8 @@ trigger-nginx-vhosts-core-post-extract() { else fn-nginx-vhosts-copy-from-directory "$APP" "$SOURCECODE_WORK_DIR" "$CONF_SIGIL_PATH" fi + + fn-nginx-vhosts-pre-validate-custom-template "$APP" } trigger-nginx-vhosts-core-post-extract "$@" diff --git a/plugins/nginx-vhosts/functions b/plugins/nginx-vhosts/functions index 0f81141c4..f15556cbf 100755 --- a/plugins/nginx-vhosts/functions +++ b/plugins/nginx-vhosts/functions @@ -193,6 +193,247 @@ is_http2_directive_supported() { echo $HAS_SUPPORT } +fn-nginx-vhosts-render-template() { + declare desc="renders an nginx.conf.sigil template via sigil to an output path" + declare APP="$1" NGINX_TEMPLATE="$2" OUTPUT_PATH="$3" + declare DOKKU_APP_LISTEN_PORT="$4" DOKKU_APP_LISTEN_IP="$5" + declare PRE_VALIDATE="${6:-false}" + local VHOST_PATH="$DOKKU_ROOT/$APP/VHOST" + local APP_SSL_PATH="$DOKKU_ROOT/$APP/tls" + local DOKKU_APP_LISTENERS PASSED_LISTEN_IP_PORT + + local IS_APP_VHOST_ENABLED=true + plugn trigger domains-vhost-enabled "$APP" 2>/dev/null || IS_APP_VHOST_ENABLED=false + + local IS_SSL_ENABLED=false + if [[ "$(plugn trigger certs-exists "$APP")" == "true" ]]; then + IS_SSL_ENABLED=true + fi + + if [[ -z "$DOKKU_APP_LISTEN_PORT" ]] && [[ -z "$DOKKU_APP_LISTEN_IP" ]]; then + DOKKU_APP_LISTENERS="$(plugn trigger network-get-listeners "$APP" "web" | xargs)" + elif [[ -n "$DOKKU_APP_LISTEN_PORT" ]] && [[ -n "$DOKKU_APP_LISTEN_IP" ]]; then + PASSED_LISTEN_IP_PORT=true + fi + + local PROXY_PORT=$(plugn trigger ports-get-property "$APP" "proxy-port") + local PROXY_SSL_PORT=$(plugn trigger ports-get-property "$APP" "proxy-ssl-port") + + local PORT_MAP PROXY_PORT_MAP proxy_port_map PROXY_UPSTREAM_PORTS="" + while read -r PORT_MAP; do + local PROXY_UPSTREAM_SCHEME="$(awk -F ':' '{ print $1 }' <<<"$PORT_MAP")" + if [[ "$PROXY_UPSTREAM_SCHEME" == "https" ]] && [[ "$IS_SSL_ENABLED" == "false" ]]; then + continue + fi + + proxy_port_map="$proxy_port_map $PORT_MAP" + + local PROXY_UPSTREAM_PORT="$(awk -F ':' '{ print $3 }' <<<"$PORT_MAP")" + if [[ "$(is_val_in_list "$PROXY_UPSTREAM_PORT" "$PROXY_UPSTREAM_PORTS" " ")" == "false" ]]; then + PROXY_UPSTREAM_PORTS+="$PROXY_UPSTREAM_PORT " + fi + done < <(plugn trigger ports-get "$APP") + PROXY_PORT_MAP="$(echo "$proxy_port_map" | xargs)" # trailing spaces mess up default template + PROXY_UPSTREAM_PORTS="$(echo "$PROXY_UPSTREAM_PORTS" | xargs)" + + local SSL_INUSE= + local NONSSL_VHOSTS=$(plugn trigger domains-list "$APP") + local NOSSL_SERVER_NAME=$(echo "$NONSSL_VHOSTS" | xargs) + local SSL_SERVER_NAME="" + if [[ "$IS_SSL_ENABLED" == "true" ]]; then + SSL_INUSE=true + local SSL_HOSTNAME=$(get_ssl_hostnames "$APP") + local SSL_HOSTNAME_REGEX=$(echo "$SSL_HOSTNAME" | xargs | sed 's|\.|\\.|g' | sed 's/\*/\[^\.\]\*/g' | sed 's/ /|/g') + + local SSL_VHOSTS + if [[ "$IS_APP_VHOST_ENABLED" == "true" ]]; then + SSL_VHOSTS=$(grep -E "^${SSL_HOSTNAME_REGEX}$" "$VHOST_PATH" || true) + else + SSL_VHOSTS=$(<"$DOKKU_ROOT/VHOST") + fi + local host + for host in $SSL_VHOSTS; do + if [[ ! $NOSSL_SERVER_NAME =~ (^|[[:space:]])$host($|[[:space:]]) ]]; then + SSL_SERVER_NAME="${host}${SSL_SERVER_NAME:+ $SSL_SERVER_NAME}" + fi + done + fi + + local NGINX_LOCATION NGINX_VERSION SPDY_SUPPORTED TLS13_SUPPORTED HTTP2_SUPPORTED HTTP2_PUSH_SUPPORTED HTTP2_DIRECTIVE_SUPPORTED GRPC_SUPPORTED + NGINX_LOCATION=$(get_nginx_location) + if [[ -z "$NGINX_LOCATION" ]]; then + return 1 + fi + NGINX_VERSION="$("$NGINX_LOCATION" -v 2>&1 | cut -d'/' -f 2 | awk '{print $1}')" + # DEPRECATED: Remove me at 1.0.0 + SPDY_SUPPORTED="false" + TLS13_SUPPORTED="true" + HTTP2_SUPPORTED="true" + HTTP2_DIRECTIVE_SUPPORTED="$(is_http2_directive_supported "$NGINX_VERSION")" + HTTP2_PUSH_SUPPORTED="$(is_http2_push_enabled "$NGINX_VERSION")" + GRPC_SUPPORTED="true" + + local NGINX_LOG_ROOT="$(fn-nginx-log-root)" + local NGINX_ACCESS_LOG_FORMAT="$(fn-nginx-computed-access-log-format "$APP")" + local NGINX_ACCESS_LOG_PATH="$(fn-nginx-computed-access-log-path "$APP")" + local NGINX_ERROR_LOG_PATH="$(fn-nginx-computed-error-log-path "$APP")" + local CLIENT_BODY_TIMEOUT="$(fn-nginx-computed-client-body-timeout "$APP")" + local CLIENT_HEADER_TIMEOUT="$(fn-nginx-computed-client-header-timeout "$APP")" + local CLIENT_MAX_BODY_SIZE="$(fn-nginx-computed-client-max-body-size "$APP")" + local KEEPALIVE_TIMEOUT="$(fn-nginx-computed-keepalive-timeout "$APP")" + local LINGERING_TIMEOUT="$(fn-nginx-computed-lingering-timeout "$APP")" + local PROXY_CONNECT_TIMEOUT="$(fn-nginx-computed-proxy-connect-timeout "$APP")" + local PROXY_READ_TIMEOUT="$(fn-nginx-computed-proxy-read-timeout "$APP")" + local PROXY_SEND_TIMEOUT="$(fn-nginx-computed-proxy-send-timeout "$APP")" + local SEND_TIMEOUT="$(fn-nginx-computed-send-timeout "$APP")" + local PROXY_BUFFER_SIZE="$(fn-nginx-computed-proxy-buffer-size "$APP")" + local PROXY_BUFFERING="$(fn-nginx-computed-proxy-buffering "$APP")" + local PROXY_BUFFERS="$(fn-nginx-computed-proxy-buffers "$APP")" + local PROXY_BUSY_BUFFERS_SIZE="$(fn-nginx-computed-proxy-busy-buffers-size "$APP")" + local PROXY_KEEPALIVE="$(fn-nginx-computed-proxy-keepalive "$APP")" + + local NGINX_BIND_ADDRESS_IP4="$(fn-nginx-computed-bind-address-ipv4 "$APP")" + local NGINX_BIND_ADDRESS_IP6="$(fn-nginx-computed-bind-address-ipv6 "$APP")" + local NGINX_UNDERSCORE_IN_HEADERS="$(fn-nginx-computed-underscore-in-headers "$APP")" + local PROXY_X_FORWARDED_FOR="$(fn-nginx-computed-x-forwarded-for-value "$APP")" + local PROXY_X_FORWARDED_PORT="$(fn-nginx-computed-x-forwarded-port-value "$APP")" + local PROXY_X_FORWARDED_PROTO="$(fn-nginx-computed-x-forwarded-proto-value "$APP")" + local PROXY_X_FORWARDED_SSL="$(fn-nginx-computed-x-forwarded-ssl "$APP")" + + local IS_DEPLOYED_WITH_LISTENERS=false + if [[ -n "$DOKKU_APP_LISTENERS" ]] && (is_deployed "$APP"); then + IS_DEPLOYED_WITH_LISTENERS=true + fi + + if [[ "$IS_DEPLOYED_WITH_LISTENERS" == "true" ]]; then + eval "$(config_export app "$APP")" + fi + + local SIGIL_PARAMS=(-f "$NGINX_TEMPLATE" APP="$APP" DOKKU_ROOT="$DOKKU_ROOT" + NOSSL_SERVER_NAME="$NOSSL_SERVER_NAME" + # Deprecated: Remove this after a few versions + DOKKU_APP_LISTENERS="$DOKKU_APP_LISTENERS" + DOKKU_LIB_ROOT="$DOKKU_LIB_ROOT" + PASSED_LISTEN_IP_PORT="$PASSED_LISTEN_IP_PORT" + SPDY_SUPPORTED="$SPDY_SUPPORTED" + TLS13_SUPPORTED="$TLS13_SUPPORTED" + HTTP2_SUPPORTED="$HTTP2_SUPPORTED" + NGINX_LOG_ROOT="$NGINX_LOG_ROOT" + NGINX_ACCESS_LOG_FORMAT="$NGINX_ACCESS_LOG_FORMAT" + NGINX_ACCESS_LOG_PATH="$NGINX_ACCESS_LOG_PATH" + NGINX_ERROR_LOG_PATH="$NGINX_ERROR_LOG_PATH" + NGINX_BIND_ADDRESS_IP4="$NGINX_BIND_ADDRESS_IP4" + NGINX_BIND_ADDRESS_IP6="$NGINX_BIND_ADDRESS_IP6" + HTTP2_DIRECTIVE_SUPPORTED="$HTTP2_DIRECTIVE_SUPPORTED" + HTTP2_PUSH_SUPPORTED="$HTTP2_PUSH_SUPPORTED" + GRPC_SUPPORTED="$GRPC_SUPPORTED" + DOKKU_APP_LISTEN_PORT="$DOKKU_APP_LISTEN_PORT" DOKKU_APP_LISTEN_IP="$DOKKU_APP_LISTEN_IP" + APP_SSL_PATH="$APP_SSL_PATH" SSL_INUSE="$SSL_INUSE" SSL_SERVER_NAME="$SSL_SERVER_NAME" + CLIENT_BODY_TIMEOUT="$CLIENT_BODY_TIMEOUT" + CLIENT_HEADER_TIMEOUT="$CLIENT_HEADER_TIMEOUT" + CLIENT_MAX_BODY_SIZE="$CLIENT_MAX_BODY_SIZE" + KEEPALIVE_TIMEOUT="$KEEPALIVE_TIMEOUT" + LINGERING_TIMEOUT="$LINGERING_TIMEOUT" + PROXY_CONNECT_TIMEOUT="$PROXY_CONNECT_TIMEOUT" + PROXY_READ_TIMEOUT="$PROXY_READ_TIMEOUT" + PROXY_SEND_TIMEOUT="$PROXY_SEND_TIMEOUT" + SEND_TIMEOUT="$SEND_TIMEOUT" + PROXY_BUFFER_SIZE="$PROXY_BUFFER_SIZE" + PROXY_BUFFERING="$PROXY_BUFFERING" + PROXY_BUFFERS="$PROXY_BUFFERS" + PROXY_BUSY_BUFFERS_SIZE="$PROXY_BUSY_BUFFERS_SIZE" + PROXY_KEEPALIVE="$PROXY_KEEPALIVE" + NGINX_UNDERSCORE_IN_HEADERS="$NGINX_UNDERSCORE_IN_HEADERS" + # Deprecated: Remove this after a few versions + NGINX_PORT="$PROXY_PORT" NGINX_SSL_PORT="$PROXY_SSL_PORT" + PROXY_PORT="$PROXY_PORT" PROXY_SSL_PORT="$PROXY_SSL_PORT" + PROXY_PORT_MAP="$PROXY_PORT_MAP" PROXY_UPSTREAM_PORTS="$PROXY_UPSTREAM_PORTS" + PROXY_X_FORWARDED_FOR="$PROXY_X_FORWARDED_FOR" + PROXY_X_FORWARDED_PORT="$PROXY_X_FORWARDED_PORT" + PROXY_X_FORWARDED_PROTO="$PROXY_X_FORWARDED_PROTO" + PROXY_X_FORWARDED_SSL="$PROXY_X_FORWARDED_SSL") + + if [[ "$IS_DEPLOYED_WITH_LISTENERS" == "true" ]]; then + local line PROC_TYPE LISTENERS UPP_PROC_TYPE + while read -r line || [[ -n "$line" ]]; do + PROC_TYPE=${line%%=*} + LISTENERS="$(plugn trigger network-get-listeners "$APP" "$PROC_TYPE" | xargs)" + UPP_PROC_TYPE="${PROC_TYPE^^}" + UPP_PROC_TYPE="${UPP_PROC_TYPE//-/_}" + SIGIL_PARAMS+=("DOKKU_APP_${UPP_PROC_TYPE}_LISTENERS=$LISTENERS") + done < <(plugn trigger ps-current-scale "$APP") + elif [[ "$PRE_VALIDATE" == "true" ]]; then + # During pre-validation no listeners exist yet; supply a placeholder so + # any upstream block in the template renders with a static server entry + # and nginx -t does not bail out on "host not found in upstream". + SIGIL_PARAMS+=(DOKKU_APP_WEB_LISTENERS="127.0.0.1:5000") + else + SIGIL_PARAMS+=(DOKKU_APP_WEB_LISTENERS="") + fi + + sigil "${SIGIL_PARAMS[@]}" | cat -s >"$OUTPUT_PATH" + return $? +} + +fn-nginx-vhosts-pre-validate-custom-template() { + declare desc="pre-validates an app's custom nginx.conf.sigil before the build phase" + declare APP="$1" + local PID_TEMPLATE="${DOKKU_LIB_ROOT}/data/nginx-vhosts/app-$APP/nginx.conf.sigil.$DOKKU_PID" + local PID_MISSING="${PID_TEMPLATE}.missing" + local DISABLE_CUSTOM_CONFIG NGINX_LOCATION + + if [[ "$(plugn trigger proxy-type "$APP")" != "nginx" ]]; then + return 0 + fi + + DISABLE_CUSTOM_CONFIG="$(fn-nginx-computed-disable-custom-config "$APP")" + if [[ "$DISABLE_CUSTOM_CONFIG" == "true" ]]; then + return 0 + fi + + if [[ -f "$PID_MISSING" ]] || [[ ! -f "$PID_TEMPLATE" ]]; then + return 0 + fi + + NGINX_LOCATION="$(get_nginx_location)" + if [[ -z "$NGINX_LOCATION" ]]; then + dokku_log_fail "Unable to locate nginx binary for pre-validation" + fi + + # ensure proxy-port/proxy-ssl-port exist for the render + plugn trigger ports-configure "$APP" >/dev/null + + local TMP_WORK_DIR + TMP_WORK_DIR="$(mktemp -d "/tmp/dokku-${DOKKU_PID}-pre-validate-nginx.XXXXXX")" + trap "rm -rf '$TMP_WORK_DIR' >/dev/null" RETURN INT TERM EXIT + + local TMP_RENDERED="$TMP_WORK_DIR/nginx.conf" + local TMP_WRAPPER="$TMP_WORK_DIR/wrapper.conf" + local VALIDATE_TEMPLATE="$PLUGIN_AVAILABLE_PATH/nginx-vhosts/templates/validate.conf.sigil" + local CUSTOM_VALIDATE_TEMPLATE + CUSTOM_VALIDATE_TEMPLATE="$(plugn trigger nginx-app-template-source "$APP" "validate-config")" + if [[ -n "$CUSTOM_VALIDATE_TEMPLATE" ]]; then + VALIDATE_TEMPLATE="$CUSTOM_VALIDATE_TEMPLATE" + fi + + dokku_log_info1 "Pre-validating custom nginx.conf.sigil" + + if ! fn-nginx-vhosts-render-template "$APP" "$PID_TEMPLATE" "$TMP_RENDERED" "" "" "true" >/dev/null; then + dokku_log_warn "Failed to render custom nginx.conf.sigil. Contents below..." + cat "$PID_TEMPLATE" + dokku_log_fail "Custom nginx.conf.sigil failed to render via sigil" + fi + + sigil -f "$VALIDATE_TEMPLATE" NGINX_CONF="$TMP_RENDERED" | cat -s >"$TMP_WRAPPER" + + if ! sudo "$NGINX_LOCATION" -t -c "$TMP_WRAPPER" 2>/dev/null; then + dokku_log_warn "Failed to validate custom nginx.conf.sigil. Rendered output below..." + cat "$TMP_RENDERED" + sudo "$NGINX_LOCATION" -t -c "$TMP_WRAPPER" || true + dokku_log_fail "Custom nginx.conf.sigil failed nginx -t validation" + fi +} + nginx_build_config() { declare desc="build nginx config to proxy app containers using sigil" declare APP="$1" DOKKU_APP_LISTEN_PORT="$2" DOKKU_APP_LISTEN_IP="$3" @@ -201,12 +442,11 @@ nginx_build_config() { local NGINX_TEMPLATE="$PLUGIN_AVAILABLE_PATH/nginx-vhosts/templates/$NGINX_TEMPLATE_NAME" local SCHEME=http local NGINX_TEMPLATE_SOURCE="built-in" - local APP_SSL_PATH="$DOKKU_ROOT/$APP/tls" local DOKKU_APP_LISTENERS CUSTOM_NGINX_TEMPLATE="$(plugn trigger nginx-app-template-source "$APP" "app-config")" if [[ -n "$CUSTOM_NGINX_TEMPLATE" ]]; then - local NGINX_TEMPLATE_SOURCE="plugin-supplied" + NGINX_TEMPLATE_SOURCE="plugin-supplied" NGINX_TEMPLATE="$CUSTOM_NGINX_TEMPLATE" fi @@ -227,8 +467,6 @@ nginx_build_config() { # setup nginx listen ports plugn trigger ports-configure "$APP" - local PROXY_PORT=$(config_get "$APP" DOKKU_PROXY_PORT) - local PROXY_SSL_PORT=$(config_get "$APP" DOKKU_PROXY_SSL_PORT) local PORT_MAP PROXY_PORT_MAP proxy_port_map while read -r PORT_MAP; do @@ -239,21 +477,14 @@ nginx_build_config() { fi proxy_port_map="$proxy_port_map $PORT_MAP" - - local PROXY_UPSTREAM_PORT="$(awk -F ':' '{ print $3 }' <<<"$PORT_MAP")" - if [[ "$(is_val_in_list "$PROXY_UPSTREAM_PORT" "$PROXY_UPSTREAM_PORTS" " ")" == "false" ]]; then - local PROXY_UPSTREAM_PORTS+="$PROXY_UPSTREAM_PORT " - fi done < <(plugn trigger ports-get "$APP") PROXY_PORT_MAP="$(echo "$proxy_port_map" | xargs)" # trailing spaces mess up default template - local PROXY_UPSTREAM_PORTS="$(echo "$PROXY_UPSTREAM_PORTS" | xargs)" local SSL_INUSE= local NONSSL_VHOSTS=$(plugn trigger domains-list "$APP") - local NOSSL_SERVER_NAME=$(echo "$NONSSL_VHOSTS" | xargs) if [[ "$IS_SSL_ENABLED" == "true" ]]; then - local SSL_INUSE=true - local SCHEME=https + SSL_INUSE=true + SCHEME=https validate_ssl_domains "$APP" local SSL_HOSTNAME=$(get_ssl_hostnames "$APP") local SSL_HOSTNAME_REGEX=$(echo "$SSL_HOSTNAME" | xargs | sed 's|\.|\\.|g' | sed 's/\*/\[^\.\]\*/g' | sed 's/ /|/g') @@ -263,153 +494,64 @@ nginx_build_config() { else local SSL_VHOSTS=$(<"$DOKKU_ROOT/VHOST") fi - local SSL_SERVER_NAME - local host - for host in $SSL_VHOSTS; do - # SSL_SERVER_NAME should only contain items not in NOSSL_SERVER_NAME - if [[ ! $NOSSL_SERVER_NAME =~ (^|[[:space:]])$host($|[[:space:]]) ]]; then - SSL_SERVER_NAME="${host}${SSL_SERVER_NAME:+ $SSL_SERVER_NAME}" - fi - done fi - local NGINX_LOCATION NGINX_VERSION SPDY_SUPPORTED TLS13_SUPPORTED HTTP2_SUPPORTED HTTP2_PUSH_SUPPORTED HTTP2_DIRECTIVE_SUPPORTED GRPC_SUPPORTED - NGINX_LOCATION=$(get_nginx_location) - if [[ -z "$NGINX_LOCATION" ]]; then - exit 1 - fi - NGINX_VERSION="$("$NGINX_LOCATION" -v 2>&1 | cut -d'/' -f 2 | awk '{print $1}')" - # DEPRECATED: Remove me at 1.0.0 - SPDY_SUPPORTED="false" - TLS13_SUPPORTED="true" - HTTP2_SUPPORTED="true" - HTTP2_DIRECTIVE_SUPPORTED="$(is_http2_directive_supported "$NGINX_VERSION")" - HTTP2_PUSH_SUPPORTED="$(is_http2_push_enabled "$NGINX_VERSION")" - GRPC_SUPPORTED="true" - - local NGINX_LOG_ROOT="$(fn-nginx-log-root)" - local NGINX_ACCESS_LOG_FORMAT="$(fn-nginx-computed-access-log-format "$APP")" - local NGINX_ACCESS_LOG_PATH="$(fn-nginx-computed-access-log-path "$APP")" - local NGINX_ERROR_LOG_PATH="$(fn-nginx-computed-error-log-path "$APP")" - local CLIENT_BODY_TIMEOUT="$(fn-nginx-computed-client-body-timeout "$APP")" - local CLIENT_HEADER_TIMEOUT="$(fn-nginx-computed-client-header-timeout "$APP")" - local CLIENT_MAX_BODY_SIZE="$(fn-nginx-computed-client-max-body-size "$APP")" - local KEEPALIVE_TIMEOUT="$(fn-nginx-computed-keepalive-timeout "$APP")" - local LINGERING_TIMEOUT="$(fn-nginx-computed-lingering-timeout "$APP")" - local PROXY_CONNECT_TIMEOUT="$(fn-nginx-computed-proxy-connect-timeout "$APP")" - local PROXY_READ_TIMEOUT="$(fn-nginx-computed-proxy-read-timeout "$APP")" - local PROXY_SEND_TIMEOUT="$(fn-nginx-computed-proxy-send-timeout "$APP")" - local SEND_TIMEOUT="$(fn-nginx-computed-send-timeout "$APP")" - local PROXY_BUFFER_SIZE="$(fn-nginx-computed-proxy-buffer-size "$APP")" - local PROXY_BUFFERING="$(fn-nginx-computed-proxy-buffering "$APP")" - local PROXY_BUFFERS="$(fn-nginx-computed-proxy-buffers "$APP")" - local PROXY_BUSY_BUFFERS_SIZE="$(fn-nginx-computed-proxy-busy-buffers-size "$APP")" - local PROXY_KEEPALIVE="$(fn-nginx-computed-proxy-keepalive "$APP")" - - if [[ -z "$DOKKU_APP_LISTENERS" ]]; then + if [[ -z "$DOKKU_APP_LISTENERS" ]] && [[ -z "$PASSED_LISTEN_IP_PORT" ]]; then dokku_log_warn_quiet "No web listeners specified for $APP" - elif (is_deployed "$APP"); then - if [[ "$(plugn trigger network-get-static-listeners "$APP" "web")" == "" ]]; then - local IMAGE_TAG=$(get_running_image_tag "$APP") - local IMAGE=$(get_deploying_app_image_name "$APP" "$IMAGE_TAG" 2>/dev/null) - if ! verify_image "$IMAGE" 2>/dev/null; then - dokku_log_fail "Missing image for app" + fi + + local IS_DEPLOYED_WITH_LISTENERS=false + if [[ -n "$DOKKU_APP_LISTENERS" ]] && (is_deployed "$APP"); then + IS_DEPLOYED_WITH_LISTENERS=true + fi + + if [[ "$IS_DEPLOYED_WITH_LISTENERS" == "true" ]] || { [[ -z "$DOKKU_APP_LISTENERS" ]] && [[ -z "$PASSED_LISTEN_IP_PORT" ]] && [[ -n "$PROXY_PORT_MAP" ]]; }; then + if [[ "$IS_DEPLOYED_WITH_LISTENERS" == "true" ]]; then + if [[ "$(plugn trigger network-get-static-listeners "$APP" "web")" == "" ]]; then + local IMAGE_TAG=$(get_running_image_tag "$APP") + local IMAGE=$(get_deploying_app_image_name "$APP" "$IMAGE_TAG" 2>/dev/null) + if ! verify_image "$IMAGE" 2>/dev/null; then + dokku_log_fail "Missing image for app" + fi fi fi local NGINX_BUILD_CONFIG_TMP_WORK_DIR=$(mktemp -d "/tmp/dokku-${DOKKU_PID}-${FUNCNAME[0]}.XXXXXX") local NGINX_CONF=$(mktemp --tmpdir="${NGINX_BUILD_CONFIG_TMP_WORK_DIR}" "nginx.conf.XXXXXX") - local CUSTOM_NGINX_TEMPLATE="$NGINX_BUILD_CONFIG_TMP_WORK_DIR/$NGINX_TEMPLATE_NAME" trap "rm -rf '$NGINX_CONF' '$NGINX_BUILD_CONFIG_TMP_WORK_DIR' >/dev/null" RETURN INT TERM EXIT - get_custom_nginx_template "$APP" "$CUSTOM_NGINX_TEMPLATE" 2>/dev/null - if [[ -f "$CUSTOM_NGINX_TEMPLATE" ]]; then - dokku_log_info1 'Overriding default nginx.conf with detected nginx.conf.sigil' - local NGINX_TEMPLATE="$CUSTOM_NGINX_TEMPLATE" - local NGINX_TEMPLATE_SOURCE="app-supplied" + if [[ "$IS_DEPLOYED_WITH_LISTENERS" == "true" ]]; then + local CUSTOM_NGINX_TEMPLATE="$NGINX_BUILD_CONFIG_TMP_WORK_DIR/$NGINX_TEMPLATE_NAME" + get_custom_nginx_template "$APP" "$CUSTOM_NGINX_TEMPLATE" 2>/dev/null + if [[ -f "$CUSTOM_NGINX_TEMPLATE" ]]; then + dokku_log_info1 'Overriding default nginx.conf with detected nginx.conf.sigil' + NGINX_TEMPLATE="$CUSTOM_NGINX_TEMPLATE" + NGINX_TEMPLATE_SOURCE="app-supplied" + fi + + if grep DOKKU_APP_LISTENERS "$NGINX_TEMPLATE"; then + dokku_log_warn "Deprecated: Usage of DOKKU_APP_LISTENERS within nginx.conf.sigil templates is deprecated in favor of DOKKU_APP_WEB_LISTENERS" + fi + + if grep NGINX_SSL_PORT "$NGINX_TEMPLATE"; then + dokku_log_warn "Deprecated: Usage of NGINX_SSL_PORT within nginx.conf.sigil templates is deprecated in favor of PROXY_SSL_PORT" + fi + + if grep NGINX_PORT "$NGINX_TEMPLATE"; then + dokku_log_warn "Deprecated: Usage of NGINX_PORT within nginx.conf.sigil templates is deprecated in favor of PROXY_PORT" + fi fi - local NGINX_BIND_ADDRESS_IP4="$(fn-nginx-computed-bind-address-ipv4 "$APP")" - local NGINX_BIND_ADDRESS_IP6="$(fn-nginx-computed-bind-address-ipv6 "$APP")" - local NGINX_UNDERSCORE_IN_HEADERS="$(fn-nginx-computed-underscore-in-headers "$APP")" - local PROXY_X_FORWARDED_FOR="$(fn-nginx-computed-x-forwarded-for-value "$APP")" - local PROXY_X_FORWARDED_PORT="$(fn-nginx-computed-x-forwarded-port-value "$APP")" - local PROXY_X_FORWARDED_PROTO="$(fn-nginx-computed-x-forwarded-proto-value "$APP")" - local PROXY_X_FORWARDED_SSL="$(fn-nginx-computed-x-forwarded-ssl "$APP")" - - eval "$(config_export app "$APP")" - local SIGIL_PARAMS=(-f "$NGINX_TEMPLATE" APP="$APP" DOKKU_ROOT="$DOKKU_ROOT" - NOSSL_SERVER_NAME="$NOSSL_SERVER_NAME" - # Deprecated: Remove this after a few versions - DOKKU_APP_LISTENERS="$DOKKU_APP_LISTENERS" - DOKKU_LIB_ROOT="$DOKKU_LIB_ROOT" - PASSED_LISTEN_IP_PORT="$PASSED_LISTEN_IP_PORT" - SPDY_SUPPORTED="$SPDY_SUPPORTED" - TLS13_SUPPORTED="$TLS13_SUPPORTED" - HTTP2_SUPPORTED="$HTTP2_SUPPORTED" - NGINX_LOG_ROOT="$NGINX_LOG_ROOT" - NGINX_ACCESS_LOG_FORMAT="$NGINX_ACCESS_LOG_FORMAT" - NGINX_ACCESS_LOG_PATH="$NGINX_ACCESS_LOG_PATH" - NGINX_ERROR_LOG_PATH="$NGINX_ERROR_LOG_PATH" - NGINX_BIND_ADDRESS_IP4="$NGINX_BIND_ADDRESS_IP4" - NGINX_BIND_ADDRESS_IP6="$NGINX_BIND_ADDRESS_IP6" - HTTP2_DIRECTIVE_SUPPORTED="$HTTP2_DIRECTIVE_SUPPORTED" - HTTP2_PUSH_SUPPORTED="$HTTP2_PUSH_SUPPORTED" - GRPC_SUPPORTED="$GRPC_SUPPORTED" - DOKKU_APP_LISTEN_PORT="$DOKKU_APP_LISTEN_PORT" DOKKU_APP_LISTEN_IP="$DOKKU_APP_LISTEN_IP" - APP_SSL_PATH="$APP_SSL_PATH" SSL_INUSE="$SSL_INUSE" SSL_SERVER_NAME="$SSL_SERVER_NAME" - CLIENT_BODY_TIMEOUT="$CLIENT_BODY_TIMEOUT" - CLIENT_HEADER_TIMEOUT="$CLIENT_HEADER_TIMEOUT" - CLIENT_MAX_BODY_SIZE="$CLIENT_MAX_BODY_SIZE" - KEEPALIVE_TIMEOUT="$KEEPALIVE_TIMEOUT" - LINGERING_TIMEOUT="$LINGERING_TIMEOUT" - PROXY_CONNECT_TIMEOUT="$PROXY_CONNECT_TIMEOUT" - PROXY_READ_TIMEOUT="$PROXY_READ_TIMEOUT" - PROXY_SEND_TIMEOUT="$PROXY_SEND_TIMEOUT" - SEND_TIMEOUT="$SEND_TIMEOUT" - PROXY_BUFFER_SIZE="$PROXY_BUFFER_SIZE" - PROXY_BUFFERING="$PROXY_BUFFERING" - PROXY_BUFFERS="$PROXY_BUFFERS" - PROXY_BUSY_BUFFERS_SIZE="$PROXY_BUSY_BUFFERS_SIZE" - PROXY_KEEPALIVE="$PROXY_KEEPALIVE" - NGINX_UNDERSCORE_IN_HEADERS="$NGINX_UNDERSCORE_IN_HEADERS" - # Deprecated: Remove this after a few versions - NGINX_PORT="$PROXY_PORT" NGINX_SSL_PORT="$PROXY_SSL_PORT" - PROXY_PORT="$PROXY_PORT" PROXY_SSL_PORT="$PROXY_SSL_PORT" - PROXY_PORT_MAP="$PROXY_PORT_MAP" PROXY_UPSTREAM_PORTS="$PROXY_UPSTREAM_PORTS" - PROXY_X_FORWARDED_FOR="$PROXY_X_FORWARDED_FOR" - PROXY_X_FORWARDED_PORT="$PROXY_X_FORWARDED_PORT" - PROXY_X_FORWARDED_PROTO="$PROXY_X_FORWARDED_PROTO" - PROXY_X_FORWARDED_SSL="$PROXY_X_FORWARDED_SSL") - - while read -r line || [[ -n "$line" ]]; do - PROC_TYPE=${line%%=*} - LISTENERS="$(plugn trigger network-get-listeners "$APP" "$PROC_TYPE" | xargs)" - UPP_PROC_TYPE="${PROC_TYPE^^}" - UPP_PROC_TYPE="${UPP_PROC_TYPE//-/_}" - SIGIL_PARAMS+=("DOKKU_APP_${UPP_PROC_TYPE}_LISTENERS=$LISTENERS") - done < <(plugn trigger ps-current-scale "$APP") - - if grep DOKKU_APP_LISTENERS "$NGINX_TEMPLATE"; then - dokku_log_warn "Deprecated: Usage of DOKKU_APP_LISTENERS within nginx.conf.sigil templates is deprecated in favor of DOKKU_APP_WEB_LISTENERS" - fi - - if grep NGINX_SSL_PORT "$NGINX_TEMPLATE"; then - dokku_log_warn "Deprecated: Usage of NGINX_SSL_PORT within nginx.conf.sigil templates is deprecated in favor of PROXY_SSL_PORT" - fi - - if grep NGINX_PORT "$NGINX_TEMPLATE"; then - dokku_log_warn "Deprecated: Usage of NGINX_PORT within nginx.conf.sigil templates is deprecated in favor of PROXY_PORT" - fi - - # execute sigil template processing xargs -i echo "-----> Configuring {}...(using $NGINX_TEMPLATE_SOURCE template)" <<<"$(echo "${SSL_VHOSTS}" "${NONSSL_VHOSTS}" | tr ' ' '\n' | sort -u)" - sigil "${SIGIL_PARAMS[@]}" | cat -s >"$NGINX_CONF" + fn-nginx-vhosts-render-template "$APP" "$NGINX_TEMPLATE" "$NGINX_CONF" "$DOKKU_APP_LISTEN_PORT" "$DOKKU_APP_LISTEN_IP" dokku_log_info1 "Creating $SCHEME nginx.conf" + mkdir -p "$DOKKU_ROOT/$APP/nginx.conf.d" mv "$NGINX_CONF" "$DOKKU_ROOT/$APP/nginx.conf" - fn-nginx-vhosts-manage-hsts "$APP" "$SSL_INUSE" + if [[ "$IS_DEPLOYED_WITH_LISTENERS" == "true" ]]; then + fn-nginx-vhosts-manage-hsts "$APP" "$SSL_INUSE" + fi plugn trigger nginx-pre-reload "$APP" "$DOKKU_APP_LISTEN_PORT" "$DOKKU_APP_LISTEN_IP" diff --git a/plugins/nginx-vhosts/go.mod b/plugins/nginx-vhosts/go.mod index edc99e40d..ea93a1149 100644 --- a/plugins/nginx-vhosts/go.mod +++ b/plugins/nginx-vhosts/go.mod @@ -1,6 +1,6 @@ module github.com/dokku/dokku/plugins/nginx-vhosts -go 1.25.5 +go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 diff --git a/plugins/nginx-vhosts/go.sum b/plugins/nginx-vhosts/go.sum index c97fc9665..e973ec8a5 100644 --- a/plugins/nginx-vhosts/go.sum +++ b/plugins/nginx-vhosts/go.sum @@ -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= diff --git a/plugins/nginx-vhosts/help-functions b/plugins/nginx-vhosts/help-functions index 53219a912..9286119be 100755 --- a/plugins/nginx-vhosts/help-functions +++ b/plugins/nginx-vhosts/help-functions @@ -32,6 +32,7 @@ fn-help-content() { nginx:report [] [], Displays an nginx report for one or more apps nginx:set (), Set or clear an nginx property for an app nginx:show-config , Display app nginx config + nginx:reload, Reloads the nginx server config nginx:start, Starts the nginx server nginx:stop, Stops the nginx server nginx:validate-config [] [--clean], Validates and optionally cleans up invalid nginx configurations diff --git a/plugins/nginx-vhosts/install b/plugins/nginx-vhosts/install index 748dec90e..94bb56442 100755 --- a/plugins/nginx-vhosts/install +++ b/plugins/nginx-vhosts/install @@ -7,26 +7,8 @@ source "$PLUGIN_AVAILABLE_PATH/config/functions" source "$PLUGIN_AVAILABLE_PATH/nginx-vhosts/internal-functions" fn-nginx-vhosts-migrate-env-vars() { - # @TODO: Remove this after a few versions - for app in $(dokku_apps "false" 2>/dev/null); do - nginx_port="$(config_get "$app" DOKKU_NGINX_PORT || true)" - nginx_ssl_port="$(config_get "$app" DOKKU_NGINX_SSL_PORT || true)" - if [[ -n "$nginx_port" ]] || [[ -n "$nginx_ssl_port" ]]; then - dokku_log_info1 "Migrating DOKKU_NGINX env variables. The following variables will be migrated" - dokku_log_info2 "DOKKU_NGINX_PORT -> DOKKU_PROXY_PORT" - dokku_log_info2 "DOKKU_NGINX_SSL_PORT -> DOKKU_PROXY_SSL_PORT" - fi - if [[ -n "$nginx_port" ]]; then - dokku_log_info1 "Migrating DOKKU_NGINX_PORT to DOKKU_PROXY_PORT for $app" - DOKKU_QUIET_OUTPUT=1 config_set --no-restart "$app" DOKKU_PROXY_PORT="$nginx_port" - DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$app" DOKKU_NGINX_PORT - fi - if [[ -n "$nginx_ssl_port" ]]; then - dokku_log_info1 "Migrating DOKKU_NGINX_SSL_PORT to DOKKU_PROXY_SSL_PORT for $app" - DOKKU_QUIET_OUTPUT=1 config_set --no-restart "$app" DOKKU_PROXY_SSL_PORT="$nginx_ssl_port" - DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$app" DOKKU_NGINX_SSL_PORT - fi - done + "$PLUGIN_CORE_AVAILABLE_PATH/common/prop" migrate-config-to-property proxy proxy-port DOKKU_NGINX_PORT + "$PLUGIN_CORE_AVAILABLE_PATH/common/prop" migrate-config-to-property proxy proxy-ssl-port DOKKU_NGINX_SSL_PORT } fn-nginx-vhosts-migrate-nginx-conf-sigil() { diff --git a/plugins/nginx-vhosts/post-create b/plugins/nginx-vhosts/post-create new file mode 100755 index 000000000..04f15e915 --- /dev/null +++ b/plugins/nginx-vhosts/post-create @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +source "$PLUGIN_CORE_AVAILABLE_PATH/common/property-functions" +source "$PLUGIN_AVAILABLE_PATH/nginx-vhosts/functions" +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x + +trigger-nginx-vhosts-post-create() { + declare desc="nginx-vhosts post-create trigger" + declare trigger="post-create" + declare APP="$1" + + fn-plugin-property-setup-app "nginx" "$APP" + + if [[ "$(plugn trigger proxy-type "$APP")" != "nginx" ]]; then + return + fi + + if [[ "$(fn-plugin-property-get "nginx" "--global" "proxy-status")" == "stopped" ]]; then + return + fi + + nginx_build_config "$APP" +} + +trigger-nginx-vhosts-post-create "$@" diff --git a/plugins/nginx-vhosts/pre-disable-vhost b/plugins/nginx-vhosts/pre-disable-vhost index 4aa94cec9..977524416 100755 --- a/plugins/nginx-vhosts/pre-disable-vhost +++ b/plugins/nginx-vhosts/pre-disable-vhost @@ -2,7 +2,7 @@ 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" trigger-nginx-vhosts-pre-disable-vhost() { declare desc="unset port prior to vhosts being disabled" @@ -14,19 +14,14 @@ trigger-nginx-vhosts-pre-disable-vhost() { fi # only clear ports if they are port 80 or 443 - local PROXY_PORT=$(config_get "$APP" DOKKU_PROXY_PORT) - local PROXY_SSL_PORT=$(config_get "$APP" DOKKU_PROXY_SSL_PORT) + local PROXY_PORT=$(plugn trigger ports-get-property "$APP" "proxy-port") + local PROXY_SSL_PORT=$(plugn trigger ports-get-property "$APP" "proxy-ssl-port") - ports_to_clear=() if [[ "$PROXY_PORT" == "80" ]]; then - ports_to_clear+=("DOKKU_PROXY_PORT") + fn-plugin-property-delete "proxy" "$APP" "proxy-port" fi if [[ "$PROXY_SSL_PORT" == "443" ]]; then - ports_to_clear+=("DOKKU_PROXY_SSL_PORT") - fi - - if [[ "${#ports_to_clear[@]}" -gt 0 ]]; then - DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$APP" "${ports_to_clear[@]}" + fn-plugin-property-delete "proxy" "$APP" "proxy-ssl-port" fi # clear ports if there is a mapping starting with http:80 or https:443 diff --git a/plugins/nginx-vhosts/pre-enable-vhost b/plugins/nginx-vhosts/pre-enable-vhost index c8a7f4e56..c3abb8439 100755 --- a/plugins/nginx-vhosts/pre-enable-vhost +++ b/plugins/nginx-vhosts/pre-enable-vhost @@ -2,7 +2,7 @@ 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" trigger-nginx-vhosts-pre-enable-vhost() { declare desc="unset port prior to vhosts being enabled" @@ -13,21 +13,19 @@ trigger-nginx-vhosts-pre-enable-vhost() { return fi - # only clear ports if they are port 80 or 443 - local PROXY_PORT=$(config_get "$APP" DOKKU_PROXY_PORT) - local PROXY_SSL_PORT=$(config_get "$APP" DOKKU_PROXY_SSL_PORT) + # clear stored proxy ports so they can be recomputed for the enabled state + local PROXY_PORT=$(plugn trigger ports-get-property "$APP" "proxy-port") + local PROXY_SSL_PORT=$(plugn trigger ports-get-property "$APP" "proxy-ssl-port") - ports_to_clear=() - if [[ "$PROXY_PORT" == "80" ]]; then - ports_to_clear+=("DOKKU_PROXY_PORT") + if [[ -n "$PROXY_PORT" ]]; then + fn-plugin-property-delete "proxy" "$APP" "proxy-port" fi - if [[ "$PROXY_SSL_PORT" == "443" ]]; then - ports_to_clear+=("DOKKU_PROXY_SSL_PORT") + if [[ -n "$PROXY_SSL_PORT" ]]; then + fn-plugin-property-delete "proxy" "$APP" "proxy-ssl-port" fi - if [[ "${#ports_to_clear[@]}" -gt 0 ]]; then - DOKKU_QUIET_OUTPUT=1 config_unset --no-restart "$APP" "${ports_to_clear[@]}" - fi + # clear auto-detected port maps so they get recomputed for vhost mode + fn-plugin-property-delete "ports" "$APP" "map-detected" # clear ports if there is a mapping starting with http:80 or https:443 local PORT_MAPS=$(plugn trigger ports-get "$APP") diff --git a/plugins/nginx-vhosts/proxy-build-config b/plugins/nginx-vhosts/proxy-build-config index c9c330e6c..ea95b757f 100755 --- a/plugins/nginx-vhosts/proxy-build-config +++ b/plugins/nginx-vhosts/proxy-build-config @@ -26,7 +26,7 @@ trigger-nginx-vhosts-proxy-build-config() { rm -f "${DOKKU_LIB_ROOT}/data/nginx-vhosts/app-$APP/nginx.conf.sigil" fi else - nginx_clear_config "$APP" + nginx_build_config "$APP" fi } diff --git a/plugins/nginx-vhosts/subcommands/reload b/plugins/nginx-vhosts/subcommands/reload new file mode 100755 index 000000000..62edc03a2 --- /dev/null +++ b/plugins/nginx-vhosts/subcommands/reload @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +source "$PLUGIN_AVAILABLE_PATH/nginx-vhosts/command-functions" +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x + +cmd-nginx-reload "$@" diff --git a/plugins/nginx-vhosts/templates/default-site.conf b/plugins/nginx-vhosts/templates/default-site.conf new file mode 100644 index 000000000..92f4fdb8e --- /dev/null +++ b/plugins/nginx-vhosts/templates/default-site.conf @@ -0,0 +1,15 @@ +# Catch-all server block for requests with unknown Host headers. +# Installed on fresh apt installs of dokku; safe to edit. +# See /etc/nginx/conf.d/dokku.conf for the per-app server blocks. +server { + listen 80 default_server; + listen [::]:80 default_server; + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + + server_name _; + access_log off; + + ssl_reject_handshake on; + return 444; +} diff --git a/plugins/nginx-vhosts/templates/nginx.conf.sigil b/plugins/nginx-vhosts/templates/nginx.conf.sigil index a7ec0e382..d30b8fe9a 100644 --- a/plugins/nginx-vhosts/templates/nginx.conf.sigil +++ b/plugins/nginx-vhosts/templates/nginx.conf.sigil @@ -26,6 +26,7 @@ server { } {{ else }} location / { +{{ if $.DOKKU_APP_WEB_LISTENERS }} gzip on; gzip_min_length 1100; @@ -51,6 +52,9 @@ server { proxy_set_header X-Forwarded-Proto {{ $.PROXY_X_FORWARDED_PROTO }}; proxy_set_header X-Request-Start $msec; {{ if $.PROXY_X_FORWARDED_SSL }}proxy_set_header X-Forwarded-Ssl {{ $.PROXY_X_FORWARDED_SSL }};{{ end }} +{{ else }} + return 502; +{{ end }} } client_max_body_size {{ $.CLIENT_MAX_BODY_SIZE }}; @@ -103,6 +107,7 @@ server { send_timeout {{ $.SEND_TIMEOUT }}; location / { +{{ if $.DOKKU_APP_WEB_LISTENERS }} gzip on; gzip_min_length 1100; @@ -129,6 +134,9 @@ server { proxy_set_header X-Forwarded-Proto {{ $.PROXY_X_FORWARDED_PROTO }}; proxy_set_header X-Request-Start $msec; {{ if $.PROXY_X_FORWARDED_SSL }}proxy_set_header X-Forwarded-Ssl {{ $.PROXY_X_FORWARDED_SSL }};{{ end }} +{{ else }} + return 502; +{{ end }} } client_max_body_size {{ $.CLIENT_MAX_BODY_SIZE }}; @@ -159,6 +167,7 @@ server { include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; } {{ else if eq $scheme "grpc"}} +{{ if $.DOKKU_APP_WEB_LISTENERS }} server { {{ if eq $.HTTP2_DIRECTIVE_SUPPORTED "true" }} listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }}; @@ -179,7 +188,9 @@ server { client_max_body_size {{ $.CLIENT_MAX_BODY_SIZE }}; include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; } +{{ end }} {{ else if eq $scheme "grpcs"}} +{{ if $.DOKKU_APP_WEB_LISTENERS }} server { {{ if eq $.HTTP2_DIRECTIVE_SUPPORTED "true" }} listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }} ssl; @@ -208,6 +219,7 @@ server { } {{ end }} {{ end }} +{{ end }} {{ if $.DOKKU_APP_WEB_LISTENERS }} {{ range $upstream_port := $.PROXY_UPSTREAM_PORTS | split " " }} diff --git a/plugins/openresty-vhosts/Dockerfile b/plugins/openresty-vhosts/Dockerfile index 61e41b33e..aba3f9815 100644 --- a/plugins/openresty-vhosts/Dockerfile +++ b/plugins/openresty-vhosts/Dockerfile @@ -1 +1 @@ -FROM dokku/openresty-docker-proxy:0.9.3 +FROM dokku/openresty-docker-proxy:0.10.0 diff --git a/plugins/openresty-vhosts/command-functions b/plugins/openresty-vhosts/command-functions index 081362b91..1ddbe475c 100755 --- a/plugins/openresty-vhosts/command-functions +++ b/plugins/openresty-vhosts/command-functions @@ -33,9 +33,13 @@ cmd-openresty-report() { declare desc="displays a openresty report for one or more apps" declare cmd="openresty: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,59 +48,83 @@ cmd-openresty-report() { INFO_FLAG="true" fi - if [[ -z "$APP" ]]; then + if [[ "$APP" == "--global" ]]; then + cmd-openresty-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" + elif [[ -z "$APP" ]]; then for app in $(dokku_apps); do - cmd-openresty-report-single "$app" "$INFO_FLAG" | tee || true + cmd-openresty-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true done else - cmd-openresty-report-single "$APP" "$INFO_FLAG" + cmd-openresty-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" fi } cmd-openresty-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=( - "--openresty-access-log-format: $(fn-openresty-access-log-format "$APP")" - "--openresty-access-log-path: $(fn-openresty-access-log-path "$APP")" - "--openresty-allowed-letsencrypt-domains-func-base64: $(fn-openresty-allowed-letsencrypt-domains-func-base64)" - "--openresty-bind-address-ipv4: $(fn-openresty-bind-address-ipv4 "$APP")" - "--openresty-bind-address-ipv6: $(fn-openresty-bind-address-ipv6 "$APP")" - "--openresty-client-body-timeout: $(fn-openresty-client-body-timeout "$APP")" - "--openresty-client-header-timeout: $(fn-openresty-client-header-timeout "$APP")" - "--openresty-client-max-body-size: $(fn-openresty-client-max-body-size "$APP")" - "--openresty-error-log-path: $(fn-openresty-error-log-path "$APP")" - "--openresty-global-hsts: $(fn-plugin-property-get-default "openresty" "--global" "hsts" "true")" - "--openresty-computed-hsts: $(fn-openresty-hsts-is-enabled "$APP")" - "--openresty-hsts: $(fn-plugin-property-get-default "openresty" "$APP" "hsts" "")" - "--openresty-hsts-include-subdomains: $(fn-openresty-hsts-include-subdomains "$APP")" - "--openresty-hsts-max-age: $(fn-openresty-hsts-max-age "$APP")" - "--openresty-hsts-preload: $(fn-openresty-hsts-preload "$APP")" - "--openresty-image: $(fn-openresty-image)" - "--openresty-keepalive-timeout: $(fn-openresty-keepalive-timeout "$APP")" - "--openresty-letsencrypt-email: $(fn-openresty-letsencrypt-email)" - "--openresty-letsencrypt-server: $(fn-openresty-letsencrypt-server)" - "--openresty-lingering-timeout: $(fn-openresty-lingering-timeout "$APP")" - "--openresty-proxy-buffer-size: $(fn-openresty-proxy-buffer-size "$APP")" - "--openresty-proxy-buffering: $(fn-openresty-proxy-buffering "$APP")" - "--openresty-proxy-buffers: $(fn-openresty-proxy-buffers "$APP")" - "--openresty-proxy-busy-buffers-size: $(fn-openresty-proxy-busy-buffers-size "$APP")" - "--openresty-proxy-connect-timeout: $(fn-openresty-proxy-connect-timeout "$APP")" - "--openresty-proxy-read-timeout: $(fn-openresty-proxy-read-timeout "$APP")" - "--openresty-proxy-send-timeout: $(fn-openresty-proxy-send-timeout "$APP")" - "--openresty-send-timeout: $(fn-openresty-send-timeout "$APP")" - "--openresty-underscore-in-headers: $(fn-openresty-underscore-in-headers "$APP")" - "--openresty-x-forwarded-for-value: $(fn-openresty-x-forwarded-for-value "$APP")" - "--openresty-x-forwarded-port-value: $(fn-openresty-x-forwarded-port-value "$APP")" - "--openresty-x-forwarded-proto-value: $(fn-openresty-x-forwarded-proto-value "$APP")" - "--openresty-x-forwarded-ssl: $(fn-openresty-x-forwarded-ssl "$APP")" - ) + local flag_map=() + if [[ "$APP" == "--global" ]]; then + flag_map=( + "--openresty-allowed-letsencrypt-domains-func-base64: $(fn-openresty-allowed-letsencrypt-domains-func-base64)" + "--openresty-global-hsts: $(fn-plugin-property-get-default "openresty" "--global" "hsts" "true")" + "--openresty-image: $(fn-openresty-image)" + "--openresty-letsencrypt-email: $(fn-openresty-letsencrypt-email)" + "--openresty-letsencrypt-server: $(fn-openresty-letsencrypt-server)" + ) + else + verify_app_name "$APP" + flag_map=( + "--openresty-access-log-format: $(fn-openresty-access-log-format "$APP")" + "--openresty-access-log-path: $(fn-openresty-access-log-path "$APP")" + "--openresty-allowed-letsencrypt-domains-func-base64: $(fn-openresty-allowed-letsencrypt-domains-func-base64)" + "--openresty-bind-address-ipv4: $(fn-openresty-bind-address-ipv4 "$APP")" + "--openresty-bind-address-ipv6: $(fn-openresty-bind-address-ipv6 "$APP")" + "--openresty-client-body-timeout: $(fn-openresty-client-body-timeout "$APP")" + "--openresty-client-header-timeout: $(fn-openresty-client-header-timeout "$APP")" + "--openresty-client-max-body-size: $(fn-openresty-client-max-body-size "$APP")" + "--openresty-error-log-path: $(fn-openresty-error-log-path "$APP")" + "--openresty-global-hsts: $(fn-plugin-property-get-default "openresty" "--global" "hsts" "true")" + "--openresty-computed-hsts: $(fn-openresty-hsts-is-enabled "$APP")" + "--openresty-hsts: $(fn-plugin-property-get-default "openresty" "$APP" "hsts" "")" + "--openresty-hsts-include-subdomains: $(fn-openresty-hsts-include-subdomains "$APP")" + "--openresty-hsts-max-age: $(fn-openresty-hsts-max-age "$APP")" + "--openresty-hsts-preload: $(fn-openresty-hsts-preload "$APP")" + "--openresty-image: $(fn-openresty-image)" + "--openresty-keepalive-timeout: $(fn-openresty-keepalive-timeout "$APP")" + "--openresty-letsencrypt-email: $(fn-openresty-letsencrypt-email)" + "--openresty-letsencrypt-server: $(fn-openresty-letsencrypt-server)" + "--openresty-lingering-timeout: $(fn-openresty-lingering-timeout "$APP")" + "--openresty-proxy-buffer-size: $(fn-openresty-proxy-buffer-size "$APP")" + "--openresty-proxy-buffering: $(fn-openresty-proxy-buffering "$APP")" + "--openresty-proxy-buffers: $(fn-openresty-proxy-buffers "$APP")" + "--openresty-proxy-busy-buffers-size: $(fn-openresty-proxy-busy-buffers-size "$APP")" + "--openresty-proxy-connect-timeout: $(fn-openresty-proxy-connect-timeout "$APP")" + "--openresty-proxy-read-timeout: $(fn-openresty-proxy-read-timeout "$APP")" + "--openresty-proxy-send-timeout: $(fn-openresty-proxy-send-timeout "$APP")" + "--openresty-send-timeout: $(fn-openresty-send-timeout "$APP")" + "--openresty-underscore-in-headers: $(fn-openresty-underscore-in-headers "$APP")" + "--openresty-x-forwarded-for-value: $(fn-openresty-x-forwarded-for-value "$APP")" + "--openresty-x-forwarded-port-value: $(fn-openresty-x-forwarded-port-value "$APP")" + "--openresty-x-forwarded-proto-value: $(fn-openresty-x-forwarded-proto-value "$APP")" + "--openresty-x-forwarded-ssl: $(fn-openresty-x-forwarded-ssl "$APP")" + ) + fi + + fn-report-validate-format "$FORMAT" "$INFO_FLAG" + + if [[ "$FORMAT" == "json" ]]; then + fn-report-emit-json flag_map "openresty" + return + fi if [[ -z "$INFO_FLAG" ]]; then - dokku_log_info2_quiet "${APP} openresty information" + if [[ "$APP" == "--global" ]]; then + dokku_log_info2_quiet "global openresty information" + else + dokku_log_info2_quiet "${APP} openresty information" + fi for flag in "${flag_map[@]}"; do key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')" dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")" diff --git a/plugins/ports/functions.go b/plugins/ports/functions.go index 76aeaed3d..afeaf7120 100644 --- a/plugins/ports/functions.go +++ b/plugins/ports/functions.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/dokku/dokku/plugins/common" - "github.com/dokku/dokku/plugins/config" "github.com/ryanuber/columnize" ) @@ -162,11 +161,8 @@ func getDetectedPortMaps(appName string) []PortMap { // getGlobalProxyPort gets the global proxy port func getGlobalProxyPort() int { port := 0 - results, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "config-get-global", - Args: []string{"DOKKU_PROXY_PORT"}, - }) - if intVar, err := strconv.Atoi(results.StdoutContents()); err == nil { + value := common.PropertyGet("proxy", "--global", "proxy-port") + if intVar, err := strconv.Atoi(value); err == nil { port = intVar } @@ -176,11 +172,8 @@ func getGlobalProxyPort() int { // getGlobalProxySSLPort gets the global proxy ssl port func getGlobalProxySSLPort() int { port := 0 - results, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "config-get-global", - Args: []string{"DOKKU_PROXY_SSL_PORT"}, - }) - if intVar, err := strconv.Atoi(results.StdoutContents()); err == nil { + value := common.PropertyGet("proxy", "--global", "proxy-ssl-port") + if intVar, err := strconv.Atoi(value); err == nil { port = intVar } @@ -201,11 +194,8 @@ func getPortMaps(appName string) []PortMap { // getProxyPort gets the proxy port for an app func getProxyPort(appName string) int { port := 0 - results, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "config-get", - Args: []string{appName, "DOKKU_PROXY_PORT"}, - }) - if intVar, err := strconv.Atoi(results.StdoutContents()); err == nil { + value := common.PropertyGet("proxy", appName, "proxy-port") + if intVar, err := strconv.Atoi(value); err == nil { port = intVar } @@ -215,11 +205,8 @@ func getProxyPort(appName string) int { // getProxySSLPort gets the proxy ssl port for an app func getProxySSLPort(appName string) int { port := 0 - results, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "config-get", - Args: []string{appName, "DOKKU_PROXY_SSL_PORT"}, - }) - if intVar, err := strconv.Atoi(results.StdoutContents()); err == nil { + value := common.PropertyGet("proxy", appName, "proxy-ssl-port") + if intVar, err := strconv.Atoi(value); err == nil { port = intVar } @@ -445,22 +432,22 @@ func setPortMaps(appName string, portMaps []PortMap) error { // setProxyPort sets the proxy port for an app func setProxyPort(appName string, port int) error { - return common.EnvWrap(func() error { - entries := map[string]string{ - "DOKKU_PROXY_PORT": fmt.Sprint(port), - } - return config.SetMany(appName, entries, false, false) - }, map[string]string{"DOKKU_QUIET_OUTPUT": "1"}) + return common.PropertyWrite("proxy", appName, "proxy-port", fmt.Sprint(port)) } // setProxySSLPort sets the proxy ssl port for an app func setProxySSLPort(appName string, port int) error { - return common.EnvWrap(func() error { - entries := map[string]string{ - "DOKKU_PROXY_SSL_PORT": fmt.Sprint(port), - } - return config.SetMany(appName, entries, false, false) - }, map[string]string{"DOKKU_QUIET_OUTPUT": "1"}) + return common.PropertyWrite("proxy", appName, "proxy-ssl-port", fmt.Sprint(port)) +} + +// transformPortMap normalizes a port map string for migration to list properties +func transformPortMap(value string) string { + portMaps, _ := parsePortMapString(value) + var parts []string + for _, portMap := range portMaps { + parts = append(parts, portMap.String()) + } + return strings.Join(parts, " ") } // uniquePortMaps returns a unique set of port maps diff --git a/plugins/ports/go.mod b/plugins/ports/go.mod index 2b5e29d50..ecbef4a56 100644 --- a/plugins/ports/go.mod +++ b/plugins/ports/go.mod @@ -1,10 +1,9 @@ module github.com/dokku/dokku/plugins/ports -go 1.25.5 +go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 - github.com/dokku/dokku/plugins/config v0.0.0-00010101000000-000000000000 github.com/ryanuber/columnize v2.1.2+incompatible github.com/spf13/pflag v1.0.10 ) @@ -14,7 +13,6 @@ require ( 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/joho/godotenv v1.5.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 diff --git a/plugins/ports/go.sum b/plugins/ports/go.sum index cf7f74cf0..e973ec8a5 100644 --- a/plugins/ports/go.sum +++ b/plugins/ports/go.sum @@ -10,8 +10,6 @@ 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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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= @@ -20,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= diff --git a/plugins/ports/proxy.go b/plugins/ports/proxy.go index 8daf37c43..c8855b6e4 100644 --- a/plugins/ports/proxy.go +++ b/plugins/ports/proxy.go @@ -4,6 +4,7 @@ import ( "fmt" ) + // PortMap is a struct that contains a scheme:host-port:container-port mapping type PortMap struct { // ContainerPort is the port on the container diff --git a/plugins/ports/report.go b/plugins/ports/report.go index 8031478b7..357cda900 100644 --- a/plugins/ports/report.go +++ b/plugins/ports/report.go @@ -8,13 +8,20 @@ import ( // ReportSingleApp is an internal function that displays the ports 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{ - "--ports-map": reportPortMap, - "--ports-map-detected": reportPortMapDetected, + var flags map[string]common.ReportFunc + if appName == "--global" { + flags = map[string]common.ReportFunc{} + } else { + flags = map[string]common.ReportFunc{ + "--ports-map": reportPortMap, + "--ports-map-detected": reportPortMapDetected, + } } flagKeys := []string{} diff --git a/plugins/ports/src/subcommands/subcommands.go b/plugins/ports/src/subcommands/subcommands.go index 14f97ed94..35d0b5e46 100644 --- a/plugins/ports/src/subcommands/subcommands.go +++ b/plugins/ports/src/subcommands/subcommands.go @@ -49,11 +49,14 @@ func main() { case "report": args := flag.NewFlagSet("ports:report", flag.ExitOnError) format := args.String("format", "stdout", "format: [ stdout | json ]") - osArgs, infoFlag, flagErr := common.ParseReportArgs("ports", os.Args[2:]) + reportArgs, flagErr := common.ParseReportArgs("ports", os.Args[2:]) if flagErr == nil { - args.Parse(osArgs) + args.Parse(reportArgs.OSArgs) appName := args.Arg(0) - err = ports.CommandReport(appName, *format, infoFlag) + if reportArgs.IsGlobal { + appName = "--global" + } + err = ports.CommandReport(appName, *format, reportArgs.InfoFlag) } default: err = fmt.Errorf("Invalid plugin subcommand call: %s", subcommand) diff --git a/plugins/ports/triggers.go b/plugins/ports/triggers.go index c8c4288a0..8d6ce6cd7 100644 --- a/plugins/ports/triggers.go +++ b/plugins/ports/triggers.go @@ -2,12 +2,10 @@ package ports import ( "encoding/json" - "errors" "fmt" "sort" "github.com/dokku/dokku/plugins/common" - "github.com/dokku/dokku/plugins/config" ) // TriggerInstall migrates the ports config to properties @@ -16,44 +14,15 @@ func TriggerInstall() error { return fmt.Errorf("Unable to install the ports plugin: %s", err.Error()) } - apps, err := common.UnfilteredDokkuApps() - if err != nil && !errors.Is(err, common.NoAppsExist) { - return nil - } - - for _, appName := range apps { - if common.PropertyExists("ports", appName, "map") { - continue - } - - results, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "config-get", - Args: []string{appName, "DOKKU_PROXY_PORT_MAP"}, - }) - portMapString := results.StdoutContents() - if portMapString == "" { - continue - } - - common.LogVerboseQuiet(fmt.Sprintf("Setting %s ports property 'map' to %v", appName, portMapString)) - portMaps, _ := parsePortMapString(portMapString) - - propertyValue := []string{} - for _, portMap := range portMaps { - propertyValue = append(propertyValue, portMap.String()) - } - - if err := common.PropertyListWrite("ports", appName, "map", propertyValue); err != nil { - return err - } - - _, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "config-unset", - Args: []string{appName, "DOKKU_PROXY_PORT_MAP"}, - }) - if err != nil { - return err - } + if err := common.MigrateConfigToProperties("ports", []common.MigrateConfigEntry{ + { + ConfigVar: "DOKKU_PROXY_PORT_MAP", + Property: "map", + ListProperty: true, + Transform: transformPortMap, + }, + }); err != nil { + return err } return nil @@ -185,34 +154,26 @@ func TriggerPostAppRenameSetup(oldAppName string, newAppName string) error { return nil } -// TriggerPostCertsRemove unsets port config vars after SSL cert is added +// TriggerPostCertsRemove unsets port properties after SSL cert is removed func TriggerPostCertsRemove(appName string) error { - keys := []string{"DOKKU_PROXY_SSL_PORT"} - if err := config.UnsetMany(appName, keys, false); err != nil { + if err := common.PropertyDelete("proxy", appName, "proxy-ssl-port"); err != nil { return err } return removePortMaps(appName, filterAppPortMaps(appName, "https", 443)) } -// TriggerPostCertsUpdate sets port config vars after SSL cert is added +// TriggerPostCertsUpdate sets port properties after SSL cert is added func TriggerPostCertsUpdate(appName string) error { - port := config.GetWithDefault(appName, "DOKKU_PROXY_PORT", "") - sslPort := config.GetWithDefault(appName, "DOKKU_PROXY_SSL_PORT", "") + port := common.PropertyGet("proxy", appName, "proxy-port") + sslPort := common.PropertyGet("proxy", appName, "proxy-ssl-port") portMaps := getPortMaps(appName) - toUnset := []string{} if port == "80" { - toUnset = append(toUnset, "DOKKU_PROXY_PORT") + common.PropertyDelete("proxy", appName, "proxy-port") } if sslPort == "443" { - toUnset = append(toUnset, "DOKKU_PROXY_SSL_PORT") - } - - if len(toUnset) > 0 { - if err := config.UnsetMany(appName, toUnset, false); err != nil { - return err - } + common.PropertyDelete("proxy", appName, "proxy-ssl-port") } var http80Ports []PortMap diff --git a/plugins/proxy/Makefile b/plugins/proxy/Makefile index 6b127937f..3ddf822fb 100644 --- a/plugins/proxy/Makefile +++ b/plugins/proxy/Makefile @@ -1,5 +1,5 @@ SUBCOMMANDS = subcommands/build-config subcommands/clear-config subcommands/disable subcommands/enable subcommands/ports subcommands/ports-add subcommands/ports-clear subcommands/ports-remove subcommands/ports-set subcommands/report subcommands/set -TRIGGERS = triggers/proxy-is-enabled triggers/proxy-type triggers/report +TRIGGERS = triggers/install triggers/proxy-is-enabled triggers/proxy-type triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-delete triggers/report BUILD = commands subcommands triggers PLUGIN_NAME = proxy diff --git a/plugins/proxy/functions.go b/plugins/proxy/functions.go index d7638f78c..11b4823b5 100644 --- a/plugins/proxy/functions.go +++ b/plugins/proxy/functions.go @@ -1,11 +1,11 @@ package proxy import ( - "github.com/dokku/dokku/plugins/config" + "github.com/dokku/dokku/plugins/common" ) func getAppProxyType(appName string) string { - return config.GetWithDefault(appName, "DOKKU_APP_PROXY_TYPE", "") + return common.PropertyGet("proxy", appName, "type") } func getComputedProxyType(appName string) string { @@ -18,5 +18,5 @@ func getComputedProxyType(appName string) string { } func getGlobalProxyType() string { - return config.GetWithDefault("--global", "DOKKU_PROXY_TYPE", "nginx") + return common.PropertyGetDefault("proxy", "--global", "type", "nginx") } diff --git a/plugins/proxy/go.mod b/plugins/proxy/go.mod index 660a3c4da..b982b60ed 100644 --- a/plugins/proxy/go.mod +++ b/plugins/proxy/go.mod @@ -1,10 +1,9 @@ module github.com/dokku/dokku/plugins/proxy -go 1.25.5 +go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 - github.com/dokku/dokku/plugins/config v0.0.0-00010101000000-000000000000 github.com/spf13/pflag v1.0.10 ) @@ -13,7 +12,6 @@ require ( 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/joho/godotenv v1.5.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 diff --git a/plugins/proxy/go.sum b/plugins/proxy/go.sum index cf7f74cf0..e973ec8a5 100644 --- a/plugins/proxy/go.sum +++ b/plugins/proxy/go.sum @@ -10,8 +10,6 @@ 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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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= @@ -20,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= diff --git a/plugins/proxy/proxy.go b/plugins/proxy/proxy.go index b674f72b0..aec2c924d 100644 --- a/plugins/proxy/proxy.go +++ b/plugins/proxy/proxy.go @@ -2,13 +2,29 @@ package proxy import ( "github.com/dokku/dokku/plugins/common" - "github.com/dokku/dokku/plugins/config" ) // RunInSerial is the default value for whether to run a command in parallel or not // and defaults to -1 (false) const RunInSerial = 0 +var ( + // DefaultProperties is a map of all valid proxy properties with corresponding default property values + DefaultProperties = map[string]string{ + "disabled": "false", + "proxy-port": "", + "proxy-ssl-port": "", + "type": "", + } + + // GlobalProperties is a map of all valid global proxy properties + GlobalProperties = map[string]bool{ + "proxy-port": true, + "proxy-ssl-port": true, + "type": true, + } +) + // BuildConfig rebuilds the proxy config for the specified app func BuildConfig(appName string) error { _, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ @@ -37,11 +53,7 @@ func Disable(appName string) error { } common.LogInfo1("Disabling proxy for app") - entries := map[string]string{ - "DOKKU_DISABLE_PROXY": "1", - } - - if err := config.SetMany(appName, entries, false, false); err != nil { + if err := common.PropertyWrite("proxy", appName, "disabled", "true"); err != nil { return err } @@ -61,10 +73,10 @@ func Enable(appName string) error { } common.LogInfo1("Enabling proxy for app") - keys := []string{"DOKKU_DISABLE_PROXY"} - if err := config.UnsetMany(appName, keys, false); err != nil { + if err := common.PropertyDelete("proxy", appName, "disabled"); err != nil { return err } + _, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ Trigger: "proxy-enable", Args: []string{appName}, @@ -75,10 +87,5 @@ func Enable(appName string) error { // IsAppProxyEnabled returns true if proxy is enabled; otherwise return false func IsAppProxyEnabled(appName string) bool { - proxyEnabled := true - disableProxy := config.GetWithDefault(appName, "DOKKU_DISABLE_PROXY", "") - if disableProxy != "" { - proxyEnabled = false - } - return proxyEnabled + return common.PropertyGetDefault("proxy", appName, "disabled", "false") != "true" } diff --git a/plugins/proxy/report.go b/plugins/proxy/report.go index 00f126cd1..305e808e0 100644 --- a/plugins/proxy/report.go +++ b/plugins/proxy/report.go @@ -6,15 +6,24 @@ import ( // ReportSingleApp is an internal function that displays the proxy 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{ - "--proxy-enabled": reportEnabled, - "--proxy-computed-type": reportComputedType, - "--proxy-global-type": reportGlobalType, - "--proxy-type": reportType, + var flags map[string]common.ReportFunc + if appName == "--global" { + flags = map[string]common.ReportFunc{ + "--proxy-global-type": reportGlobalType, + } + } else { + flags = map[string]common.ReportFunc{ + "--proxy-enabled": reportEnabled, + "--proxy-computed-type": reportComputedType, + "--proxy-global-type": reportGlobalType, + "--proxy-type": reportType, + } } flagKeys := []string{} diff --git a/plugins/proxy/src/subcommands/subcommands.go b/plugins/proxy/src/subcommands/subcommands.go index ca06d2660..07b1a5655 100644 --- a/plugins/proxy/src/subcommands/subcommands.go +++ b/plugins/proxy/src/subcommands/subcommands.go @@ -48,23 +48,28 @@ func main() { case "report": args := flag.NewFlagSet("proxy:report", flag.ExitOnError) format := args.String("format", "stdout", "format: [ stdout | json ]") - osArgs, infoFlag, flagErr := common.ParseReportArgs("proxy", os.Args[2:]) + reportArgs, flagErr := common.ParseReportArgs("proxy", os.Args[2:]) if flagErr == nil { - args.Parse(osArgs) + args.Parse(reportArgs.OSArgs) appName := args.Arg(0) - err = proxy.CommandReport(appName, *format, infoFlag) + if reportArgs.IsGlobal { + appName = "--global" + } + err = proxy.CommandReport(appName, *format, reportArgs.InfoFlag) } case "set": args := flag.NewFlagSet("proxy:set", flag.ExitOnError) global := args.Bool("global", false, "--global: set a global property") args.Parse(os.Args[2:]) appName := args.Arg(0) - proxyType := args.Arg(1) + property := args.Arg(1) + value := args.Arg(2) if *global { appName = "--global" - proxyType = args.Arg(0) + property = args.Arg(0) + value = args.Arg(1) } - err = proxy.CommandSet(appName, proxyType) + err = proxy.CommandSet(appName, property, value) default: err = fmt.Errorf("Invalid plugin subcommand call: %s", subcommand) } diff --git a/plugins/proxy/src/triggers/triggers.go b/plugins/proxy/src/triggers/triggers.go index 713e1976d..e8acb3b6c 100644 --- a/plugins/proxy/src/triggers/triggers.go +++ b/plugins/proxy/src/triggers/triggers.go @@ -18,12 +18,25 @@ func main() { var err error switch trigger { + case "install": + err = proxy.TriggerInstall() case "proxy-is-enabled": appName := flag.Arg(0) err = proxy.TriggerProxyIsEnabled(appName) case "proxy-type": appName := flag.Arg(0) err = proxy.TriggerProxyType(appName) + case "post-app-clone-setup": + oldAppName := flag.Arg(0) + newAppName := flag.Arg(1) + err = proxy.TriggerPostAppCloneSetup(oldAppName, newAppName) + case "post-app-rename-setup": + oldAppName := flag.Arg(0) + newAppName := flag.Arg(1) + err = proxy.TriggerPostAppRenameSetup(oldAppName, newAppName) + case "post-delete": + appName := flag.Arg(0) + err = proxy.TriggerPostDelete(appName) case "report": appName := flag.Arg(0) err = proxy.ReportSingleApp(appName, "", "") diff --git a/plugins/proxy/subcommands.go b/plugins/proxy/subcommands.go index ccf7fa9f5..997125a38 100644 --- a/plugins/proxy/subcommands.go +++ b/plugins/proxy/subcommands.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/dokku/dokku/plugins/common" - "github.com/dokku/dokku/plugins/config" ) // CommandBuildConfig rebuilds config for a given app @@ -82,29 +81,19 @@ func CommandReport(appName string, format string, infoFlag string) error { return ReportSingleApp(appName, format, infoFlag) } -// CommandSet sets a proxy for an app -func CommandSet(appName string, proxyType string) error { - if appName != "--global" { - if err := common.VerifyAppName(appName); err != nil { - return err +// CommandSet set or clear a proxy property for an app +func CommandSet(appName string, property string, value string) error { + // backward compatibility: `proxy:set ` is treated as + // `proxy:set type ` + if _, ok := DefaultProperties[property]; !ok && value == "" { + if strings.Contains(property, ":") { + common.LogWarn("Detected potential port mapping instead of proxy type") + return errors.New("Consider using ports:set command or specifying a valid proxy") } + value = property + property = "type" } - if len(proxyType) < 2 { - return errors.New("Please specify a proxy type") - } - - if strings.Contains(proxyType, ":") { - common.LogWarn("Detected potential port mapping instead of proxy type") - return errors.New("Consider using ports:set command or specifying a valid proxy") - } - - key := "DOKKU_APP_PROXY_TYPE" - if appName == "--global" { - key = "DOKKU_PROXY_TYPE" - } - entries := map[string]string{ - key: proxyType, - } - return config.SetMany(appName, entries, false, false) + common.CommandPropertySet("proxy", appName, property, value, DefaultProperties, GlobalProperties) + return nil } diff --git a/plugins/proxy/triggers.go b/plugins/proxy/triggers.go index 0dff90629..935e63d77 100644 --- a/plugins/proxy/triggers.go +++ b/plugins/proxy/triggers.go @@ -2,8 +2,49 @@ package proxy import ( "fmt" + + "github.com/dokku/dokku/plugins/common" ) +// TriggerInstall runs the install step for the proxy plugin +func TriggerInstall() error { + if err := common.PropertySetup("proxy"); err != nil { + return fmt.Errorf("Unable to install the proxy plugin: %s", err.Error()) + } + + if err := common.MigrateConfigToProperties("proxy", []common.MigrateConfigEntry{ + { + ConfigVar: "DOKKU_APP_PROXY_TYPE", + GlobalConfigVar: "DOKKU_PROXY_TYPE", + Property: "type", + }, + { + ConfigVar: "DOKKU_DISABLE_PROXY", + Property: "disabled", + Transform: func(value string) string { + if value != "" { + return "true" + } + return value + }, + }, + { + ConfigVar: "DOKKU_PROXY_PORT", + GlobalConfigVar: "DOKKU_PROXY_PORT", + Property: "proxy-port", + }, + { + ConfigVar: "DOKKU_PROXY_SSL_PORT", + GlobalConfigVar: "DOKKU_PROXY_SSL_PORT", + Property: "proxy-ssl-port", + }, + }); err != nil { + return err + } + + return nil +} + // TriggerProxyIsEnabled prints true or false depending on whether the proxy is enabled func TriggerProxyIsEnabled(appName string) error { if IsAppProxyEnabled(appName) { @@ -22,3 +63,22 @@ func TriggerProxyType(appName string) error { return nil } + +// TriggerPostAppCloneSetup creates new proxy files +func TriggerPostAppCloneSetup(oldAppName string, newAppName string) error { + return common.PropertyClone("proxy", oldAppName, newAppName) +} + +// TriggerPostAppRenameSetup renames proxy files +func TriggerPostAppRenameSetup(oldAppName string, newAppName string) error { + if err := common.PropertyClone("proxy", oldAppName, newAppName); err != nil { + return err + } + + return common.PropertyDestroy("proxy", oldAppName) +} + +// TriggerPostDelete destroys the proxy property for a given app container +func TriggerPostDelete(appName string) error { + return common.PropertyDestroy("proxy", appName) +} diff --git a/plugins/ps/go.mod b/plugins/ps/go.mod index 10721049b..29490ea46 100644 --- a/plugins/ps/go.mod +++ b/plugins/ps/go.mod @@ -1,10 +1,9 @@ module github.com/dokku/dokku/plugins/ps -go 1.25.5 +go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 - github.com/dokku/dokku/plugins/config v0.0.0-00010101000000-000000000000 github.com/dokku/dokku/plugins/docker-options v0.0.0-00010101000000-000000000000 github.com/gofrs/flock v0.13.0 github.com/spf13/pflag v1.0.10 @@ -15,7 +14,6 @@ require ( 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/joho/godotenv v1.5.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 @@ -28,6 +26,7 @@ require ( golang.org/x/crypto v0.50.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect + mvdan.cc/sh/v3 v3.13.1 // indirect ) replace github.com/dokku/dokku/plugins/common => ../common diff --git a/plugins/ps/go.sum b/plugins/ps/go.sum index d16651988..f05183443 100644 --- a/plugins/ps/go.sum +++ b/plugins/ps/go.sum @@ -4,6 +4,8 @@ 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-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -12,18 +14,20 @@ 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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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= @@ -34,6 +38,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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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= @@ -57,3 +63,5 @@ 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= +mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= +mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/plugins/ps/ps.go b/plugins/ps/ps.go index 307306f24..1386c47aa 100644 --- a/plugins/ps/ps.go +++ b/plugins/ps/ps.go @@ -13,14 +13,19 @@ const RunInSerial = 0 var ( // DefaultProperties is a map of all valid ps properties with corresponding default property values DefaultProperties = map[string]string{ - "restart-policy": "on-failure:10", + "dockerfile-start-cmd": "", "procfile-path": "", + "restart-policy": "on-failure:10", + "restore": "true", + "skip-deploy": "", + "start-cmd": "", "stop-timeout-seconds": "30", } // GlobalProperties is a map of all valid global ps properties GlobalProperties = map[string]bool{ "procfile-path": true, + "skip-deploy": true, "stop-timeout-seconds": true, } ) @@ -165,13 +170,9 @@ func Restore(appName string) error { return nil } - results, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "config-get", - Args: []string{appName, "DOKKU_APP_RESTORE"}, - }) - restore := results.StdoutContents() - if restore == "0" { - common.LogWarn(fmt.Sprintf("Skipping ps:restore for %s as DOKKU_APP_RESTORE=%s", appName, restore)) + restore := common.PropertyGetDefault("ps", appName, "restore", "true") + if restore == "false" { + common.LogWarn(fmt.Sprintf("Skipping ps:restore for %s as restore property is false", appName)) return nil } diff --git a/plugins/ps/report.go b/plugins/ps/report.go index 74c4c17a9..1ccfd3e3f 100644 --- a/plugins/ps/report.go +++ b/plugins/ps/report.go @@ -10,28 +10,38 @@ import ( // ReportSingleApp is an internal function that displays the ps 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{ - "--deployed": reportDeployed, - "--processes": reportProcesses, - "--ps-can-scale": reportCanScale, - "--ps-restart-policy": reportRestartPolicy, - "--ps-computed-procfile-path": reportComputedProcfilePath, - "--ps-global-procfile-path": reportGlobalProcfilePath, - "--ps-procfile-path": reportProcfilePath, - "--restore": reportRestore, - "--running": reportRunningState, - "--global-stop-timeout-seconds": reportGlobalStopTimeoutSeconds, - "--computed-stop-timeout-seconds": reportComputedStopTimeoutSeconds, - "--stop-timeout-seconds": reportStopTimeoutSeconds, - } + var flags map[string]common.ReportFunc + if appName == "--global" { + flags = map[string]common.ReportFunc{ + "--ps-global-procfile-path": reportGlobalProcfilePath, + "--global-stop-timeout-seconds": reportGlobalStopTimeoutSeconds, + } + } else { + flags = map[string]common.ReportFunc{ + "--deployed": reportDeployed, + "--processes": reportProcesses, + "--ps-can-scale": reportCanScale, + "--ps-restart-policy": reportRestartPolicy, + "--ps-computed-procfile-path": reportComputedProcfilePath, + "--ps-global-procfile-path": reportGlobalProcfilePath, + "--ps-procfile-path": reportProcfilePath, + "--restore": reportRestore, + "--running": reportRunningState, + "--global-stop-timeout-seconds": reportGlobalStopTimeoutSeconds, + "--computed-stop-timeout-seconds": reportComputedStopTimeoutSeconds, + "--stop-timeout-seconds": reportStopTimeoutSeconds, + } - extraFlags := addStatusFlags(appName, infoFlag) - for flag, fn := range extraFlags { - flags[flag] = fn + extraFlags := addStatusFlags(appName, infoFlag) + for flag, fn := range extraFlags { + flags[flag] = fn + } } flagKeys := []string{} @@ -132,18 +142,7 @@ func reportRestartPolicy(appName string) string { } func reportRestore(appName string) string { - results, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "config-get", - Args: []string{appName, "DOKKU_APP_RESTORE"}, - }) - restore := results.StdoutContents() - if restore == "0" { - restore = "false" - } else { - restore = "true" - } - - return restore + return common.PropertyGetDefault("ps", appName, "restore", "true") } func reportRunningState(appName string) string { diff --git a/plugins/ps/src/subcommands/subcommands.go b/plugins/ps/src/subcommands/subcommands.go index 24172b490..440640214 100644 --- a/plugins/ps/src/subcommands/subcommands.go +++ b/plugins/ps/src/subcommands/subcommands.go @@ -33,11 +33,14 @@ func main() { case "report": args := flag.NewFlagSet("ps:report", flag.ExitOnError) format := args.String("format", "stdout", "format: [ stdout | json ]") - osArgs, infoFlag, flagErr := common.ParseReportArgs("ps", os.Args[2:]) + reportArgs, flagErr := common.ParseReportArgs("ps", os.Args[2:]) if flagErr == nil { - args.Parse(osArgs) + args.Parse(reportArgs.OSArgs) appName := args.Arg(0) - err = ps.CommandReport(appName, *format, infoFlag) + if reportArgs.IsGlobal { + appName = "--global" + } + err = ps.CommandReport(appName, *format, reportArgs.InfoFlag) } case "restart": args := flag.NewFlagSet("ps:restart", flag.ExitOnError) diff --git a/plugins/ps/triggers.go b/plugins/ps/triggers.go index 22471053d..7c4c128b4 100644 --- a/plugins/ps/triggers.go +++ b/plugins/ps/triggers.go @@ -9,7 +9,6 @@ import ( "strings" "github.com/dokku/dokku/plugins/common" - "github.com/dokku/dokku/plugins/config" dockeroptions "github.com/dokku/dokku/plugins/docker-options" ) @@ -37,13 +36,7 @@ func TriggerCorePostDeploy(appName string) error { return err } - entries := map[string]string{ - "DOKKU_APP_RESTORE": "1", - } - - return common.SuppressOutput(func() error { - return config.SetMany(appName, entries, false, false) - }) + return common.PropertyWrite("ps", appName, "restore", "true") } // TriggerCorePostExtract ensures that the main Procfile is the one specified by procfile-path @@ -135,29 +128,40 @@ func TriggerInstall() error { if common.FileExists(dokkuScaleExtracted) { os.Remove(dokkuScaleExtracted) } - - results, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "config-get", - Args: []string{appName, "DOKKU_DOCKER_STOP_TIMEOUT"}, - }) - stopTimeout := results.StdoutContents() - if stopTimeout == "" { - continue - } - - common.LogVerboseQuiet(fmt.Sprintf("Setting %s ps property 'stop-timeout-seconds' to %v", appName, stopTimeout)) - if err := common.PropertyWrite("ps", appName, "stop-timeout-seconds", stopTimeout); err != nil { - return err - } - - _, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "config-unset", - Args: []string{appName, "DOKKU_DOCKER_STOP_TIMEOUT"}, - }) - if err != nil { - return err - } } + + if err := common.MigrateConfigToProperties("ps", []common.MigrateConfigEntry{ + { + ConfigVar: "DOKKU_DOCKER_STOP_TIMEOUT", + Property: "stop-timeout-seconds", + }, + { + ConfigVar: "DOKKU_APP_RESTORE", + Property: "restore", + Transform: func(value string) string { + if value == "0" { + return "false" + } + return "true" + }, + }, + { + ConfigVar: "DOKKU_SKIP_DEPLOY", + GlobalConfigVar: "DOKKU_SKIP_DEPLOY", + Property: "skip-deploy", + }, + { + ConfigVar: "DOKKU_START_CMD", + Property: "start-cmd", + }, + { + ConfigVar: "DOKKU_DOCKERFILE_START_CMD", + Property: "dockerfile-start-cmd", + }, + }); err != nil { + return err + } + return nil } @@ -240,13 +244,7 @@ func TriggerPostDelete(appName string) error { // TriggerPostStop sets the restore property to false func TriggerPostStop(appName string) error { - entries := map[string]string{ - "DOKKU_APP_RESTORE": "0", - } - - return common.SuppressOutput(func() error { - return config.SetMany(appName, entries, false, false) - }) + return common.PropertyWrite("ps", appName, "restore", "false") } // TriggerPostReleaseBuilder ensures an app has an up to date scale parameters @@ -321,14 +319,24 @@ func TriggerPsSetScale(appName string, skipDeploy bool, clearExisting bool, proc func TriggerPsGetProperty(appName string, property string) error { computedValueMap := map[string]common.ReportFunc{ + "restore": reportRestore, + "skip-deploy": reportComputedSkipDeploy, "stop-timeout-seconds": reportComputedStopTimeoutSeconds, } fn, ok := computedValueMap[property] if !ok { - return fmt.Errorf("Invalid network property specified: %v", property) + return fmt.Errorf("Invalid ps property specified: %v", property) } fmt.Println(fn(appName)) return nil } + +func reportComputedSkipDeploy(appName string) string { + value := common.PropertyGet("ps", appName, "skip-deploy") + if value == "" { + value = common.PropertyGet("ps", "--global", "skip-deploy") + } + return value +} diff --git a/plugins/registry/go.mod b/plugins/registry/go.mod index a9b554fa5..24a0145a1 100644 --- a/plugins/registry/go.mod +++ b/plugins/registry/go.mod @@ -1,6 +1,6 @@ module github.com/dokku/dokku/plugins/registry -go 1.25.5 +go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 diff --git a/plugins/registry/go.sum b/plugins/registry/go.sum index c97fc9665..e973ec8a5 100644 --- a/plugins/registry/go.sum +++ b/plugins/registry/go.sum @@ -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= diff --git a/plugins/registry/report.go b/plugins/registry/report.go index 9d1fe3251..a4ca924e7 100644 --- a/plugins/registry/report.go +++ b/plugins/registry/report.go @@ -8,22 +8,33 @@ import ( // ReportSingleApp is an internal function that displays the registry 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{ - "--registry-computed-image-repo": reportComputedImageRepo, - "--registry-image-repo": reportImageRepo, - "--registry-computed-push-on-release": reportComputedPushOnRelease, - "--registry-global-push-on-release": reportGlobalPushOnRelease, - "--registry-push-on-release": reportPushOnRelease, - "--registry-computed-server": reportComputedServer, - "--registry-global-server": reportGlobalServer, - "--registry-global-image-repo-template": reportGlobalImageRepoTemplate, - "--registry-server": reportServer, - "--registry-tag-version": reportTagVersion, - "--registry-push-extra-tags": reportPushExtraTags, + var flags map[string]common.ReportFunc + if appName == "--global" { + flags = map[string]common.ReportFunc{ + "--registry-global-push-on-release": reportGlobalPushOnRelease, + "--registry-global-server": reportGlobalServer, + "--registry-global-image-repo-template": reportGlobalImageRepoTemplate, + } + } else { + flags = map[string]common.ReportFunc{ + "--registry-computed-image-repo": reportComputedImageRepo, + "--registry-image-repo": reportImageRepo, + "--registry-computed-push-on-release": reportComputedPushOnRelease, + "--registry-global-push-on-release": reportGlobalPushOnRelease, + "--registry-push-on-release": reportPushOnRelease, + "--registry-computed-server": reportComputedServer, + "--registry-global-server": reportGlobalServer, + "--registry-global-image-repo-template": reportGlobalImageRepoTemplate, + "--registry-server": reportServer, + "--registry-tag-version": reportTagVersion, + "--registry-push-extra-tags": reportPushExtraTags, + } } flagKeys := []string{} diff --git a/plugins/registry/src/subcommands/subcommands.go b/plugins/registry/src/subcommands/subcommands.go index 35377be84..c8fa06479 100644 --- a/plugins/registry/src/subcommands/subcommands.go +++ b/plugins/registry/src/subcommands/subcommands.go @@ -84,11 +84,14 @@ func main() { case "report": args := flag.NewFlagSet("registry:report", flag.ExitOnError) format := args.String("format", "stdout", "format: [ stdout | json ]") - osArgs, infoFlag, flagErr := common.ParseReportArgs("registry", os.Args[2:]) + reportArgs, flagErr := common.ParseReportArgs("registry", os.Args[2:]) if flagErr == nil { - args.Parse(osArgs) + args.Parse(reportArgs.OSArgs) appName := args.Arg(0) - err = registry.CommandReport(appName, *format, infoFlag) + if reportArgs.IsGlobal { + appName = "--global" + } + err = registry.CommandReport(appName, *format, reportArgs.InfoFlag) } case "set": args := flag.NewFlagSet("registry:set", flag.ExitOnError) diff --git a/plugins/repo/go.mod b/plugins/repo/go.mod index 812060a28..311aad75e 100644 --- a/plugins/repo/go.mod +++ b/plugins/repo/go.mod @@ -1,6 +1,6 @@ module github.com/dokku/dokku/plugins/repo -go 1.25.5 +go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 diff --git a/plugins/repo/go.sum b/plugins/repo/go.sum index c97fc9665..e973ec8a5 100644 --- a/plugins/repo/go.sum +++ b/plugins/repo/go.sum @@ -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= diff --git a/plugins/resource/.gitignore b/plugins/resource/.gitignore index 1e1f0ddd2..6c9bef3b9 100644 --- a/plugins/resource/.gitignore +++ b/plugins/resource/.gitignore @@ -5,6 +5,7 @@ /install /post-* /report +/docker-args-process-build /docker-args-process-deploy /docker-args-process-run /resource-get-property diff --git a/plugins/resource/Makefile b/plugins/resource/Makefile index 9357f9cc2..8260561f5 100644 --- a/plugins/resource/Makefile +++ b/plugins/resource/Makefile @@ -1,5 +1,5 @@ SUBCOMMANDS = subcommands/limit subcommands/limit-clear subcommands/report subcommands/reserve subcommands/reserve-clear -TRIGGERS = triggers/docker-args-process-deploy triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-delete triggers/report triggers/resource-get-property +TRIGGERS = triggers/docker-args-process-build triggers/docker-args-process-deploy triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-delete triggers/report triggers/resource-get-property BUILD = commands subcommands triggers PLUGIN_NAME = resource diff --git a/plugins/resource/go.mod b/plugins/resource/go.mod index 5a330f2c1..055ff8509 100644 --- a/plugins/resource/go.mod +++ b/plugins/resource/go.mod @@ -1,6 +1,6 @@ module github.com/dokku/dokku/plugins/resource -go 1.25.5 +go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 diff --git a/plugins/resource/go.sum b/plugins/resource/go.sum index c97fc9665..e973ec8a5 100644 --- a/plugins/resource/go.sum +++ b/plugins/resource/go.sum @@ -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= diff --git a/plugins/resource/resource.go b/plugins/resource/resource.go index 9c14a7e54..93c8285d4 100644 --- a/plugins/resource/resource.go +++ b/plugins/resource/resource.go @@ -19,8 +19,10 @@ type Resource struct { // ReportSingleApp is an internal function that displays the resource report for one or more apps func ReportSingleApp(appName, format, infoFlag string) error { - if err := common.VerifyAppName(appName); err != nil { - return err + if appName != "--global" { + if err := common.VerifyAppName(appName); err != nil { + return err + } } resources, err := common.PropertyGetAll("resource", appName) diff --git a/plugins/resource/src/subcommands/subcommands.go b/plugins/resource/src/subcommands/subcommands.go index c54697ba6..09f9e97df 100644 --- a/plugins/resource/src/subcommands/subcommands.go +++ b/plugins/resource/src/subcommands/subcommands.go @@ -51,11 +51,14 @@ func main() { case "report": args := flag.NewFlagSet("resource:report", flag.ExitOnError) format := args.String("format", "stdout", "format: [ stdout | json ]") - osArgs, infoFlag, flagErr := common.ParseReportArgs("resource", os.Args[2:]) + reportArgs, flagErr := common.ParseReportArgs("resource", os.Args[2:]) if flagErr == nil { - args.Parse(osArgs) + args.Parse(reportArgs.OSArgs) appName := args.Arg(0) - err = resource.CommandReport(appName, *format, infoFlag) + if reportArgs.IsGlobal { + appName = "--global" + } + err = resource.CommandReport(appName, *format, reportArgs.InfoFlag) } case "reserve": args := flag.NewFlagSet("resource:reserve", flag.ExitOnError) diff --git a/plugins/resource/src/triggers/triggers.go b/plugins/resource/src/triggers/triggers.go index 01e6a9cfe..8dcb03380 100644 --- a/plugins/resource/src/triggers/triggers.go +++ b/plugins/resource/src/triggers/triggers.go @@ -18,6 +18,10 @@ func main() { var err error switch trigger { + case "docker-args-process-build": + appName := flag.Arg(0) + builderType := flag.Arg(1) + err = resource.TriggerDockerArgsProcessBuild(appName, builderType) case "docker-args-process-deploy": appName := flag.Arg(0) processType := flag.Arg(3) diff --git a/plugins/resource/triggers.go b/plugins/resource/triggers.go index 8e3746ae9..6994c69d1 100644 --- a/plugins/resource/triggers.go +++ b/plugins/resource/triggers.go @@ -91,6 +91,84 @@ func TriggerDockerArgsProcessDeploy(appName string, processType string) error { return nil } +// TriggerDockerArgsProcessBuild outputs build-phase docker options for a builder +func TriggerDockerArgsProcessBuild(appName string, builderType string) error { + stdin, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + + if os.Getenv("DOKKU_OMIT_RESOURCE_ARGS") == "1" { + fmt.Print(string(stdin)) + return nil + } + + allowed := validBuildLimitsForBuilder(builderType) + if len(allowed) == 0 { + fmt.Print(string(stdin)) + return nil + } + + resources, err := common.PropertyGetAll("resource", appName) + if err != nil { + fmt.Print(string(stdin)) + return nil + } + + limits := make(map[string]string) + prefix := "build.limit." + for key, value := range resources { + if !strings.HasPrefix(key, prefix) { + continue + } + resourceKey := strings.TrimPrefix(key, prefix) + if !allowed[resourceKey] { + continue + } + + flagName := resourceKey + if resourceKey == "cpu" { + flagName = "cpus" + } + if resourceKey == "nvidia-gpu" { + flagName = "gpus" + } + limits[flagName] = value + } + + for key, value := range limits { + if value == "" { + continue + } + value = addMemorySuffixForDocker(key, value) + fmt.Printf(" --%s=%s ", key, value) + } + + fmt.Print(string(stdin)) + return nil +} + +// validBuildLimitsForBuilder returns the resource keys (in property-key form) +// that the named builder can apply during the build phase. An empty map means +// the builder does not support build-phase resource limits. +func validBuildLimitsForBuilder(builderType string) map[string]bool { + switch builderType { + case "herokuish": + return map[string]bool{ + "cpu": true, + "memory": true, + "memory-swap": true, + "nvidia-gpu": true, + } + case "dockerfile": + return map[string]bool{ + "memory": true, + "memory-swap": true, + } + } + return nil +} + // TriggerInstall runs the install step for the resource plugin func TriggerInstall() error { if err := common.PropertySetup("resource"); err != nil { diff --git a/plugins/scheduler-docker-local/Makefile b/plugins/scheduler-docker-local/Makefile index bd7c75ae1..e781a2d7f 100644 --- a/plugins/scheduler-docker-local/Makefile +++ b/plugins/scheduler-docker-local/Makefile @@ -1,4 +1,4 @@ -TRIGGERS = triggers/scheduler-cron-write +TRIGGERS = triggers/scheduler-cron-write triggers/scheduler-storage-exec BUILD = triggers PLUGIN_NAME = scheduler-docker-local diff --git a/plugins/scheduler-docker-local/bin/scheduler-deploy-process b/plugins/scheduler-docker-local/bin/scheduler-deploy-process index 4d4ed744d..bc567deb9 100755 --- a/plugins/scheduler-docker-local/bin/scheduler-deploy-process +++ b/plugins/scheduler-docker-local/bin/scheduler-deploy-process @@ -85,8 +85,15 @@ fn-scheduler-deploy-process() { else local CONTAINER_IDX_OFFSET=$((PROC_COUNT + 1)) fi - local container_state_filetype + local container_state_filetype orphaned_container_file orphaned_cid pushd "$DOKKU_ROOT/$APP" >/dev/null + for orphaned_container_file in $(find . -maxdepth 1 -name "CONTAINER.$PROC_TYPE.*" -printf "%f\n" | sort -t . -k 3 -n | tail -n +$CONTAINER_IDX_OFFSET); do + [[ -s "$orphaned_container_file" ]] || continue + orphaned_cid="$(<"$orphaned_container_file")" + [[ -z "$orphaned_cid" ]] && continue + dokku_log_verbose "Scheduling orphaned container shutdown in $DOKKU_WAIT_TO_RETIRE seconds (${orphaned_container_file#CONTAINER.})" + plugn trigger scheduler-register-retired "$APP" "$orphaned_cid" "$DOKKU_WAIT_TO_RETIRE" + done for container_state_filetype in CONTAINER IP PORT; do find . -maxdepth 1 -name "$container_state_filetype.$PROC_TYPE.*" -printf "%f\n" | sort -t . -k 3 -n | tail -n +$CONTAINER_IDX_OFFSET | xargs rm -f done diff --git a/plugins/scheduler-docker-local/bin/scheduler-deploy-process-container b/plugins/scheduler-docker-local/bin/scheduler-deploy-process-container index 487dde936..b9ec8a3c9 100755 --- a/plugins/scheduler-docker-local/bin/scheduler-deploy-process-container +++ b/plugins/scheduler-docker-local/bin/scheduler-deploy-process-container @@ -2,6 +2,7 @@ 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/scheduler-docker-local/internal-functions" main() { @@ -122,7 +123,7 @@ fn-scheduler-docker-local-extract-start-cmd() { return fi - DOKKU_DOCKERFILE_START_CMD=$(config_get "$APP" DOKKU_DOCKERFILE_START_CMD || true) + DOKKU_DOCKERFILE_START_CMD=$(fn-plugin-property-get-default "ps" "$APP" "dockerfile-start-cmd" "") DOKKU_PROCFILE_START_CMD=$(plugn trigger procfile-get-command "$APP" "$PROC_TYPE" "$PORT" 2>/dev/null || echo '') START_CMD=${DOKKU_DOCKERFILE_START_CMD:-$DOKKU_PROCFILE_START_CMD} echo "$START_CMD" diff --git a/plugins/scheduler-docker-local/check-deploy b/plugins/scheduler-docker-local/check-deploy index 4a6362ead..8772aefd6 100755 --- a/plugins/scheduler-docker-local/check-deploy +++ b/plugins/scheduler-docker-local/check-deploy @@ -36,6 +36,7 @@ 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/checks/functions" source "$PLUGIN_AVAILABLE_PATH/config/functions" source "$PLUGIN_AVAILABLE_PATH/scheduler-docker-local/internal-functions" @@ -59,21 +60,17 @@ trigger-scheduler-docker-local-check-deploy() { local DOKKU_APP_CONTAINER_ID=${DOKKU_APP_CIDS[0]} fi - # source global and in-app envs to get DOKKU_CHECKS_WAIT and any other necessary vars - eval "$(config_export global)" - eval "$(config_export app "$APP")" - if [[ "$(is_app_proctype_checks_skipped "$APP" "$DOKKU_APP_CONTAINER_TYPE")" == "true" ]]; then dokku_log_info2_quiet "Zero downtime checks have been skipped ($DOKKU_APP_CONTAINER_TYPE.$CONTAINER_INDEX)" exit 0 fi # Wait this many seconds (default 5) for server to start before running checks. - local WAIT="${DOKKU_CHECKS_WAIT:-5}" + local WAIT="$(fn-plugin-property-get-default "checks" "$APP" "wait" "5")" # Wait this many seconds (default 30) for each response. - local TIMEOUT="${DOKKU_CHECKS_TIMEOUT:-30}" + local TIMEOUT="$(fn-plugin-property-get-default "checks" "$APP" "timeout" "30")" # use this number of retries for checks - local ATTEMPTS="${DOKKU_CHECKS_ATTEMPTS:-5}" + local ATTEMPTS="$(fn-plugin-property-get-default "checks" "$APP" "attempts" "5")" local CHECKS_FILENAME="$(fn-scheduler-docker-local-get-process-specific-checks-file-path "$APP")" local IMAGE_TAG="$(get_running_image_tag "$APP")" @@ -121,7 +118,7 @@ trigger-scheduler-docker-local-check-deploy() { } trap "checks_check_deploy_cleanup $APP $DOKKU_APP_CONTAINER_TYPE $DOKKU_APP_CONTAINER_ID $CONTAINER_INDEX $TMP_APP_JSON_OUTPUT" RETURN INT TERM EXIT - local DOKKU_DEFAULT_CHECKS_WAIT="${DOKKU_DEFAULT_CHECKS_WAIT:-10}" + local DOKKU_DEFAULT_CHECKS_WAIT="$(fn-plugin-property-get-default "checks" "--global" "default-wait" "10")" content="$(docker-container-healthchecker add "$DOKKU_APP_CONTAINER_TYPE" --app-json "$TMP_APP_JSON_OUTPUT" --if-empty --pretty --uptime "$DOKKU_DEFAULT_CHECKS_WAIT")" echo "$content" >"$TMP_APP_JSON_OUTPUT" diff --git a/plugins/scheduler-docker-local/go.mod b/plugins/scheduler-docker-local/go.mod index 5a9cf3138..8eb6eddd8 100644 --- a/plugins/scheduler-docker-local/go.mod +++ b/plugins/scheduler-docker-local/go.mod @@ -1,17 +1,20 @@ module github.com/dokku/dokku/plugins/scheduler-docker-local -go 1.25.5 +go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 github.com/dokku/dokku/plugins/cron v0.0.0-00010101000000-000000000000 + github.com/dokku/dokku/plugins/storage v0.0.0-00010101000000-000000000000 github.com/multiformats/go-base36 v0.2.0 + github.com/spf13/pflag v1.0.10 golang.org/x/sync v0.20.0 ) require ( github.com/alexellis/go-execute/v2 v2.2.1 // indirect github.com/dokku/dokku/plugins/app-json v0.0.0-00010101000000-000000000000 // indirect + github.com/dokku/dokku/plugins/docker-options v0.0.0-00010101000000-000000000000 // 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 @@ -38,3 +41,7 @@ replace github.com/dokku/dokku/plugins/app-json => ../app-json replace github.com/dokku/dokku/plugins/common => ../common replace github.com/dokku/dokku/plugins/cron => ../cron + +replace github.com/dokku/dokku/plugins/storage => ../storage + +replace github.com/dokku/dokku/plugins/docker-options => ../docker-options diff --git a/plugins/scheduler-docker-local/go.sum b/plugins/scheduler-docker-local/go.sum index 3de658ee0..caf492d5a 100644 --- a/plugins/scheduler-docker-local/go.sum +++ b/plugins/scheduler-docker-local/go.sum @@ -28,8 +28,8 @@ 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.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= @@ -46,6 +46,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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= diff --git a/plugins/scheduler-docker-local/internal-functions b/plugins/scheduler-docker-local/internal-functions index d0cfdb061..08426ac9a 100755 --- a/plugins/scheduler-docker-local/internal-functions +++ b/plugins/scheduler-docker-local/internal-functions @@ -9,9 +9,13 @@ cmd-scheduler-docker-local-report() { declare desc="displays a scheduler-docker-local report for one or more apps" declare cmd="scheduler-docker-local: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,28 +24,43 @@ cmd-scheduler-docker-local-report() { INFO_FLAG="true" fi - if [[ -z "$APP" ]]; then + if [[ "$APP" == "--global" ]]; then + cmd-scheduler-docker-local-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" + elif [[ -z "$APP" ]]; then for app in $(dokku_apps); do - cmd-scheduler-docker-local-report-single "$app" "$INFO_FLAG" | tee || true + cmd-scheduler-docker-local-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true done else - cmd-scheduler-docker-local-report-single "$APP" "$INFO_FLAG" + cmd-scheduler-docker-local-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" fi } cmd-scheduler-docker-local-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" + if [[ "$APP" != "--global" ]]; then + verify_app_name "$APP" + fi local flag_map=( "--scheduler-docker-local-init-process: $(fn-plugin-property-get "scheduler-docker-local" "$APP" "init-process" "true")" "--scheduler-docker-local-parallel-schedule-count: $(fn-plugin-property-get "scheduler-docker-local" "$APP" "parallel-schedule-count" "")" ) + fn-report-validate-format "$FORMAT" "$INFO_FLAG" + + if [[ "$FORMAT" == "json" ]]; then + fn-report-emit-json flag_map "scheduler-docker-local" + return + fi + if [[ -z "$INFO_FLAG" ]]; then - dokku_log_info2_quiet "${APP} scheduler-docker-local information" + if [[ "$APP" == "--global" ]]; then + dokku_log_info2_quiet "global scheduler-docker-local information" + else + dokku_log_info2_quiet "${APP} scheduler-docker-local information" + fi for flag in "${flag_map[@]}"; do key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')" dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")" @@ -243,6 +262,11 @@ fn-scheduler-docker-local-retire-images() { fi if echo "$RM_OUTPUT" | grep -q "image is being used by running container"; then + if fn-scheduler-docker-local-image-in-use-by-app "$RETIRE_APP" "$IMAGE_ID" ""; then + dokku_log_warn "Image ${IMAGE_ID} is still in use by ${RETIRE_APP}, removing from retire list" + DEAD_IMAGES+=("$IMAGE_ID") + continue + fi dokku_log_warn "Image ${IMAGE_ID} has running containers, skipping rm" continue fi @@ -273,6 +297,39 @@ fn-scheduler-docker-local-register-retired() { fi } +fn-scheduler-docker-local-image-in-use-by-app() { + declare desc="returns 0 if IMAGE_ID is used by an app container that is not already pending retirement" + declare APP="$1" IMAGE_ID="$2" EXCLUDE_CONTAINER_ID="$3" + local DEAD_CONTAINER_FILE="${DOKKU_LIB_ROOT}/data/scheduler-docker-local/dead-containers" + local cid exclude_full_id container_image short_cid + + if [[ -z "$IMAGE_ID" ]]; then + return 1 + fi + + if [[ -n "$EXCLUDE_CONTAINER_ID" ]]; then + exclude_full_id="$("$DOCKER_BIN" container inspect "$EXCLUDE_CONTAINER_ID" --format '{{.Id}}' 2>/dev/null || true)" + fi + + for cid in $("$DOCKER_BIN" container ls -q --no-trunc -f label=com.dokku.app-name="$APP" 2>/dev/null); do + [[ -z "$cid" ]] && continue + if [[ -n "$exclude_full_id" ]] && [[ "$cid" == "$exclude_full_id" ]]; then + continue + fi + + short_cid="${cid:0:12}" + if [[ -f "$DEAD_CONTAINER_FILE" ]] && grep -q "$short_cid" "$DEAD_CONTAINER_FILE"; then + continue + fi + + container_image="$("$DOCKER_BIN" container inspect "$cid" --format '{{.Image}}' 2>/dev/null | cut -d: -f2 || true)" + if [[ -n "$container_image" ]] && [[ "$container_image" == "$IMAGE_ID" ]]; then + return 0 + fi + done + return 1 +} + fn-scheduler-docker-local-start-app-container() { declare desc="starts a single app container" declare APP="$1" diff --git a/plugins/scheduler-docker-local/scheduler-deploy b/plugins/scheduler-docker-local/scheduler-deploy index a3c246c7c..16c3370be 100755 --- a/plugins/scheduler-docker-local/scheduler-deploy +++ b/plugins/scheduler-docker-local/scheduler-deploy @@ -2,6 +2,7 @@ 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/checks/functions" source "$PLUGIN_AVAILABLE_PATH/config/functions" source "$PLUGIN_AVAILABLE_PATH/scheduler-docker-local/internal-functions" @@ -35,7 +36,7 @@ trigger-scheduler-docker-local-scheduler-deploy() { DOKKU_DOCKER_STOP_TIMEOUT="$(plugn trigger ps-get-property "$APP" stop-timeout-seconds)" [[ $DOKKU_DOCKER_STOP_TIMEOUT ]] && DOCKER_STOP_TIME_ARG="-t=${DOKKU_DOCKER_STOP_TIMEOUT}" - DOKKU_START_CMD="$(config_get "$APP" DOKKU_START_CMD || true)" + DOKKU_START_CMD="$(fn-plugin-property-get-default "ps" "$APP" "start-cmd" "")" local PROCFILE_EXISTS=false if plugn trigger procfile-exists "$APP" 2>/dev/null; then @@ -83,6 +84,19 @@ trigger-scheduler-docker-local-scheduler-deploy() { # kill the old container if [[ -n "$oldids" ]]; then + # Send SIGTERM to old containers immediately so the application can begin + # graceful shutdown the moment proxy traffic switches to the new containers. + # We use `container kill --signal=SIGTERM` rather than `container stop` + # because it returns immediately and docker does not follow up with a + # SIGKILL on its own when the signal is something other than SIGKILL - + # the existing background block below remains responsible for the + # eventual hard stop after the wait-to-retire grace period. + for oldid in $oldids; do + if "$DOCKER_BIN" container inspect "$oldid" &>/dev/null; then + "$DOCKER_BIN" container kill --signal=SIGTERM "$oldid" &>/dev/null || true + fi + done + # Let the old container finish processing requests, before terminating it dokku_log_info1 "Shutting down old containers in $DOKKU_WAIT_TO_RETIRE seconds" ( diff --git a/plugins/scheduler-docker-local/scheduler-enter b/plugins/scheduler-docker-local/scheduler-enter index e1a4e8def..03d794a9a 100755 --- a/plugins/scheduler-docker-local/scheduler-enter +++ b/plugins/scheduler-docker-local/scheduler-enter @@ -1,5 +1,6 @@ #!/usr/bin/env bash source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" +source "$PLUGIN_CORE_AVAILABLE_PATH/common/property-functions" source "$PLUGIN_AVAILABLE_PATH/config/functions" set -eo pipefail [[ $DOKKU_TRACE ]] && set -x @@ -92,10 +93,9 @@ trigger-scheduler-docker-local-scheduler-enter() { shift fi - local DOKKU_APP_SHELL="/bin/bash" - DOKKU_APP_SHELL="$(config_get --global DOKKU_APP_SHELL || echo "$DOKKU_APP_SHELL")" - DOKKU_APP_SHELL="$(config_get "$APP" DOKKU_APP_SHELL || echo "$DOKKU_APP_SHELL")" - [[ -z "$DOKKU_APP_SHELL" ]] && DOKKU_APP_SHELL="/bin/bash" + local DOKKU_APP_SHELL + DOKKU_APP_SHELL="$(fn-plugin-property-get-default "scheduler" "$APP" "shell" "")" + [[ -z "$DOKKU_APP_SHELL" ]] && DOKKU_APP_SHELL="$(fn-plugin-property-get-default "scheduler" "--global" "shell" "/bin/bash")" local EXEC_CMD="" has_tty && local DOKKU_RUN_OPTS+=" -i -t" diff --git a/plugins/scheduler-docker-local/scheduler-register-retired b/plugins/scheduler-docker-local/scheduler-register-retired index 2c700205e..3d16d7fbc 100755 --- a/plugins/scheduler-docker-local/scheduler-register-retired +++ b/plugins/scheduler-docker-local/scheduler-register-retired @@ -21,12 +21,17 @@ trigger-scheduler-docker-local-scheduler-register-retired() { if [[ -n "$IMAGE_ID" ]] && [[ -z "$DOKKU_SKIP_IMAGE_CLEANUP_REGISTRATION" ]]; then ALT_IMAGE_TAGS="$("$DOCKER_BIN" image inspect --format '{{ index .Config.Labels "com.dokku.docker-image-labeler/alternate-tags" }}' "$IMAGE_ID" 2>/dev/null || true)" - fn-scheduler-docker-local-register-retired "image" "$APP" "$IMAGE_ID" "$WAIT" + if ! fn-scheduler-docker-local-image-in-use-by-app "$APP" "$IMAGE_ID" "$CONTAINER_ID"; then + fn-scheduler-docker-local-register-retired "image" "$APP" "$IMAGE_ID" "$WAIT" + fi if [[ -n "$ALT_IMAGE_TAGS" ]]; then ALT_IMAGE_TAG="$(echo "$ALT_IMAGE_TAGS" | jq -r ".[]")" if [[ "$(plugn trigger git-get-property "$APP" "source-image")" != "$ALT_IMAGE_TAG" ]]; then ALT_IMAGE_ID="$("$DOCKER_BIN" image inspect --format '{{ .Id }}' "$ALT_IMAGE_TAG" 2>/dev/null || true)" - fn-scheduler-docker-local-register-retired "image" "$APP" "$(echo "$ALT_IMAGE_ID" | cut -d ':' -f2)" "$WAIT" + ALT_IMAGE_ID="$(echo "$ALT_IMAGE_ID" | cut -d ':' -f2)" + if [[ -n "$ALT_IMAGE_ID" ]] && ! fn-scheduler-docker-local-image-in-use-by-app "$APP" "$ALT_IMAGE_ID" "$CONTAINER_ID"; then + fn-scheduler-docker-local-register-retired "image" "$APP" "$ALT_IMAGE_ID" "$WAIT" + fi fi fi fi diff --git a/plugins/scheduler-docker-local/scheduler-run b/plugins/scheduler-docker-local/scheduler-run index b9b5cb9e3..90327e49e 100755 --- a/plugins/scheduler-docker-local/scheduler-run +++ b/plugins/scheduler-docker-local/scheduler-run @@ -2,6 +2,7 @@ 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" source "$PLUGIN_AVAILABLE_PATH/scheduler-docker-local/internal-functions" @@ -72,6 +73,7 @@ trigger-scheduler-docker-local-scheduler-run() { local DYNO_NUMBER="$RANDOM" local IMAGE_SOURCE_TYPE="dockerfile" is_image_herokuish_based "$IMAGE" "$APP" && IMAGE_SOURCE_TYPE="herokuish" + is_image_cnb_based "$IMAGE" && IMAGE_SOURCE_TYPE="pack" DOCKER_ARGS+=$(: | plugn trigger docker-args-process-run "$APP" "$IMAGE_SOURCE_TYPE" "$IMAGE_TAG") DOCKER_ARGS+=" -e DYNO=$PROCESS_TYPE.$DYNO_NUMBER --name $APP.$PROCESS_TYPE.$DYNO_NUMBER" @@ -95,8 +97,8 @@ trigger-scheduler-docker-local-scheduler-run() { done fi - [[ "$IMAGE_SOURCE_TYPE" == "herokuish" ]] && local EXEC_CMD="/exec" - is_image_cnb_based "$IMAGE" && EXEC_CMD="" + local EXEC_CMD="" + [[ "$IMAGE_SOURCE_TYPE" == "herokuish" ]] && EXEC_CMD="/exec" if [[ "$1" == "--" ]]; then shift @@ -137,8 +139,8 @@ trigger-scheduler-docker-local-scheduler-run() { RUN_COMMAND=("$@") if [[ "${#RUN_COMMAND[@]}" -eq 0 ]]; then if [[ -z "$DOKKU_SHELL" ]]; then - local DOKKU_APP_SHELL=$(config_get "$APP" DOKKU_APP_SHELL || true) - local DOKKU_GLOBAL_SHELL=$(config_get --global DOKKU_APP_SHELL || true) + local DOKKU_APP_SHELL=$(fn-plugin-property-get-default "scheduler" "$APP" "shell" "") + local DOKKU_GLOBAL_SHELL=$(fn-plugin-property-get-default "scheduler" "--global" "shell" "") local DOKKU_SHELL=${DOKKU_APP_SHELL:="$DOKKU_GLOBAL_SHELL"} fi diff --git a/plugins/scheduler-docker-local/src/triggers/triggers.go b/plugins/scheduler-docker-local/src/triggers/triggers.go index 7ee657f92..2e2c4ff61 100644 --- a/plugins/scheduler-docker-local/src/triggers/triggers.go +++ b/plugins/scheduler-docker-local/src/triggers/triggers.go @@ -1,11 +1,12 @@ package main import ( - "flag" "fmt" "os" "strings" + flag "github.com/spf13/pflag" + "github.com/dokku/dokku/plugins/common" schedulerdockerlocal "github.com/dokku/dokku/plugins/scheduler-docker-local" ) @@ -14,6 +15,10 @@ import ( func main() { parts := strings.Split(os.Args[0], "/") trigger := parts[len(parts)-1] + + interactive := flag.Bool("interactive", false, "--interactive: stdin is open") + tty := flag.Bool("tty", false, "--tty: stdin is a terminal") + asUser := flag.String("as-user", "", "--as-user: numeric uid override") flag.Parse() var err error @@ -21,6 +26,27 @@ func main() { case "scheduler-cron-write": scheduler := flag.Arg(0) err = schedulerdockerlocal.TriggerSchedulerCronWrite(scheduler) + case "scheduler-storage-exec": + args := flag.Args() + if len(args) < 3 { + err = fmt.Errorf("scheduler-storage-exec requires ") + break + } + scheduler := args[0] + entryName := args[1] + image := args[2] + var cmd []string + if len(args) > 3 { + cmd = args[3:] + } + err = schedulerdockerlocal.TriggerSchedulerStorageExec(scheduler, schedulerdockerlocal.StorageExecInput{ + EntryName: entryName, + Image: image, + Interactive: *interactive, + Tty: *tty, + AsUser: *asUser, + Command: cmd, + }) default: err = fmt.Errorf("Invalid plugin trigger call: %s", trigger) } diff --git a/plugins/scheduler-docker-local/storage_exec.go b/plugins/scheduler-docker-local/storage_exec.go new file mode 100644 index 000000000..4db397e29 --- /dev/null +++ b/plugins/scheduler-docker-local/storage_exec.go @@ -0,0 +1,164 @@ +package schedulerdockerlocal + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/dokku/dokku/plugins/common" + "github.com/dokku/dokku/plugins/storage" +) + +// dockerNamedVolumeRegexp mirrors storage.dockerNamedVolumeRegexp and is +// used to decide whether a HostPath should be statted (filesystem) or +// looked up via `docker volume inspect`. +var dockerNamedVolumeRegexp = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) + +// StorageExecInput captures the inputs forwarded by the storage plugin +// over the scheduler-storage-exec trigger. +type StorageExecInput struct { + EntryName string + Image string + Interactive bool + Tty bool + AsUser string + Command []string +} + +// TriggerSchedulerStorageExec runs an interactive or non-interactive +// command in a throwaway docker container that mounts the storage +// entry's host path or named volume at /data. Returns the exit code of +// docker run via os.Exit so the caller's status mirrors the underlying +// tool. +func TriggerSchedulerStorageExec(scheduler string, input StorageExecInput) error { + if scheduler != "docker-local" { + // Not for us; let other handlers respond. + return nil + } + + if !storage.EntryExists(input.EntryName) { + return fmt.Errorf("storage entry %q does not exist", input.EntryName) + } + entry, err := storage.LoadEntry(input.EntryName) + if err != nil { + return err + } + if entry.Scheduler != storage.SchedulerDockerLocal { + return fmt.Errorf("storage entry %q has scheduler %q, not docker-local", entry.Name, entry.Scheduler) + } + + if err := preflightDockerLocalSource(entry.HostPath); err != nil { + return err + } + + args, err := buildDockerExecArgs(entry, input) + if err != nil { + return err + } + + result, err := common.CallExecCommand(common.ExecCommandInput{ + Command: common.DockerBin(), + Args: args, + StreamStdio: true, + }) + // CallExecCommand wraps non-zero exit as an error; for storage:exec + // the docker-run exit code is the signal we want to forward to the + // caller. Propagate it before falling through to the err return, + // which would otherwise be collapsed to exit 1 by the dispatcher. + if result.ExitCode != 0 { + os.Exit(result.ExitCode) + } + if err != nil { + return err + } + return nil +} + +// preflightDockerLocalSource fails fast when the host path or named +// volume backing the entry doesn't exist, so the user gets a real error +// instead of a generic "docker: ..." line. +func preflightDockerLocalSource(hostPath string) error { + if hostPath == "" { + return errors.New("storage entry is missing a host path") + } + if filepath.IsAbs(hostPath) { + info, err := os.Stat(hostPath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("host path %s does not exist", hostPath) + } + return fmt.Errorf("unable to stat host path %s: %w", hostPath, err) + } + if !info.IsDir() { + return fmt.Errorf("host path %s exists but is not a directory", hostPath) + } + return nil + } + if !dockerNamedVolumeRegexp.MatchString(hostPath) { + return fmt.Errorf("host path %q is neither absolute nor a valid docker volume token", hostPath) + } + result, err := common.CallExecCommand(common.ExecCommandInput{ + Command: common.DockerBin(), + Args: []string{"volume", "inspect", hostPath}, + }) + if err != nil { + return err + } + if result.ExitCode != 0 { + return fmt.Errorf("docker named volume %q is not present: %s", hostPath, strings.TrimSpace(result.StderrContents())) + } + return nil +} + +// buildDockerExecArgs assembles the `docker run` argv for a storage:exec +// invocation. Split out so the unit test can exercise it without +// actually invoking docker. +func buildDockerExecArgs(entry *storage.Entry, input StorageExecInput) ([]string, error) { + cmd := input.Command + if len(cmd) == 0 { + cmd = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"} + } + + args := []string{"run", "--rm"} + if input.Tty { + args = append(args, "-it") + } else if input.Interactive { + args = append(args, "-i") + } + + user, err := resolveUser(entry.Chown, input.AsUser) + if err != nil { + return nil, err + } + if user != "" { + args = append(args, "--user", user) + } + + args = append(args, "-v", fmt.Sprintf("%s:/data", entry.HostPath)) + args = append(args, input.Image) + args = append(args, cmd...) + return args, nil +} + +// resolveUser turns the entry's Chown setting plus an optional --as-user +// override into a docker --user value. Returns an empty string when the +// container should run as the image's default user. +func resolveUser(chown string, asUser string) (string, error) { + if asUser != "" { + return fmt.Sprintf("%s:%s", asUser, asUser), nil + } + if chown == "" || chown == "false" { + return "", nil + } + uid, err := storage.ResolveChownID(chown) + if err != nil { + return "", err + } + if uid == "" || uid == "false" { + return "", nil + } + return fmt.Sprintf("%s:%s", uid, uid), nil +} diff --git a/plugins/scheduler-docker-local/storage_exec_test.go b/plugins/scheduler-docker-local/storage_exec_test.go new file mode 100644 index 000000000..110bab0eb --- /dev/null +++ b/plugins/scheduler-docker-local/storage_exec_test.go @@ -0,0 +1,118 @@ +package schedulerdockerlocal + +import ( + "strings" + "testing" + + "github.com/dokku/dokku/plugins/storage" +) + +func TestBuildDockerExecArgsTTY(t *testing.T) { + entry := &storage.Entry{ + Name: "demo-data", + Scheduler: storage.SchedulerDockerLocal, + HostPath: "/var/lib/dokku/data/storage/demo-data", + } + + cases := []struct { + name string + interactive bool + tty bool + want string + }{ + {"non-interactive", false, false, ""}, + {"piped stdin", true, false, "-i"}, + {"interactive tty", true, true, "-it"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args, err := buildDockerExecArgs(entry, StorageExecInput{ + Image: "alpine:3", + Interactive: tc.interactive, + Tty: tc.tty, + Command: []string{"ls", "/data"}, + }) + if err != nil { + t.Fatalf("buildDockerExecArgs returned error: %v", err) + } + joined := strings.Join(args, " ") + if tc.want == "" { + if strings.Contains(joined, " -i ") || strings.Contains(joined, " -it ") { + t.Fatalf("expected no -i / -it flag, got: %s", joined) + } + } else if !strings.Contains(joined, " "+tc.want+" ") { + t.Fatalf("expected to find %q in args, got: %s", tc.want, joined) + } + }) + } +} + +func TestBuildDockerExecArgsUser(t *testing.T) { + entry := &storage.Entry{ + Name: "demo-data", + Scheduler: storage.SchedulerDockerLocal, + HostPath: "/var/lib/dokku/data/storage/demo-data", + Chown: "false", + } + + args, err := buildDockerExecArgs(entry, StorageExecInput{ + Image: "alpine:3", + Command: []string{"ls"}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if strings.Contains(strings.Join(args, " "), "--user") { + t.Fatalf("did not expect --user flag with chown=false, got: %v", args) + } + + args, err = buildDockerExecArgs(entry, StorageExecInput{ + Image: "alpine:3", + AsUser: "1234", + Command: []string{"ls"}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if !strings.Contains(strings.Join(args, " "), "--user 1234:1234") { + t.Fatalf("expected --user 1234:1234, got: %v", args) + } +} + +func TestBuildDockerExecArgsDefaultsToShellWhenNoCommand(t *testing.T) { + entry := &storage.Entry{ + Name: "demo-data", + Scheduler: storage.SchedulerDockerLocal, + HostPath: "/var/lib/dokku/data/storage/demo-data", + } + args, err := buildDockerExecArgs(entry, StorageExecInput{ + Image: "alpine:3", + }) + if err != nil { + t.Fatalf("err: %v", err) + } + last := args[len(args)-3:] + if last[0] != "sh" || last[1] != "-c" { + t.Fatalf("expected the trailing command to be a shell fallback, got: %v", last) + } +} + +func TestBuildDockerExecArgsMountsHostPath(t *testing.T) { + entry := &storage.Entry{ + Name: "demo-data", + Scheduler: storage.SchedulerDockerLocal, + HostPath: "/srv/demo", + } + args, err := buildDockerExecArgs(entry, StorageExecInput{ + Image: "alpine:3", + Command: []string{"ls"}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + joined := strings.Join(args, " ") + if !strings.Contains(joined, "-v /srv/demo:/data") { + t.Fatalf("expected -v /srv/demo:/data, got: %s", joined) + } +} diff --git a/plugins/scheduler-k3s/Makefile b/plugins/scheduler-k3s/Makefile index fce3bb816..13dc52e1b 100644 --- a/plugins/scheduler-k3s/Makefile +++ b/plugins/scheduler-k3s/Makefile @@ -1,5 +1,5 @@ SUBCOMMANDS = subcommands/annotations:set subcommands/autoscaling-auth:set subcommands/autoscaling-auth:report subcommands/cluster:add subcommands/cluster:list subcommands/cluster:remove subcommands/ensure-charts subcommands/initialize subcommands/labels:set subcommands/profiles:add subcommands/profiles:list subcommands/profiles:remove subcommands/report subcommands/set subcommands/show-kubeconfig subcommands/uninstall -TRIGGERS = triggers/core-post-deploy triggers/core-post-extract triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-certs-update triggers/post-certs-remove triggers/post-create triggers/post-delete triggers/report triggers/scheduler-app-status triggers/scheduler-deploy triggers/scheduler-enter triggers/scheduler-is-deployed triggers/scheduler-logs triggers/scheduler-proxy-config triggers/scheduler-proxy-logs triggers/scheduler-post-delete triggers/scheduler-run triggers/scheduler-run-list triggers/scheduler-stop triggers/scheduler-cron-write +TRIGGERS = triggers/core-post-deploy triggers/core-post-extract triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-certs-update triggers/post-certs-remove triggers/post-create triggers/post-delete triggers/report triggers/scheduler-app-status triggers/scheduler-deploy triggers/scheduler-enter triggers/scheduler-is-deployed triggers/scheduler-logs triggers/scheduler-proxy-config triggers/scheduler-proxy-logs triggers/scheduler-post-delete triggers/scheduler-run triggers/scheduler-run-list triggers/scheduler-stop triggers/scheduler-cron-write triggers/storage-create triggers/storage-destroy triggers/storage-status triggers/scheduler-storage-exec BUILD = commands subcommands triggers PLUGIN_NAME = scheduler-k3s diff --git a/plugins/scheduler-k3s/chart_hooks.go b/plugins/scheduler-k3s/chart_hooks.go new file mode 100644 index 000000000..324ded96d --- /dev/null +++ b/plugins/scheduler-k3s/chart_hooks.go @@ -0,0 +1,104 @@ +package scheduler_k3s + +import ( + "context" + "fmt" + "sort" + + "github.com/Masterminds/semver/v3" +) + +// ChartHookFunc is the signature for a function that runs around a helm chart +// upgrade. It receives the active Kubernetes client, the chart definition, and +// the helm release that was installed before the upgrade started. +type ChartHookFunc func(ctx context.Context, clientset KubernetesClient, chart HelmChart, installedRevision Release) error + +// ChartHook attaches version-specific behaviour to a helm chart upgrade. A +// hook fires when the currently installed release version is below +// TargetVersion and the chart's configured target version is at or above +// TargetVersion. PreUpgrade runs before the chart is upgraded to +// TargetVersion; PostUpgrade runs after that intermediate upgrade succeeds. +type ChartHook struct { + TargetVersion string + PreUpgrade ChartHookFunc + PostUpgrade ChartHookFunc +} + +// ChartHooks is keyed by HelmChart.ReleaseName. selectChartHooks resorts +// entries by semver, so authoring order is informative rather than load-bearing. +var ChartHooks = map[string][]ChartHook{ + "keda-add-ons-http": { + { + TargetVersion: "0.12.2", + PreUpgrade: deleteKedaAddOnsHttpDeployments, + }, + }, +} + +// selectChartHooks returns the hooks for releaseName whose TargetVersion is +// strictly greater than installedVersion and less than or equal to +// targetVersion, sorted ascending by TargetVersion. An empty installedVersion +// signals a fresh install and yields no hooks. +func selectChartHooks(releaseName, installedVersion, targetVersion string) ([]ChartHook, error) { + if installedVersion == "" { + return nil, nil + } + + hooks, ok := ChartHooks[releaseName] + if !ok || len(hooks) == 0 { + return nil, nil + } + + installed, err := semver.NewVersion(installedVersion) + if err != nil { + return nil, fmt.Errorf("Invalid installed version %q for chart %s: %w", installedVersion, releaseName, err) + } + + target, err := semver.NewVersion(targetVersion) + if err != nil { + return nil, fmt.Errorf("Invalid target version %q for chart %s: %w", targetVersion, releaseName, err) + } + + type indexedHook struct { + version *semver.Version + hook ChartHook + } + + selected := []indexedHook{} + for _, hook := range hooks { + hookVersion, err := semver.NewVersion(hook.TargetVersion) + if err != nil { + return nil, fmt.Errorf("Invalid hook TargetVersion %q for chart %s: %w", hook.TargetVersion, releaseName, err) + } + + if hookVersion.Compare(installed) <= 0 { + continue + } + if hookVersion.Compare(target) > 0 { + continue + } + + selected = append(selected, indexedHook{version: hookVersion, hook: hook}) + } + + sort.Slice(selected, func(i, j int) bool { + return selected[i].version.LessThan(selected[j].version) + }) + + out := make([]ChartHook, 0, len(selected)) + for _, entry := range selected { + out = append(out, entry.hook) + } + return out, nil +} + +// deleteKedaAddOnsHttpDeployments removes the deployments managed by the +// keda-add-ons-http helm release so the chart can recreate them with the +// updated selectors introduced in 0.12.x. Selectors on Deployments are +// immutable, so a straight helm upgrade fails without this step. +func deleteKedaAddOnsHttpDeployments(ctx context.Context, clientset KubernetesClient, chart HelmChart, installedRevision Release) error { + return clientset.DeleteDeployment(ctx, DeleteDeploymentInput{ + Namespace: chart.Namespace, + LabelSelector: "app.kubernetes.io/instance=keda-add-ons-http", + }) +} diff --git a/plugins/scheduler-k3s/chart_hooks_test.go b/plugins/scheduler-k3s/chart_hooks_test.go new file mode 100644 index 000000000..797500c8d --- /dev/null +++ b/plugins/scheduler-k3s/chart_hooks_test.go @@ -0,0 +1,159 @@ +package scheduler_k3s + +import ( + "context" + "testing" +) + +func makeHook(version string) ChartHook { + noop := func(context.Context, KubernetesClient, HelmChart, Release) error { return nil } + return ChartHook{ + TargetVersion: version, + PreUpgrade: noop, + } +} + +func versionsOf(hooks []ChartHook) []string { + out := make([]string, 0, len(hooks)) + for _, hook := range hooks { + out = append(out, hook.TargetVersion) + } + return out +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestSelectChartHooks(t *testing.T) { + previous := ChartHooks + t.Cleanup(func() { ChartHooks = previous }) + + ChartHooks = map[string][]ChartHook{ + "chart-with-hooks": { + makeHook("0.12.0"), + makeHook("0.10.0"), + makeHook("0.11.0"), + }, + } + + cases := []struct { + name string + releaseName string + installedVersion string + targetVersion string + want []string + wantErr bool + }{ + { + name: "fresh install yields no hooks", + releaseName: "chart-with-hooks", + installedVersion: "", + targetVersion: "0.12.0", + want: nil, + }, + { + name: "no hooks registered for release", + releaseName: "chart-without-hooks", + installedVersion: "0.5.0", + targetVersion: "0.6.0", + want: nil, + }, + { + name: "single applicable hook returned", + releaseName: "chart-with-hooks", + installedVersion: "0.10.0", + targetVersion: "0.11.0", + want: []string{"0.11.0"}, + }, + { + name: "all hooks below installed version excluded", + releaseName: "chart-with-hooks", + installedVersion: "0.12.0", + targetVersion: "0.12.0", + want: nil, + }, + { + name: "all applicable hooks returned in ascending semver order", + releaseName: "chart-with-hooks", + installedVersion: "0.9.0", + targetVersion: "0.12.0", + want: []string{"0.10.0", "0.11.0", "0.12.0"}, + }, + { + name: "partial coverage returns only unsatisfied hooks", + releaseName: "chart-with-hooks", + installedVersion: "0.10.0", + targetVersion: "0.12.0", + want: []string{"0.11.0", "0.12.0"}, + }, + { + name: "target above all hooks still caps at chart target", + releaseName: "chart-with-hooks", + installedVersion: "0.9.0", + targetVersion: "0.11.0", + want: []string{"0.10.0", "0.11.0"}, + }, + { + name: "unparseable installed version errors", + releaseName: "chart-with-hooks", + installedVersion: "not-a-version", + targetVersion: "0.12.0", + wantErr: true, + }, + { + name: "unparseable target version errors", + releaseName: "chart-with-hooks", + installedVersion: "0.9.0", + targetVersion: "not-a-version", + wantErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := selectChartHooks(tc.releaseName, tc.installedVersion, tc.targetVersion) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + gotVersions := versionsOf(got) + if !equalStrings(gotVersions, tc.want) { + t.Fatalf("versions mismatch: got %v, want %v", gotVersions, tc.want) + } + }) + } +} + +func TestSelectChartHooksTreatsLeadingVAsEqual(t *testing.T) { + previous := ChartHooks + t.Cleanup(func() { ChartHooks = previous }) + + ChartHooks = map[string][]ChartHook{ + "chart-v-prefixed": { + makeHook("v1.13.3"), + }, + } + + got, err := selectChartHooks("chart-v-prefixed", "v1.12.0", "v1.13.3") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 || got[0].TargetVersion != "v1.13.3" { + t.Fatalf("expected v1.13.3 hook, got %v", versionsOf(got)) + } +} diff --git a/plugins/scheduler-k3s/config_secret.go b/plugins/scheduler-k3s/config_secret.go new file mode 100644 index 000000000..c36c6ba31 --- /dev/null +++ b/plugins/scheduler-k3s/config_secret.go @@ -0,0 +1,188 @@ +package scheduler_k3s + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "path/filepath" + + "github.com/dokku/dokku/plugins/common" +) + +// ConfigSecretValues contains the values for the config secret helm chart +type ConfigSecretValues struct { + Global ConfigSecretGlobalValues `yaml:"global"` +} + +// ConfigSecretGlobalValues contains the global values for the config secret chart +type ConfigSecretGlobalValues struct { + Annotations map[string]string `yaml:"annotations,omitempty"` + AppName string `yaml:"app_name"` + Labels map[string]string `yaml:"labels,omitempty"` + Namespace string `yaml:"namespace"` + Secrets map[string]string `yaml:"secrets,omitempty"` +} + +// GetConfigSecretReleaseName returns the helm release name for the config secret +func GetConfigSecretReleaseName(appName string) string { + return fmt.Sprintf("config-%s", appName) +} + +// GetConfigSecretName returns the kubernetes secret name for the config env +func GetConfigSecretName(appName string) string { + return fmt.Sprintf("config-%s", appName) +} + +// CreateOrUpdateConfigSecretInput contains the inputs to CreateOrUpdateConfigSecret +type CreateOrUpdateConfigSecretInput struct { + AppName string + Env map[string]string + Annotations map[string]string + Labels map[string]string +} + +// CreateOrUpdateConfigSecret creates or updates the config env secret helm chart for an app +func CreateOrUpdateConfigSecret(ctx context.Context, input CreateOrUpdateConfigSecretInput) error { + appName := input.AppName + if err := isKubernetesAvailable(); err != nil { + common.LogDebug("kubernetes not available, skipping config secret creation") + return nil + } + + scheduler := common.PropertyGetDefault("scheduler", appName, "selected", "") + globalScheduler := common.PropertyGetDefault("scheduler", "--global", "selected", "docker-local") + if scheduler == "" { + scheduler = globalScheduler + } + if scheduler != "k3s" { + common.LogDebug("app does not use k3s scheduler, skipping config secret creation") + return nil + } + + chartDir, err := os.MkdirTemp("", "dokku-config-secret-chart-") + if err != nil { + return fmt.Errorf("error creating chart directory: %w", err) + } + defer os.RemoveAll(chartDir) + + if err := os.MkdirAll(filepath.Join(chartDir, "templates"), os.FileMode(0755)); err != nil { + return fmt.Errorf("error creating chart templates directory: %w", err) + } + + namespace := getComputedNamespace(appName) + releaseName := GetConfigSecretReleaseName(appName) + + chart := &Chart{ + ApiVersion: "v2", + AppVersion: "1.0.0", + Name: releaseName, + Icon: "https://dokku.com/assets/dokku-logo.svg", + Version: "0.0.1", + } + + err = writeYaml(WriteYamlInput{ + Object: chart, + Path: filepath.Join(chartDir, "Chart.yaml"), + }) + if err != nil { + return fmt.Errorf("error writing chart: %w", err) + } + + b, err := templates.ReadFile("templates/config-secret-chart/templates/config-secret.yaml") + if err != nil { + return fmt.Errorf("error reading config-secret template: %w", err) + } + + filename := filepath.Join(chartDir, "templates", "config-secret.yaml") + err = os.WriteFile(filename, b, os.FileMode(0644)) + if err != nil { + return fmt.Errorf("error writing config-secret template: %w", err) + } + + if os.Getenv("DOKKU_TRACE") == "1" { + common.CatFile(filename) + } + + encodedSecrets := map[string]string{} + for key, value := range input.Env { + encodedSecrets[key] = base64.StdEncoding.EncodeToString([]byte(value)) + } + + values := &ConfigSecretValues{ + Global: ConfigSecretGlobalValues{ + Annotations: input.Annotations, + AppName: appName, + Labels: input.Labels, + Namespace: namespace, + Secrets: encodedSecrets, + }, + } + + err = writeYaml(WriteYamlInput{ + Object: values, + Path: filepath.Join(chartDir, "values.yaml"), + }) + if err != nil { + return fmt.Errorf("error writing values: %w", err) + } + + if err := createKubernetesNamespace(ctx, namespace); err != nil { + return fmt.Errorf("error creating namespace: %w", err) + } + + helmAgent, err := NewHelmAgent(namespace, DeployLogPrinter) + if err != nil { + return fmt.Errorf("error creating helm agent: %w", err) + } + + chartPath, err := filepath.Abs(chartDir) + if err != nil { + return fmt.Errorf("error getting chart path: %w", err) + } + + common.LogVerboseQuiet(fmt.Sprintf("Installing config secret for %s", appName)) + err = helmAgent.InstallOrUpgradeChart(ctx, ChartInput{ + ChartPath: chartPath, + Namespace: namespace, + ReleaseName: releaseName, + Wait: false, + }) + if err != nil { + return fmt.Errorf("error installing config secret chart: %w", err) + } + + return nil +} + +// DeleteConfigSecret deletes the config env secret helm chart for an app +func DeleteConfigSecret(ctx context.Context, appName string) error { + if err := isKubernetesAvailable(); err != nil { + common.LogDebug("kubernetes not available, skipping config secret deletion") + return nil + } + + namespace := getComputedNamespace(appName) + releaseName := GetConfigSecretReleaseName(appName) + + helmAgent, err := NewHelmAgent(namespace, DeployLogPrinter) + if err != nil { + return fmt.Errorf("error creating helm agent: %w", err) + } + + exists, err := helmAgent.ChartExists(releaseName) + if err != nil { + return fmt.Errorf("error checking if config secret chart exists: %w", err) + } + + if !exists { + return nil + } + + common.LogVerboseQuiet(fmt.Sprintf("Removing config secret for %s", appName)) + if err := helmAgent.UninstallChart(releaseName); err != nil { + return fmt.Errorf("error uninstalling config secret chart: %w", err) + } + + return nil +} diff --git a/plugins/scheduler-k3s/config_secret_test.go b/plugins/scheduler-k3s/config_secret_test.go new file mode 100644 index 000000000..aedd9727e --- /dev/null +++ b/plugins/scheduler-k3s/config_secret_test.go @@ -0,0 +1,65 @@ +package scheduler_k3s + +import ( + "encoding/base64" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestGetConfigSecretReleaseName(t *testing.T) { + got := GetConfigSecretReleaseName("myapp") + want := "config-myapp" + if got != want { + t.Fatalf("GetConfigSecretReleaseName(\"myapp\") = %q, want %q", got, want) + } +} + +func TestGetConfigSecretName(t *testing.T) { + got := GetConfigSecretName("myapp") + want := "config-myapp" + if got != want { + t.Fatalf("GetConfigSecretName(\"myapp\") = %q, want %q", got, want) + } +} + +func TestConfigSecretValuesEncoding(t *testing.T) { + values := &ConfigSecretValues{ + Global: ConfigSecretGlobalValues{ + AppName: "myapp", + Namespace: "default", + Secrets: map[string]string{ + "DATABASE_URL": base64.StdEncoding.EncodeToString([]byte("postgres://localhost/db")), + "REDIS_URL": base64.StdEncoding.EncodeToString([]byte("redis://localhost:6379")), + }, + }, + } + + data, err := yaml.Marshal(values) + if err != nil { + t.Fatalf("yaml.Marshal returned error: %v", err) + } + + var roundTrip ConfigSecretValues + if err := yaml.Unmarshal(data, &roundTrip); err != nil { + t.Fatalf("yaml.Unmarshal returned error: %v", err) + } + + if roundTrip.Global.AppName != "myapp" { + t.Errorf("AppName = %q, want \"myapp\"", roundTrip.Global.AppName) + } + if roundTrip.Global.Namespace != "default" { + t.Errorf("Namespace = %q, want \"default\"", roundTrip.Global.Namespace) + } + if len(roundTrip.Global.Secrets) != 2 { + t.Errorf("Secrets length = %d, want 2", len(roundTrip.Global.Secrets)) + } + + decoded, err := base64.StdEncoding.DecodeString(roundTrip.Global.Secrets["DATABASE_URL"]) + if err != nil { + t.Fatalf("base64 decode failed: %v", err) + } + if string(decoded) != "postgres://localhost/db" { + t.Errorf("DATABASE_URL decoded = %q, want \"postgres://localhost/db\"", string(decoded)) + } +} diff --git a/plugins/scheduler-k3s/functions.go b/plugins/scheduler-k3s/functions.go index 3cbbc3251..faaddffca 100644 --- a/plugins/scheduler-k3s/functions.go +++ b/plugins/scheduler-k3s/functions.go @@ -434,21 +434,13 @@ func extractStartCommand(input StartCommandInput) string { return "/start " + input.ProcessType } - resp, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "config-get", - Args: []string{input.AppName, "DOKKU_START_CMD"}, - }) - if err == nil && resp.ExitCode == 0 && len(resp.Stdout) > 0 { - command = strings.TrimSpace(resp.Stdout) + if startCmd := common.PropertyGet("ps", input.AppName, "start-cmd"); startCmd != "" { + command = startCmd } if input.ImageSourceType == "dockerfile" { - resp, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "config-get", - Args: []string{input.AppName, "DOKKU_DOCKERFILE_START_CMD"}, - }) - if err == nil && resp.ExitCode == 0 && len(resp.Stdout) > 0 { - command = strings.TrimSpace(resp.Stdout) + if dockerfileStartCmd := common.PropertyGet("ps", input.AppName, "dockerfile-start-cmd"); dockerfileStartCmd != "" { + command = dockerfileStartCmd } } @@ -816,6 +808,63 @@ func getComputedImagePullSecrets(appName string) string { return imagePullSecrets } +// pruneStaleImagePullSecretsFromDeployments rewrites the imagePullSecrets list on existing app +// Deployments to contain only the names in keepNames. This removes references that helm has +// lost track of due to strategic-merge accumulation on PodSpec.ImagePullSecrets, which is the +// root cause of stale-secret pod hard crashes after rollbacks. The patch is a no-op when the +// live list already matches. +func pruneStaleImagePullSecretsFromDeployments(ctx context.Context, clientset KubernetesClient, namespace string, appName string, keepNames []string) error { + deployments, err := clientset.ListDeployments(ctx, ListDeploymentsInput{ + Namespace: namespace, + LabelSelector: fmt.Sprintf("app.kubernetes.io/part-of=%s", appName), + }) + if err != nil { + return fmt.Errorf("error listing deployments: %w", err) + } + + keepSet := map[string]struct{}{} + for _, name := range keepNames { + if name != "" { + keepSet[name] = struct{}{} + } + } + + for _, deployment := range deployments { + live := deployment.Spec.Template.Spec.ImagePullSecrets + if needsImagePullSecretsPrune(live, keepSet) { + common.LogVerboseQuiet(fmt.Sprintf("Pruning stale imagePullSecrets entries from deployment %s", deployment.Name)) + err := clientset.SetDeploymentImagePullSecrets(ctx, SetDeploymentImagePullSecretsInput{ + Name: deployment.Name, + Namespace: namespace, + ImagePullSecrets: keepNames, + }) + if err != nil { + return fmt.Errorf("error pruning deployment %s: %w", deployment.Name, err) + } + } + } + + return nil +} + +// needsImagePullSecretsPrune reports whether the live imagePullSecrets list differs from the +// desired keep-set (either contains entries not in the keep-set or is missing entries in it). +func needsImagePullSecretsPrune(live []corev1.LocalObjectReference, keepSet map[string]struct{}) bool { + if len(live) != len(keepSet) { + return true + } + + seen := map[string]struct{}{} + for _, ref := range live { + if _, ok := keepSet[ref.Name]; !ok { + return true + } + seen[ref.Name] = struct{}{} + } + + return len(seen) != len(keepSet) +} + func getGlobalIngressClass() string { return common.PropertyGetDefault("scheduler-k3s", "--global", "ingress-class", DefaultIngressClass) } @@ -1687,17 +1736,57 @@ func installHelmCharts(ctx context.Context, clientset KubernetesClient, shouldIn return fmt.Errorf("Error parsing deploy timeout duration: %w", err) } - err = helmAgent.InstallOrUpgradeChart(ctx, ChartInput{ - ChartPath: chart.ChartPath, - Namespace: chart.Namespace, - ReleaseName: chart.ReleaseName, - RepoURL: chart.RepoURL, - Values: values, - Version: chart.Version, - Timeout: timeoutDuration, - Wait: true, - }) + installedRevision, err := helmAgent.InstalledRevision(chart.ReleaseName) if err != nil { + return fmt.Errorf("Error getting installed revision for %s: %w", chart.ReleaseName, err) + } + + hooks, err := selectChartHooks(chart.ReleaseName, installedRevision.Version, chart.Version) + if err != nil { + return fmt.Errorf("Error selecting upgrade hooks for chart %s: %w", chart.ReleaseName, err) + } + + buildChartInput := func(version string) ChartInput { + return ChartInput{ + ChartPath: chart.ChartPath, + Namespace: chart.Namespace, + ReleaseName: chart.ReleaseName, + RepoURL: chart.RepoURL, + Values: values, + Version: version, + Timeout: timeoutDuration, + Wait: true, + } + } + + lastUpgradedVersion := "" + for _, hook := range hooks { + if hook.PreUpgrade != nil { + common.LogVerbose(fmt.Sprintf("Running pre-upgrade hook %s@%s", chart.ReleaseName, hook.TargetVersion)) + if err := hook.PreUpgrade(ctx, clientset, chart, installedRevision); err != nil { + return fmt.Errorf("Error running pre-upgrade hook for %s@%s: %w", chart.ReleaseName, hook.TargetVersion, err) + } + } + + common.LogVerbose(fmt.Sprintf("Upgrading %s to intermediate version %s", chart.ReleaseName, hook.TargetVersion)) + if err := helmAgent.InstallOrUpgradeChart(ctx, buildChartInput(hook.TargetVersion)); err != nil { + return fmt.Errorf("Error installing chart %s at version %s: %w", chart.ChartPath, hook.TargetVersion, err) + } + lastUpgradedVersion = hook.TargetVersion + + if hook.PostUpgrade != nil { + common.LogVerbose(fmt.Sprintf("Running post-upgrade hook %s@%s", chart.ReleaseName, hook.TargetVersion)) + if err := hook.PostUpgrade(ctx, clientset, chart, installedRevision); err != nil { + return fmt.Errorf("Error running post-upgrade hook for %s@%s: %w", chart.ReleaseName, hook.TargetVersion, err) + } + } + } + + if lastUpgradedVersion == chart.Version { + continue + } + + if err := helmAgent.InstallOrUpgradeChart(ctx, buildChartInput(chart.Version)); err != nil { return fmt.Errorf("Error installing chart %s: %w", chart.ChartPath, err) } } diff --git a/plugins/scheduler-k3s/functions_test.go b/plugins/scheduler-k3s/functions_test.go new file mode 100644 index 000000000..10bf58d6c --- /dev/null +++ b/plugins/scheduler-k3s/functions_test.go @@ -0,0 +1,93 @@ +package scheduler_k3s + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func TestNeedsImagePullSecretsPrune(t *testing.T) { + cases := []struct { + name string + live []corev1.LocalObjectReference + keepSet map[string]struct{} + want bool + }{ + { + name: "empty live, empty keep", + live: []corev1.LocalObjectReference{}, + keepSet: map[string]struct{}{}, + want: false, + }, + { + name: "matches keep set exactly", + live: []corev1.LocalObjectReference{{Name: "pull-secret-foo"}}, + keepSet: map[string]struct{}{ + "pull-secret-foo": {}, + }, + want: false, + }, + { + name: "leaked entries beyond keep set", + live: []corev1.LocalObjectReference{ + {Name: "ims-foo.111"}, + {Name: "ims-foo.222"}, + {Name: "pull-secret-foo"}, + }, + keepSet: map[string]struct{}{ + "pull-secret-foo": {}, + }, + want: true, + }, + { + name: "live missing the keep entry", + live: []corev1.LocalObjectReference{ + {Name: "ims-foo.111"}, + }, + keepSet: map[string]struct{}{ + "pull-secret-foo": {}, + }, + want: true, + }, + { + name: "live populated, keep set empty", + live: []corev1.LocalObjectReference{{Name: "ims-foo.111"}}, + keepSet: map[string]struct{}{}, + want: true, + }, + { + name: "empty live, keep set populated", + live: []corev1.LocalObjectReference{}, + keepSet: map[string]struct{}{"pull-secret-foo": {}}, + want: true, + }, + { + name: "user override only, no leaked entries", + live: []corev1.LocalObjectReference{{Name: "my-custom-secret"}}, + keepSet: map[string]struct{}{ + "my-custom-secret": {}, + }, + want: false, + }, + { + name: "user override with leaked dokku-managed entries", + live: []corev1.LocalObjectReference{ + {Name: "ims-foo.111"}, + {Name: "my-custom-secret"}, + }, + keepSet: map[string]struct{}{ + "my-custom-secret": {}, + }, + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := needsImagePullSecretsPrune(tc.live, tc.keepSet) + if got != tc.want { + t.Errorf("needsImagePullSecretsPrune() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/plugins/scheduler-k3s/go.mod b/plugins/scheduler-k3s/go.mod index 142754e2a..6e9b0eb34 100644 --- a/plugins/scheduler-k3s/go.mod +++ b/plugins/scheduler-k3s/go.mod @@ -1,18 +1,19 @@ module github.com/dokku/dokku/plugins/scheduler-k3s -go 1.25.5 +go 1.26.2 require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/cert-manager/cert-manager v1.20.2 - github.com/dokku/dokku/plugins/app-json v0.0.0-20250618161309-8d0c35f1333c - github.com/dokku/dokku/plugins/common v0.0.0-20250618161309-8d0c35f1333c - github.com/dokku/dokku/plugins/config v0.0.0-20250618161309-8d0c35f1333c - github.com/dokku/dokku/plugins/cron v0.0.0-20250618161309-8d0c35f1333c - github.com/dokku/dokku/plugins/docker-options v0.0.0-20250618161309-8d0c35f1333c - github.com/dokku/dokku/plugins/logs v0.0.0-20250618161309-8d0c35f1333c - github.com/dokku/dokku/plugins/nginx-vhosts v0.0.0-20250618161309-8d0c35f1333c - github.com/dokku/dokku/plugins/registry v0.0.0-20250618161309-8d0c35f1333c + 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/dokku/dokku/plugins/config v0.0.0-00010101000000-000000000000 + github.com/dokku/dokku/plugins/cron v0.0.0-00010101000000-000000000000 + github.com/dokku/dokku/plugins/docker-options v0.0.0-00010101000000-000000000000 + github.com/dokku/dokku/plugins/logs v0.0.0-00010101000000-000000000000 + github.com/dokku/dokku/plugins/nginx-vhosts v0.0.0-00010101000000-000000000000 + github.com/dokku/dokku/plugins/registry v0.0.0-00010101000000-000000000000 + github.com/dokku/dokku/plugins/storage v0.0.0-00010101000000-000000000000 github.com/fatih/color v1.19.0 github.com/fluxcd/pkg/kustomize v1.31.0 github.com/go-openapi/jsonpointer v0.23.1 @@ -23,15 +24,15 @@ require ( github.com/kedacore/keda/v2 v2.18.3 github.com/ryanuber/columnize v2.1.2+incompatible github.com/spf13/pflag v1.0.10 - github.com/traefik/traefik/v2 v2.11.43 + github.com/traefik/traefik/v2 v2.11.44 golang.org/x/sync v0.20.0 gopkg.in/yaml.v3 v3.0.1 helm.sh/helm/v3 v3.20.2 k8s.io/api v0.35.4 - k8s.io/apimachinery v0.35.4 + k8s.io/apimachinery v0.36.0 k8s.io/client-go v0.35.4 k8s.io/kubectl v0.35.4 - k8s.io/kubernetes v1.35.4 + k8s.io/kubernetes v1.36.0 k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 mvdan.cc/sh/v3 v3.13.1 sigs.k8s.io/kustomize/api v0.21.1 @@ -68,7 +69,7 @@ require ( github.com/fastfishio/qson v1.0.2 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/go-acme/lego/v4 v4.34.0 // indirect + github.com/go-acme/lego/v4 v4.35.2 // indirect github.com/go-errors/errors v1.5.1 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect @@ -116,7 +117,6 @@ require ( github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/otiai10/copy v1.14.1 // indirect @@ -128,8 +128,8 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.17.0 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.19.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rubenv/sql-migrate v1.8.1 // indirect @@ -157,7 +157,7 @@ require ( gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/grpc v1.80.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/apiextensions-apiserver v0.35.2 // indirect @@ -165,7 +165,8 @@ require ( k8s.io/cli-runtime v0.35.4 // indirect k8s.io/component-base v0.35.4 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/streaming v0.36.0 // indirect knative.dev/pkg v0.0.0-20250708013613-d3550d4350f9 // indirect oras.land/oras-go/v2 v2.6.0 // indirect sigs.k8s.io/controller-runtime v0.23.3 // indirect @@ -193,6 +194,8 @@ replace github.com/dokku/dokku/plugins/nginx-vhosts => ../nginx-vhosts replace github.com/dokku/dokku/plugins/registry => ../registry +replace github.com/dokku/dokku/plugins/storage => ../storage + replace github.com/joho/godotenv => github.com/joho/godotenv v1.2.0 replace sigs.k8s.io/controller-runtime => sigs.k8s.io/controller-runtime v0.22.4 diff --git a/plugins/scheduler-k3s/go.sum b/plugins/scheduler-k3s/go.sum index 4c79608a2..6721d3746 100644 --- a/plugins/scheduler-k3s/go.sum +++ b/plugins/scheduler-k3s/go.sum @@ -50,8 +50,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v1.0.0-rc.1 h1:83KIq4yy1erSRgOVHNk1HYdPvzdJ5CnsWaRoJX4C41E= github.com/containerd/platforms v1.0.0-rc.1/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= @@ -101,8 +101,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/go-acme/lego/v4 v4.34.0 h1:oRsIuPJ4ORX7ufviXvelUpBSez2XxeKGwo5pNG9BVeY= -github.com/go-acme/lego/v4 v4.34.0/go.mod h1:gsmdlx/ZS6OUeXbOj0U+VnCLLfEFj4WCYRkcGpZw+pc= +github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU= +github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= @@ -149,8 +149,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= @@ -167,8 +167,8 @@ github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -249,13 +249,11 @@ github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9 github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -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/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= +github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= +github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -283,10 +281,10 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= -github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= @@ -336,8 +334,8 @@ github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a h1:a6TNDN9CgG+cYj github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= github.com/traefik/paerser v0.2.2 h1:cpzW/ZrQrBh3mdwD/jnp6aXASiUFKOVr6ldP+keJTcQ= github.com/traefik/paerser v0.2.2/go.mod h1:7BBDd4FANoVgaTZG+yh26jI6CA2nds7D/4VTEdIsh24= -github.com/traefik/traefik/v2 v2.11.43 h1:FbariGiZThFNmnuQOZhCYZpP9MNPRM9+Z/tDUWriNK0= -github.com/traefik/traefik/v2 v2.11.43/go.mod h1:3jrf93FXaxsoWtckf+flLcV8bXAAAjZm297fqJ299fo= +github.com/traefik/traefik/v2 v2.11.44 h1:e4PrCt0KTFL7R3oBlkJEKQ2ZnzZUkuCGg7m3KVxk6io= +github.com/traefik/traefik/v2 v2.11.44/go.mod h1:Jklle0KFKCNYXZCeIzvjXo9TmcL7f/K8V1OoPp/0dls= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= @@ -360,10 +358,10 @@ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 h1:zG8 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0/go.mod h1:hOfBCz8kv/wuq73Mx2H2QnWokh/kHZxkh6SNF2bdKtw= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.37.0 h1:9PgnL3QNlj10uGxExowIDIZu66aVBwWhXmbOp1pa6RA= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.37.0/go.mod h1:0ineDcLELf6JmKfuo0wvvhAVMuxWFYvkTin2iV4ydPQ= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 h1:bDMKF3RUSxshZ5OjOTi8rsHGaPKsAt76FaqgvIUySLc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0/go.mod h1:dDT67G/IkA46Mr2l9Uj7HsQVwsjASyV9SjGofsiUZDA= go.opentelemetry.io/otel/exporters/prometheus v0.59.0 h1:HHf+wKS6o5++XZhS98wvILrLVgHxjA/AMjqHKes+uzo= @@ -386,8 +384,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -429,8 +427,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -449,8 +447,8 @@ k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988= k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU= k8s.io/apiextensions-apiserver v0.35.2 h1:iyStXHoJZsUXPh/nFAsjC29rjJWdSgUmG1XpApE29c0= k8s.io/apiextensions-apiserver v0.35.2/go.mod h1:OdyGvcO1FtMDWQ+rRh/Ei3b6X3g2+ZDHd0MSRGeS8rU= -k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds= -k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc= +k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ= +k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc= k8s.io/apiserver v0.35.2 h1:rb52v0CZGEL0FkhjS+I6jHflAp7fZ4MIaKcEHX7wmDk= k8s.io/apiserver v0.35.2/go.mod h1:CROJUAu0tfjZLyYgSeBsBan2T7LUJGh0ucWwTCSSk7g= k8s.io/cli-runtime v0.35.4 h1:8QRCXSDvopflFNM65Vkkdv42BljPdRSiqf6HFyI1iik= @@ -461,12 +459,14 @@ k8s.io/component-base v0.35.4 h1:6n1tNJ87johN0Hif0Fs8K2GMthsaUwMqCebUDLYyv7U= k8s.io/component-base v0.35.4/go.mod h1:qaDJgz5c1KYKla9occFmlJEfPpkuA55s90G509R+PeY= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= -k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/kubectl v0.35.4 h1:IHitney6OUeH29rBQnt6Cas6az8HpFeSAohormITNMc= k8s.io/kubectl v0.35.4/go.mod h1:CGWAaof9ae4vGDAyhnSf1bSQN/U7jiWQHLVbMbLMjRI= -k8s.io/kubernetes v1.35.4 h1:o/8dBC/pHVpYoGV4OAIytAlNPZbdYVTXJHoYvXC4qzM= -k8s.io/kubernetes v1.35.4/go.mod h1:fPfnQs8GtfrLQ+KuOcpvwQ+mV17jVcgdvPL6ZHxKp10= +k8s.io/kubernetes v1.36.0 h1:JKaAkgSzI4+ZvNWrNJg56jAkUaOiqgBaJstmi6ycyoU= +k8s.io/kubernetes v1.36.0/go.mod h1:MLdeJ3qw2CWH9BFml5GvptxQVQckz54fJOZ/WuixpFE= +k8s.io/streaming v0.36.0 h1:agnTxU+NFulUrtYzXUGKO3ndEa8jKwht1Kwn9nu9x+4= +k8s.io/streaming v0.36.0/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= knative.dev/pkg v0.0.0-20250708013613-d3550d4350f9 h1:yCiThjArNgVjRbyBGvRvYr/B6G3djvr5pXrh8MXfnsk= diff --git a/plugins/scheduler-k3s/k8s.go b/plugins/scheduler-k3s/k8s.go index 46cf92c87..205cab96c 100644 --- a/plugins/scheduler-k3s/k8s.go +++ b/plugins/scheduler-k3s/k8s.go @@ -3,6 +3,7 @@ package scheduler_k3s import ( "bufio" "context" + "encoding/json" "errors" "fmt" "io" @@ -296,6 +297,55 @@ func (k KubernetesClient) CreateNamespace(ctx context.Context, input CreateNames return *namespace, err } +// DeleteDeploymentInput contains all the information needed to delete one or more Kubernetes deployments +type DeleteDeploymentInput struct { + // Name is the Kubernetes deployment name + Name string + + // Namespace is the Kubernetes namespace + Namespace string + + // LabelSelector is the Kubernetes label selector + LabelSelector string +} + +// DeleteDeployment deletes a Kubernetes deployment by name or label selector, +// using foreground propagation so dependent ReplicaSets and Pods are removed +// before the call returns. +func (k KubernetesClient) DeleteDeployment(ctx context.Context, input DeleteDeploymentInput) error { + if input.Name == "" && input.LabelSelector == "" { + return fmt.Errorf("name or label selector is required") + } + + if input.Name != "" && input.LabelSelector != "" { + return fmt.Errorf("name and label selector cannot be used together") + } + + deleteOptions := metav1.DeleteOptions{ + PropagationPolicy: ptr.To(metav1.DeletePropagationForeground), + } + + if input.Name != "" { + return k.Client.AppsV1().Deployments(input.Namespace).Delete(ctx, input.Name, deleteOptions) + } + + deployments, err := k.ListDeployments(ctx, ListDeploymentsInput{ + Namespace: input.Namespace, + LabelSelector: input.LabelSelector, + }) + if err != nil { + return fmt.Errorf("Error listing deployments: %w", err) + } + + for _, deployment := range deployments { + if err := k.Client.AppsV1().Deployments(input.Namespace).Delete(ctx, deployment.Name, deleteOptions); err != nil { + return fmt.Errorf("Error deleting deployment %s: %w", deployment.Name, err) + } + } + + return nil +} + // DeleteIngressInput contains all the information needed to delete a Kubernetes ingress type DeleteIngressInput struct { // Name is the Kubernetes ingress name @@ -928,6 +978,64 @@ func (k KubernetesClient) ScaleDeployment(ctx context.Context, input ScaleDeploy return nil } +// SetDeploymentImagePullSecretsInput contains all the information needed to overwrite a Deployment's imagePullSecrets list +type SetDeploymentImagePullSecretsInput struct { + // Name is the Kubernetes deployment name + Name string + + // Namespace is the Kubernetes namespace + Namespace string + + // ImagePullSecrets is the desired list of imagePullSecret names. May be empty. + ImagePullSecrets []string +} + +// SetDeploymentImagePullSecrets overwrites the deployment pod template's imagePullSecrets list with exactly +// the names supplied in input.ImagePullSecrets. Strategic-merge patches honor patchMergeKey semantics, which +// is precisely what causes leaked entries to accumulate in the first place. To force a hard replace we use +// a JSON merge patch on the imagePullSecrets path; JSON merge patch replaces array fields wholesale. +func (k KubernetesClient) SetDeploymentImagePullSecrets(ctx context.Context, input SetDeploymentImagePullSecretsInput) error { + type patchPodSpec struct { + ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets"` + } + type patchPodTemplate struct { + Spec patchPodSpec `json:"spec"` + } + type patchSpec struct { + Template patchPodTemplate `json:"template"` + } + type patchBody struct { + Spec patchSpec `json:"spec"` + } + + refs := make([]corev1.LocalObjectReference, 0, len(input.ImagePullSecrets)) + for _, name := range input.ImagePullSecrets { + refs = append(refs, corev1.LocalObjectReference{Name: name}) + } + + body := patchBody{ + Spec: patchSpec{ + Template: patchPodTemplate{ + Spec: patchPodSpec{ + ImagePullSecrets: refs, + }, + }, + }, + } + + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("error marshalling deployment patch: %w", err) + } + + _, err = k.Client.AppsV1().Deployments(input.Namespace).Patch(ctx, input.Name, types.MergePatchType, data, metav1.PatchOptions{}) + if err != nil { + return fmt.Errorf("error patching deployment imagePullSecrets: %w", err) + } + + return nil +} + type StreamLogsInput struct { // ContainerName is the Kubernetes container name ContainerName string diff --git a/plugins/scheduler-k3s/pull_secret.go b/plugins/scheduler-k3s/pull_secret.go new file mode 100644 index 000000000..47ccb02df --- /dev/null +++ b/plugins/scheduler-k3s/pull_secret.go @@ -0,0 +1,183 @@ +package scheduler_k3s + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "path/filepath" + + "github.com/dokku/dokku/plugins/common" +) + +// ImagePullSecretValues contains the values for the dokku-managed image pull secret helm chart +type ImagePullSecretValues struct { + Global ImagePullSecretGlobalValues `yaml:"global"` +} + +// ImagePullSecretGlobalValues contains the global values for the image pull secret chart +type ImagePullSecretGlobalValues struct { + Annotations map[string]string `yaml:"annotations,omitempty"` + AppName string `yaml:"app_name"` + Labels map[string]string `yaml:"labels,omitempty"` + Namespace string `yaml:"namespace"` + PullSecretBase64 string `yaml:"pull_secret_base64"` +} + +// GetImagePullSecretReleaseName returns the helm release name for the image pull secret +func GetImagePullSecretReleaseName(appName string) string { + return fmt.Sprintf("pull-secret-%s", appName) +} + +// GetImagePullSecretName returns the kubernetes secret name for the dokku-managed image pull secret +func GetImagePullSecretName(appName string) string { + return fmt.Sprintf("pull-secret-%s", appName) +} + +// CreateOrUpdateImagePullSecretInput contains the inputs to CreateOrUpdateImagePullSecret +type CreateOrUpdateImagePullSecretInput struct { + AppName string + DockerConfigJSON []byte + Annotations map[string]string + Labels map[string]string +} + +// CreateOrUpdateImagePullSecret creates or updates the image pull secret helm chart for an app +func CreateOrUpdateImagePullSecret(ctx context.Context, input CreateOrUpdateImagePullSecretInput) error { + appName := input.AppName + if err := isKubernetesAvailable(); err != nil { + common.LogDebug("kubernetes not available, skipping image pull secret creation") + return nil + } + + scheduler := common.PropertyGetDefault("scheduler", appName, "selected", "") + globalScheduler := common.PropertyGetDefault("scheduler", "--global", "selected", "docker-local") + if scheduler == "" { + scheduler = globalScheduler + } + if scheduler != "k3s" { + common.LogDebug("app does not use k3s scheduler, skipping image pull secret creation") + return nil + } + + chartDir, err := os.MkdirTemp("", "dokku-pull-secret-chart-") + if err != nil { + return fmt.Errorf("error creating chart directory: %w", err) + } + defer os.RemoveAll(chartDir) + + if err := os.MkdirAll(filepath.Join(chartDir, "templates"), os.FileMode(0755)); err != nil { + return fmt.Errorf("error creating chart templates directory: %w", err) + } + + namespace := getComputedNamespace(appName) + releaseName := GetImagePullSecretReleaseName(appName) + + chart := &Chart{ + ApiVersion: "v2", + AppVersion: "1.0.0", + Name: releaseName, + Icon: "https://dokku.com/assets/dokku-logo.svg", + Version: "0.0.1", + } + + err = writeYaml(WriteYamlInput{ + Object: chart, + Path: filepath.Join(chartDir, "Chart.yaml"), + }) + if err != nil { + return fmt.Errorf("error writing chart: %w", err) + } + + b, err := templates.ReadFile("templates/pull-secret-chart/templates/pull-secret.yaml") + if err != nil { + return fmt.Errorf("error reading pull-secret template: %w", err) + } + + filename := filepath.Join(chartDir, "templates", "pull-secret.yaml") + err = os.WriteFile(filename, b, os.FileMode(0644)) + if err != nil { + return fmt.Errorf("error writing pull-secret template: %w", err) + } + + if os.Getenv("DOKKU_TRACE") == "1" { + common.CatFile(filename) + } + + values := &ImagePullSecretValues{ + Global: ImagePullSecretGlobalValues{ + Annotations: input.Annotations, + AppName: appName, + Labels: input.Labels, + Namespace: namespace, + PullSecretBase64: base64.StdEncoding.EncodeToString(input.DockerConfigJSON), + }, + } + + err = writeYaml(WriteYamlInput{ + Object: values, + Path: filepath.Join(chartDir, "values.yaml"), + }) + if err != nil { + return fmt.Errorf("error writing values: %w", err) + } + + if err := createKubernetesNamespace(ctx, namespace); err != nil { + return fmt.Errorf("error creating namespace: %w", err) + } + + helmAgent, err := NewHelmAgent(namespace, DeployLogPrinter) + if err != nil { + return fmt.Errorf("error creating helm agent: %w", err) + } + + chartPath, err := filepath.Abs(chartDir) + if err != nil { + return fmt.Errorf("error getting chart path: %w", err) + } + + common.LogVerboseQuiet(fmt.Sprintf("Installing image pull secret for %s", appName)) + err = helmAgent.InstallOrUpgradeChart(ctx, ChartInput{ + ChartPath: chartPath, + Namespace: namespace, + ReleaseName: releaseName, + Wait: false, + }) + if err != nil { + return fmt.Errorf("error installing image pull secret chart: %w", err) + } + + return nil +} + +// DeleteImagePullSecret deletes the image pull secret helm chart for an app +func DeleteImagePullSecret(ctx context.Context, appName string) error { + if err := isKubernetesAvailable(); err != nil { + common.LogDebug("kubernetes not available, skipping image pull secret deletion") + return nil + } + + namespace := getComputedNamespace(appName) + releaseName := GetImagePullSecretReleaseName(appName) + + helmAgent, err := NewHelmAgent(namespace, DeployLogPrinter) + if err != nil { + return fmt.Errorf("error creating helm agent: %w", err) + } + + exists, err := helmAgent.ChartExists(releaseName) + if err != nil { + return fmt.Errorf("error checking if image pull secret chart exists: %w", err) + } + + if !exists { + return nil + } + + common.LogVerboseQuiet(fmt.Sprintf("Removing image pull secret for %s", appName)) + if err := helmAgent.UninstallChart(releaseName); err != nil { + return fmt.Errorf("error uninstalling image pull secret chart: %w", err) + } + + return nil +} diff --git a/plugins/scheduler-k3s/pull_secret_test.go b/plugins/scheduler-k3s/pull_secret_test.go new file mode 100644 index 000000000..204f72f5a --- /dev/null +++ b/plugins/scheduler-k3s/pull_secret_test.go @@ -0,0 +1,61 @@ +package scheduler_k3s + +import ( + "encoding/base64" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestGetImagePullSecretReleaseName(t *testing.T) { + got := GetImagePullSecretReleaseName("myapp") + want := "pull-secret-myapp" + if got != want { + t.Fatalf("GetImagePullSecretReleaseName(\"myapp\") = %q, want %q", got, want) + } +} + +func TestGetImagePullSecretName(t *testing.T) { + got := GetImagePullSecretName("myapp") + want := "pull-secret-myapp" + if got != want { + t.Fatalf("GetImagePullSecretName(\"myapp\") = %q, want %q", got, want) + } +} + +func TestImagePullSecretValuesEncoding(t *testing.T) { + dockerConfig := []byte(`{"auths":{"https://index.docker.io/v1/":{"auth":"dXNlcjpwYXNz"}}}`) + + values := &ImagePullSecretValues{ + Global: ImagePullSecretGlobalValues{ + AppName: "myapp", + Namespace: "default", + PullSecretBase64: base64.StdEncoding.EncodeToString(dockerConfig), + }, + } + + data, err := yaml.Marshal(values) + if err != nil { + t.Fatalf("yaml.Marshal returned error: %v", err) + } + + var roundTrip ImagePullSecretValues + if err := yaml.Unmarshal(data, &roundTrip); err != nil { + t.Fatalf("yaml.Unmarshal returned error: %v", err) + } + + if roundTrip.Global.AppName != "myapp" { + t.Errorf("AppName = %q, want \"myapp\"", roundTrip.Global.AppName) + } + if roundTrip.Global.Namespace != "default" { + t.Errorf("Namespace = %q, want \"default\"", roundTrip.Global.Namespace) + } + + decoded, err := base64.StdEncoding.DecodeString(roundTrip.Global.PullSecretBase64) + if err != nil { + t.Fatalf("base64 decode failed: %v", err) + } + if string(decoded) != string(dockerConfig) { + t.Errorf("PullSecretBase64 decoded = %q, want %q", string(decoded), string(dockerConfig)) + } +} diff --git a/plugins/scheduler-k3s/report.go b/plugins/scheduler-k3s/report.go index d1bb6d2de..4ba9a5c8d 100644 --- a/plugins/scheduler-k3s/report.go +++ b/plugins/scheduler-k3s/report.go @@ -9,38 +9,59 @@ import ( // ReportSingleApp is an internal function that displays the scheduler-k3s 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{ - "--scheduler-k3s-computed-deploy-timeout": reportComputedDeployTimeout, - "--scheduler-k3s-deploy-timeout": reportDeployTimeout, - "--scheduler-k3s-global-deploy-timeout": reportGlobalDeployTimeout, - "--scheduler-k3s-computed-image-pull-secrets": reportComputedImagePullSecrets, - "--scheduler-k3s-image-pull-secrets": reportImagePullSecrets, - "--scheduler-k3s-global-image-pull-secrets": reportGlobalImagePullSecrets, - "--scheduler-k3s-global-kubeconfig-path": reportGlobalKubeconfigPath, - "--scheduler-k3s-global-kube-context": reportGlobalKubeContext, - "--scheduler-k3s-computed-letsencrypt-server": reportComputedLetsencryptServer, - "--scheduler-k3s-letsencrypt-server": reportLetsencryptServer, - "--scheduler-k3s-global-letsencrypt-server": reportGlobalLetsencryptServer, - "--scheduler-k3s-global-ingress-class": reportGlobalIngressClass, - "--scheduler-k3s-global-letsencrypt-email-prod": reportGlobalLetsencryptEmailProd, - "--scheduler-k3s-global-letsencrypt-email-stag": reportGlobalLetsencryptEmailStag, - "--scheduler-k3s-computed-kustomize-root-path": reportComputedKustomizeRootPath, - "--scheduler-k3s-kustomize-root-path": reportKustomizeRootPath, - "--scheduler-k3s-global-kustomize-root-path": reportGlobalKustomizeRootPath, - "--scheduler-k3s-computed-namespace": reportComputedNamespace, - "--scheduler-k3s-namespace": reportNamespace, - "--scheduler-k3s-global-namespace": reportGlobalNamespace, - "--scheduler-k3s-global-network-interface": reportGlobalNetworkInterface, - "--scheduler-k3s-computed-rollback-on-failure": reportComputedRollbackOnFailure, - "--scheduler-k3s-rollback-on-failure": reportRollbackOnFailure, - "--scheduler-k3s-global-rollback-on-failure": reportGlobalRollbackOnFailure, - "--scheduler-k3s-computed-shm-size": reportComputedShmSize, - "--scheduler-k3s-global-shm-size": reportGlobalShmSize, - "--scheduler-k3s-shm-size": reportShmSize, + var flags map[string]common.ReportFunc + if appName == "--global" { + flags = map[string]common.ReportFunc{ + "--scheduler-k3s-global-deploy-timeout": reportGlobalDeployTimeout, + "--scheduler-k3s-global-image-pull-secrets": reportGlobalImagePullSecrets, + "--scheduler-k3s-global-kubeconfig-path": reportGlobalKubeconfigPath, + "--scheduler-k3s-global-kube-context": reportGlobalKubeContext, + "--scheduler-k3s-global-letsencrypt-server": reportGlobalLetsencryptServer, + "--scheduler-k3s-global-ingress-class": reportGlobalIngressClass, + "--scheduler-k3s-global-letsencrypt-email-prod": reportGlobalLetsencryptEmailProd, + "--scheduler-k3s-global-letsencrypt-email-stag": reportGlobalLetsencryptEmailStag, + "--scheduler-k3s-global-kustomize-root-path": reportGlobalKustomizeRootPath, + "--scheduler-k3s-global-namespace": reportGlobalNamespace, + "--scheduler-k3s-global-network-interface": reportGlobalNetworkInterface, + "--scheduler-k3s-global-rollback-on-failure": reportGlobalRollbackOnFailure, + "--scheduler-k3s-global-shm-size": reportGlobalShmSize, + } + } else { + flags = map[string]common.ReportFunc{ + "--scheduler-k3s-computed-deploy-timeout": reportComputedDeployTimeout, + "--scheduler-k3s-deploy-timeout": reportDeployTimeout, + "--scheduler-k3s-global-deploy-timeout": reportGlobalDeployTimeout, + "--scheduler-k3s-computed-image-pull-secrets": reportComputedImagePullSecrets, + "--scheduler-k3s-image-pull-secrets": reportImagePullSecrets, + "--scheduler-k3s-global-image-pull-secrets": reportGlobalImagePullSecrets, + "--scheduler-k3s-global-kubeconfig-path": reportGlobalKubeconfigPath, + "--scheduler-k3s-global-kube-context": reportGlobalKubeContext, + "--scheduler-k3s-computed-letsencrypt-server": reportComputedLetsencryptServer, + "--scheduler-k3s-letsencrypt-server": reportLetsencryptServer, + "--scheduler-k3s-global-letsencrypt-server": reportGlobalLetsencryptServer, + "--scheduler-k3s-global-ingress-class": reportGlobalIngressClass, + "--scheduler-k3s-global-letsencrypt-email-prod": reportGlobalLetsencryptEmailProd, + "--scheduler-k3s-global-letsencrypt-email-stag": reportGlobalLetsencryptEmailStag, + "--scheduler-k3s-computed-kustomize-root-path": reportComputedKustomizeRootPath, + "--scheduler-k3s-kustomize-root-path": reportKustomizeRootPath, + "--scheduler-k3s-global-kustomize-root-path": reportGlobalKustomizeRootPath, + "--scheduler-k3s-computed-namespace": reportComputedNamespace, + "--scheduler-k3s-namespace": reportNamespace, + "--scheduler-k3s-global-namespace": reportGlobalNamespace, + "--scheduler-k3s-global-network-interface": reportGlobalNetworkInterface, + "--scheduler-k3s-computed-rollback-on-failure": reportComputedRollbackOnFailure, + "--scheduler-k3s-rollback-on-failure": reportRollbackOnFailure, + "--scheduler-k3s-global-rollback-on-failure": reportGlobalRollbackOnFailure, + "--scheduler-k3s-computed-shm-size": reportComputedShmSize, + "--scheduler-k3s-global-shm-size": reportGlobalShmSize, + "--scheduler-k3s-shm-size": reportShmSize, + } } chartProperties, err := common.PropertyGetAllByPrefix("scheduler-k3s", "--global", "chart.") diff --git a/plugins/scheduler-k3s/scheduler_k3s.go b/plugins/scheduler-k3s/scheduler_k3s.go index 190571c5f..81dff53a7 100644 --- a/plugins/scheduler-k3s/scheduler_k3s.go +++ b/plugins/scheduler-k3s/scheduler_k3s.go @@ -116,7 +116,7 @@ var HelmCharts = []HelmChart{ Namespace: "ingress-nginx", ReleaseName: "ingress-nginx", RepoURL: "https://kubernetes.github.io/ingress-nginx", - Version: "4.10.0", + Version: "4.15.1", }, { ChartPath: "keda", @@ -124,7 +124,7 @@ var HelmCharts = []HelmChart{ Namespace: "keda", ReleaseName: "keda", RepoURL: "https://kedacore.github.io/charts", - Version: "2.16.0", + Version: "2.19.0", }, { ChartPath: "keda-add-ons-http", @@ -132,7 +132,7 @@ var HelmCharts = []HelmChart{ Namespace: "keda", ReleaseName: "keda-add-ons-http", RepoURL: "https://kedacore.github.io/charts", - Version: "0.8.0", + Version: "0.12.2", }, { ChartPath: "vector", @@ -140,7 +140,7 @@ var HelmCharts = []HelmChart{ Namespace: "vector", ReleaseName: "vector", RepoURL: "https://helm.vector.dev", - Version: "0.42.0", + Version: "0.52.0", }, } diff --git a/plugins/scheduler-k3s/src/subcommands/subcommands.go b/plugins/scheduler-k3s/src/subcommands/subcommands.go index 7a950f1ec..c5680e66d 100644 --- a/plugins/scheduler-k3s/src/subcommands/subcommands.go +++ b/plugins/scheduler-k3s/src/subcommands/subcommands.go @@ -126,11 +126,14 @@ func main() { case "report": args := flag.NewFlagSet("scheduler-k3s:report", flag.ExitOnError) format := args.String("format", "stdout", "format: [ stdout | json ]") - osArgs, infoFlag, flagErr := common.ParseReportArgs("scheduler-k3s", os.Args[2:]) + reportArgs, flagErr := common.ParseReportArgs("scheduler-k3s", os.Args[2:]) if flagErr == nil { - args.Parse(osArgs) + args.Parse(reportArgs.OSArgs) appName := args.Arg(0) - err = scheduler_k3s.CommandReport(appName, *format, infoFlag) + if reportArgs.IsGlobal { + appName = "--global" + } + err = scheduler_k3s.CommandReport(appName, *format, reportArgs.InfoFlag) } case "set": args := flag.NewFlagSet("scheduler-k3s:set", flag.ExitOnError) diff --git a/plugins/scheduler-k3s/src/triggers/triggers.go b/plugins/scheduler-k3s/src/triggers/triggers.go index d78606e72..6998f9b1d 100644 --- a/plugins/scheduler-k3s/src/triggers/triggers.go +++ b/plugins/scheduler-k3s/src/triggers/triggers.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "os" "strconv" @@ -18,6 +19,9 @@ func main() { parts := strings.Split(os.Args[0], "/") trigger := parts[len(parts)-1] podIdentifier := flag.String("container-id", "", "--container-id: A pod identifier") + storageExecInteractive := flag.Bool("interactive", false, "--interactive: stdin is open (storage:exec)") + storageExecTty := flag.Bool("tty", false, "--tty: stdin is a terminal (storage:exec)") + storageExecAsUser := flag.String("as-user", "", "--as-user: numeric uid override (storage:exec)") flag.Parse() var err error @@ -158,6 +162,36 @@ func main() { appName := flag.Arg(1) format := flag.Arg(2) err = scheduler_k3s.TriggerSchedulerRunList(scheduler, appName, format) + case "storage-create": + entryName := flag.Arg(0) + err = scheduler_k3s.TriggerStorageCreate(context.Background(), entryName) + case "storage-destroy": + entryName := flag.Arg(0) + err = scheduler_k3s.TriggerStorageDestroy(context.Background(), entryName) + case "storage-status": + entryName := flag.Arg(0) + err = scheduler_k3s.TriggerStorageStatus(context.Background(), entryName) + case "scheduler-storage-exec": + positional := flag.Args() + if len(positional) < 3 { + err = fmt.Errorf("scheduler-storage-exec requires ") + break + } + schedulerName := positional[0] + entryName := positional[1] + image := positional[2] + var cmd []string + if len(positional) > 3 { + cmd = positional[3:] + } + err = scheduler_k3s.TriggerSchedulerStorageExec(context.Background(), schedulerName, scheduler_k3s.StorageExecInput{ + EntryName: entryName, + Image: image, + Interactive: *storageExecInteractive, + Tty: *storageExecTty, + AsUser: *storageExecAsUser, + Command: cmd, + }) default: err = fmt.Errorf("Invalid plugin trigger call: %s", trigger) } diff --git a/plugins/scheduler-k3s/storage.go b/plugins/scheduler-k3s/storage.go new file mode 100644 index 000000000..9cf0e29ce --- /dev/null +++ b/plugins/scheduler-k3s/storage.go @@ -0,0 +1,313 @@ +package scheduler_k3s + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/dokku/dokku/plugins/common" + storage "github.com/dokku/dokku/plugins/storage" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// StorageChartValues are the helm chart inputs for the per-entry storage chart. +type StorageChartValues struct { + Global StorageChartGlobal `yaml:"global"` +} + +// StorageChartGlobal contains global values for the storage chart. +type StorageChartGlobal struct { + EntryName string `yaml:"entry_name"` + Namespace string `yaml:"namespace"` + Size string `yaml:"size"` + AccessMode string `yaml:"access_mode,omitempty"` + StorageClass string `yaml:"storage_class,omitempty"` + HostPath string `yaml:"host_path,omitempty"` + ReclaimPolicy string `yaml:"reclaim_policy,omitempty"` + Annotations map[string]string `yaml:"annotations,omitempty"` + Labels map[string]string `yaml:"labels,omitempty"` +} + +// GetStorageReleaseName returns the helm release name for a storage entry. +func GetStorageReleaseName(entryName string) string { + return fmt.Sprintf("storage-%s", entryName) +} + +// readEntryFromStdin decodes a storage.Entry from stdin. The storage +// plugin pipes the JSON-encoded entry to scheduler triggers. +func readEntryFromStdin() (*storage.Entry, error) { + raw, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("unable to read storage entry from stdin: %w", err) + } + if len(raw) == 0 { + return nil, fmt.Errorf("no storage entry payload received on stdin") + } + entry := &storage.Entry{} + if err := json.Unmarshal(raw, entry); err != nil { + return nil, fmt.Errorf("unable to parse storage entry payload: %w", err) + } + return entry, nil +} + +// TriggerStorageCreate provisions or updates the PVC/PV for a k3s storage +// entry. Idempotent: re-runs against the same entry are a helm upgrade. +func TriggerStorageCreate(ctx context.Context, entryName string) error { + if err := isKubernetesAvailable(); err != nil { + return fmt.Errorf("kubernetes not available: %w", err) + } + + entry, err := readEntryFromStdin() + if err != nil { + return err + } + if entry.Name != entryName { + return fmt.Errorf("entry name mismatch: stdin=%q, arg=%q", entry.Name, entryName) + } + if entry.Scheduler != storage.SchedulerK3s { + return nil + } + + if err := validateStorageClass(ctx, entry.StorageClass); err != nil { + return err + } + + chartDir, err := os.MkdirTemp("", "dokku-storage-chart-") + if err != nil { + return fmt.Errorf("error creating chart directory: %w", err) + } + defer os.RemoveAll(chartDir) + + releaseName := GetStorageReleaseName(entry.Name) + namespace := entry.Namespace + if namespace == "" { + namespace = "default" + } + + if err := os.MkdirAll(filepath.Join(chartDir, "templates"), 0755); err != nil { + return fmt.Errorf("error creating chart templates directory: %w", err) + } + + chart := &Chart{ + ApiVersion: "v2", + AppVersion: "1.0.0", + Name: releaseName, + Icon: "https://dokku.com/assets/dokku-logo.svg", + Version: "0.0.1", + } + if err := writeYaml(WriteYamlInput{Object: chart, Path: filepath.Join(chartDir, "Chart.yaml")}); err != nil { + return fmt.Errorf("error writing chart: %w", err) + } + + for _, name := range []string{"persistent-volume-claim.yaml", "persistent-volume.yaml"} { + b, err := templates.ReadFile(filepath.Join("templates", "storage-chart", "templates", name)) + if err != nil { + return fmt.Errorf("error reading %s: %w", name, err) + } + if err := os.WriteFile(filepath.Join(chartDir, "templates", name), b, 0644); err != nil { + return fmt.Errorf("error writing %s: %w", name, err) + } + } + + values := &StorageChartValues{ + Global: StorageChartGlobal{ + EntryName: entry.Name, + Namespace: namespace, + Size: entry.Size, + AccessMode: entry.AccessMode, + StorageClass: entry.StorageClass, + HostPath: entry.HostPath, + ReclaimPolicy: entry.ReclaimPolicy, + Annotations: entry.Annotations, + Labels: entry.Labels, + }, + } + if err := writeYaml(WriteYamlInput{Object: values, Path: filepath.Join(chartDir, "values.yaml")}); err != nil { + return fmt.Errorf("error writing values: %w", err) + } + + if err := createKubernetesNamespace(ctx, namespace); err != nil { + return fmt.Errorf("error creating namespace: %w", err) + } + + helmAgent, err := NewHelmAgent(namespace, DeployLogPrinter) + if err != nil { + return fmt.Errorf("error creating helm agent: %w", err) + } + + chartPath, err := filepath.Abs(chartDir) + if err != nil { + return fmt.Errorf("error getting chart path: %w", err) + } + + common.LogVerboseQuiet(fmt.Sprintf("Installing storage chart for %s", entry.Name)) + if err := helmAgent.InstallOrUpgradeChart(ctx, ChartInput{ + ChartPath: chartPath, + Namespace: namespace, + ReleaseName: releaseName, + Wait: false, + }); err != nil { + return fmt.Errorf("error installing storage chart: %w", err) + } + return nil +} + +// TriggerStorageDestroy removes the helm release backing a storage entry. +func TriggerStorageDestroy(ctx context.Context, entryName string) error { + if err := isKubernetesAvailable(); err != nil { + return fmt.Errorf("kubernetes not available: %w", err) + } + + entry, err := readEntryFromStdin() + if err != nil { + return err + } + if entry.Scheduler != storage.SchedulerK3s { + return nil + } + + namespace := entry.Namespace + if namespace == "" { + namespace = "default" + } + releaseName := GetStorageReleaseName(entryName) + + helmAgent, err := NewHelmAgent(namespace, DeployLogPrinter) + if err != nil { + return fmt.Errorf("error creating helm agent: %w", err) + } + exists, err := helmAgent.ChartExists(releaseName) + if err != nil { + return fmt.Errorf("error checking if storage chart exists: %w", err) + } + if !exists { + return nil + } + + common.LogVerboseQuiet(fmt.Sprintf("Removing storage chart for %s", entryName)) + if err := helmAgent.UninstallChart(releaseName); err != nil { + return fmt.Errorf("error uninstalling storage chart: %w", err) + } + return nil +} + +// TriggerStorageStatus prints "Bound" if the entry's PVC is bound, +// "Pending" or "Lost" otherwise. Used by the storage:wait command. +func TriggerStorageStatus(ctx context.Context, entryName string) error { + if err := isKubernetesAvailable(); err != nil { + return fmt.Errorf("kubernetes not available: %w", err) + } + entry, err := readEntryFromStdin() + if err != nil { + return err + } + namespace := entry.Namespace + if namespace == "" { + namespace = "default" + } + + clientset, err := NewKubernetesClient() + if err != nil { + return err + } + pvc, err := clientset.Client.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, entryName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("error fetching PVC %s/%s: %w", namespace, entryName, err) + } + fmt.Println(string(pvc.Status.Phase)) + return nil +} + +// validateStorageClass surfaces a clear error when the requested storage +// class doesn't exist in the cluster. +func validateStorageClass(ctx context.Context, name string) error { + if name == "" { + return nil + } + clientset, err := NewKubernetesClient() + if err != nil { + return err + } + classes, err := clientset.Client.StorageV1().StorageClasses().List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("error listing storage classes: %w", err) + } + available := []string{} + for _, cls := range classes.Items { + if cls.Name == name { + return nil + } + available = append(available, cls.Name) + } + return fmt.Errorf("storage class %q does not exist; available: %s", name, strings.Join(available, ", ")) +} + +// AppMountPair mirrors the storage plugin's wire format so we can decode +// the storage-app-mounts trigger output here. +type AppMountPair struct { + Entry *storage.Entry `json:"entry"` + Attachment *storage.Attachment `json:"attachment"` +} + +// LoadAppMounts asks the storage plugin for an app's mount pairs in the +// given phase. Returns an empty slice when there are none. +func LoadAppMounts(appName string, phase string) ([]AppMountPair, error) { + results, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ + Trigger: "storage-app-mounts", + Args: []string{appName, phase}, + }) + if err != nil { + return nil, err + } + output := strings.TrimSpace(results.StdoutContents()) + if output == "" { + return nil, nil + } + pairs := []AppMountPair{} + if err := json.Unmarshal([]byte(output), &pairs); err != nil { + return nil, fmt.Errorf("unable to parse storage-app-mounts output: %w", err) + } + return pairs, nil +} + +// ToProcessVolumes converts each AppMountPair into a ProcessVolume. K3s +// app deployments reference the PVC by name; the PVC itself is owned by +// the storage entry's separate helm release. Any docker-local entries +// found here are an error - they cannot be mounted on a k3s app. +func ToProcessVolumes(pairs []AppMountPair) ([]ProcessVolume, error) { + volumes := []ProcessVolume{} + for _, pair := range pairs { + if pair.Entry == nil || pair.Attachment == nil { + continue + } + if pair.Entry.Scheduler == storage.SchedulerDockerLocal { + return nil, fmt.Errorf("storage entry %q is scheduler=docker-local but is mounted on a k3s app; recreate it with --scheduler k3s", pair.Entry.Name) + } + volumes = append(volumes, ProcessVolume{ + Name: pair.Entry.Name, + MountPath: pair.Attachment.ContainerPath, + SubPath: pair.Attachment.Subpath, + ReadOnly: pair.Attachment.Readonly, + PersistentClaim: &ProcessVolumePersistentClaim{ + ClaimName: pair.Entry.Name, + }, + }) + } + return volumes, nil +} + +// asK8sVolumeMount is a small helper used by tests / other callers that +// want a corev1.VolumeMount from a ProcessVolume. +func asK8sVolumeMount(v ProcessVolume) corev1.VolumeMount { + return corev1.VolumeMount{ + Name: v.Name, + MountPath: v.MountPath, + SubPath: v.SubPath, + ReadOnly: v.ReadOnly, + } +} diff --git a/plugins/scheduler-k3s/storage_exec.go b/plugins/scheduler-k3s/storage_exec.go new file mode 100644 index 000000000..a7193cde1 --- /dev/null +++ b/plugins/scheduler-k3s/storage_exec.go @@ -0,0 +1,236 @@ +package scheduler_k3s + +import ( + "context" + "errors" + "fmt" + "os" + "strconv" + "time" + + "github.com/dokku/dokku/plugins/common" + "github.com/dokku/dokku/plugins/storage" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + utilexec "k8s.io/client-go/util/exec" +) + +// StorageExecInput captures the inputs forwarded by the storage plugin +// over the scheduler-storage-exec trigger. +type StorageExecInput struct { + EntryName string + Image string + Interactive bool + Tty bool + AsUser string + Command []string +} + +// TriggerSchedulerStorageExec runs an interactive or non-interactive +// command against a k3s storage entry by creating a throwaway Pod that +// mounts the PVC at /data, exec-ing the user's command via the +// kubernetes API, and deleting the Pod afterwards. Errors at every +// stage (PVC missing, image pull failure, etc.) come through the +// structured Kubernetes API objects rather than kubectl-shaped strings. +func TriggerSchedulerStorageExec(ctx context.Context, scheduler string, input StorageExecInput) error { + if scheduler != storage.SchedulerK3s { + return nil + } + if !storage.EntryExists(input.EntryName) { + return fmt.Errorf("storage entry %q does not exist", input.EntryName) + } + entry, err := storage.LoadEntry(input.EntryName) + if err != nil { + return err + } + if entry.Scheduler != storage.SchedulerK3s { + return fmt.Errorf("storage entry %q has scheduler %q, not k3s", entry.Name, entry.Scheduler) + } + + if err := isKubernetesAvailable(); err != nil { + return fmt.Errorf("kubernetes not available: %w", err) + } + + clientset, err := NewKubernetesClient() + if err != nil { + return err + } + + namespace := entry.Namespace + if namespace == "" { + namespace = "default" + } + + if _, err := clientset.Client.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, entry.Name, metav1.GetOptions{}); err != nil { + if apierrors.IsNotFound(err) { + return fmt.Errorf("PVC %s not found in namespace %s", entry.Name, namespace) + } + return fmt.Errorf("error reading PVC %s in namespace %s: %w", entry.Name, namespace, err) + } + + podName := fmt.Sprintf("dokku-storage-exec-%s-%d", entry.Name, time.Now().UnixNano()/int64(time.Millisecond)%1000000) + pod, err := buildStoragePodSpec(podName, namespace, entry, input) + if err != nil { + return err + } + + common.LogVerboseQuiet(fmt.Sprintf("Creating exec pod %s/%s", namespace, podName)) + if _, err := clientset.Client.CoreV1().Pods(namespace).Create(ctx, pod, metav1.CreateOptions{}); err != nil { + return fmt.Errorf("error creating exec pod %s/%s: %w", namespace, podName, err) + } + + defer func() { + bg := context.Background() + gracePeriod := int64(0) + _ = clientset.Client.CoreV1().Pods(namespace).Delete(bg, podName, metav1.DeleteOptions{GracePeriodSeconds: &gracePeriod}) + }() + + if err := waitForPodRunning(ctx, clientset, namespace, podName, 60*time.Second); err != nil { + return err + } + + command := input.Command + if len(command) == 0 { + command = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"} + } + + execErr := clientset.ExecCommand(ctx, ExecCommandInput{ + Name: podName, + Namespace: namespace, + ContainerName: "exec", + Command: command, + }) + if execErr == nil { + return nil + } + + var coded utilexec.CodeExitError + if errors.As(execErr, &coded) { + os.Exit(coded.Code) + } + return execErr +} + +// buildStoragePodSpec assembles the throwaway Pod that mounts the PVC +// for storage:exec. Split out so the unit test can verify the spec +// without hitting a cluster. +func buildStoragePodSpec(name string, namespace string, entry *storage.Entry, input StorageExecInput) (*corev1.Pod, error) { + uid, err := resolveStorageExecUID(entry.Chown, input.AsUser) + if err != nil { + return nil, err + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "dokku", + "dokku.com/storage-entry": entry.Name, + "dokku.com/purpose": "storage-exec", + }, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Volumes: []corev1.Volume{ + { + Name: "data", + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: entry.Name, + }, + }, + }, + }, + Containers: []corev1.Container{ + { + Name: "exec", + Image: input.Image, + Command: []string{"sleep", "infinity"}, + Stdin: input.Interactive, + TTY: input.Tty, + VolumeMounts: []corev1.VolumeMount{ + {Name: "data", MountPath: "/data"}, + }, + }, + }, + }, + } + + if uid != nil { + pod.Spec.SecurityContext = &corev1.PodSecurityContext{ + RunAsUser: uid, + RunAsGroup: uid, + FSGroup: uid, + } + } + + return pod, nil +} + +// resolveStorageExecUID picks the UID to run the exec Pod as. Returns +// nil to mean "leave the image's default user in place". +func resolveStorageExecUID(chown string, asUser string) (*int64, error) { + if asUser != "" { + n, err := strconv.ParseInt(asUser, 10, 64) + if err != nil { + return nil, fmt.Errorf("--as-user must be a numeric uid, got %q", asUser) + } + return &n, nil + } + if chown == "" || chown == "false" { + return nil, nil + } + uidStr, err := storage.ResolveChownID(chown) + if err != nil { + return nil, err + } + if uidStr == "" || uidStr == "false" { + return nil, nil + } + n, err := strconv.ParseInt(uidStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("could not parse resolved chown UID %q: %w", uidStr, err) + } + return &n, nil +} + +// waitForPodRunning polls the Pod until its phase is Running, surfacing +// the structured Kubernetes API state on failure (image pull failures, +// scheduling problems, etc.) rather than a generic timeout. +func waitForPodRunning(ctx context.Context, clientset KubernetesClient, namespace, name string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + pod, err := clientset.Client.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("error reading exec pod %s/%s: %w", namespace, name, err) + } + switch pod.Status.Phase { + case corev1.PodRunning: + return nil + case corev1.PodFailed, corev1.PodSucceeded: + return fmt.Errorf("exec pod %s/%s reached terminal phase %s before exec could attach", namespace, name, pod.Status.Phase) + } + + for _, cs := range pod.Status.ContainerStatuses { + if cs.State.Waiting == nil { + continue + } + reason := cs.State.Waiting.Reason + switch reason { + case "ImagePullBackOff", "ErrImagePull", "CreateContainerConfigError", "InvalidImageName": + return fmt.Errorf("exec pod %s/%s container %q failed to start: %s: %s", namespace, name, cs.Name, reason, cs.State.Waiting.Message) + } + } + + if time.Now().After(deadline) { + return fmt.Errorf("exec pod %s/%s did not reach Running within %s (current phase: %s)", namespace, name, timeout, pod.Status.Phase) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(500 * time.Millisecond): + } + } +} diff --git a/plugins/scheduler-k3s/storage_exec_test.go b/plugins/scheduler-k3s/storage_exec_test.go new file mode 100644 index 000000000..7bc3b0cf6 --- /dev/null +++ b/plugins/scheduler-k3s/storage_exec_test.go @@ -0,0 +1,103 @@ +package scheduler_k3s + +import ( + "testing" + + "github.com/dokku/dokku/plugins/storage" +) + +func TestBuildStoragePodSpec(t *testing.T) { + entry := &storage.Entry{ + Name: "demo-data", + Scheduler: storage.SchedulerK3s, + Namespace: "dokku", + } + pod, err := buildStoragePodSpec("dokku-storage-exec-demo-data-1", "dokku", entry, StorageExecInput{ + Image: "alpine:3", + Interactive: true, + Tty: true, + Command: []string{"sh"}, + }) + if err != nil { + t.Fatalf("buildStoragePodSpec err: %v", err) + } + + if pod.Spec.RestartPolicy != "Never" { + t.Fatalf("expected RestartPolicy=Never, got %s", pod.Spec.RestartPolicy) + } + if len(pod.Spec.Volumes) != 1 || pod.Spec.Volumes[0].PersistentVolumeClaim == nil { + t.Fatalf("expected one PVC volume, got %+v", pod.Spec.Volumes) + } + if pod.Spec.Volumes[0].PersistentVolumeClaim.ClaimName != "demo-data" { + t.Fatalf("expected PVC claim name demo-data, got %s", pod.Spec.Volumes[0].PersistentVolumeClaim.ClaimName) + } + if len(pod.Spec.Containers) != 1 { + t.Fatalf("expected one container, got %d", len(pod.Spec.Containers)) + } + c := pod.Spec.Containers[0] + if c.Image != "alpine:3" { + t.Fatalf("expected image alpine:3, got %s", c.Image) + } + if !c.Stdin || !c.TTY { + t.Fatalf("expected stdin and tty true on the container, got stdin=%v tty=%v", c.Stdin, c.TTY) + } + if len(c.VolumeMounts) != 1 || c.VolumeMounts[0].MountPath != "/data" { + t.Fatalf("expected one volume mount at /data, got %+v", c.VolumeMounts) + } +} + +func TestBuildStoragePodSpecChownToSecurityContext(t *testing.T) { + entry := &storage.Entry{ + Name: "demo-data", + Scheduler: storage.SchedulerK3s, + Chown: "herokuish", + } + pod, err := buildStoragePodSpec("p", "default", entry, StorageExecInput{Image: "alpine:3"}) + if err != nil { + t.Fatalf("err: %v", err) + } + if pod.Spec.SecurityContext == nil || pod.Spec.SecurityContext.RunAsUser == nil { + t.Fatalf("expected SecurityContext.RunAsUser to be populated for chown=herokuish") + } + // Don't assert exact UID because ResolveChownID consults docker for + // userns offset; only assert non-nil and non-zero. + if *pod.Spec.SecurityContext.RunAsUser == 0 { + t.Fatalf("expected non-zero UID for chown=herokuish, got 0") + } +} + +func TestBuildStoragePodSpecAsUserOverride(t *testing.T) { + entry := &storage.Entry{ + Name: "demo-data", + Scheduler: storage.SchedulerK3s, + Chown: "herokuish", + } + pod, err := buildStoragePodSpec("p", "default", entry, StorageExecInput{ + Image: "alpine:3", + AsUser: "0", + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if pod.Spec.SecurityContext == nil || pod.Spec.SecurityContext.RunAsUser == nil { + t.Fatalf("expected RunAsUser to be set") + } + if *pod.Spec.SecurityContext.RunAsUser != 0 { + t.Fatalf("expected RunAsUser to be 0 (override), got %d", *pod.Spec.SecurityContext.RunAsUser) + } +} + +func TestBuildStoragePodSpecChownFalseLeavesDefaultUser(t *testing.T) { + entry := &storage.Entry{ + Name: "demo-data", + Scheduler: storage.SchedulerK3s, + Chown: "false", + } + pod, err := buildStoragePodSpec("p", "default", entry, StorageExecInput{Image: "alpine:3"}) + if err != nil { + t.Fatalf("err: %v", err) + } + if pod.Spec.SecurityContext != nil { + t.Fatalf("expected no SecurityContext when chown=false, got %+v", pod.Spec.SecurityContext) + } +} diff --git a/plugins/scheduler-k3s/template.go b/plugins/scheduler-k3s/template.go index b850df321..9d2b2e63a 100644 --- a/plugins/scheduler-k3s/template.go +++ b/plugins/scheduler-k3s/template.go @@ -50,14 +50,12 @@ type GlobalValues struct { Keda GlobalKedaValues `yaml:"keda"` Namespace string `yaml:"namespace"` Network GlobalNetwork `yaml:"network"` - Secrets map[string]string `yaml:"secrets,omitempty"` SecurityContext SecurityContext `yaml:"security_context,omitempty"` } type GlobalImage struct { ImagePullSecrets string `yaml:"image_pull_secrets"` Name string `yaml:"name"` - PullSecretBase64 string `yaml:"pull_secret_base64"` Type string `yaml:"type"` WorkingDir string `yaml:"working_dir"` } @@ -119,9 +117,12 @@ type ProcessValues struct { } type ProcessVolume struct { - Name string `yaml:"name"` - MountPath string `yaml:"mount_path"` - EmptyDir *ProcessVolumeEmptyDir `yaml:"empty_dir,omitempty"` + Name string `yaml:"name"` + MountPath string `yaml:"mount_path"` + SubPath string `yaml:"sub_path,omitempty"` + ReadOnly bool `yaml:"read_only,omitempty"` + EmptyDir *ProcessVolumeEmptyDir `yaml:"empty_dir,omitempty"` + PersistentClaim *ProcessVolumePersistentClaim `yaml:"persistent_claim,omitempty"` } type ProcessVolumeEmptyDir struct { @@ -129,6 +130,10 @@ type ProcessVolumeEmptyDir struct { SizeLimit string `yaml:"size_limit"` } +type ProcessVolumePersistentClaim struct { + ClaimName string `yaml:"claim_name"` +} + type ProcessAnnotations struct { CertificateAnnotations map[string]string `yaml:"certificate,omitempty"` CronJobAnnotations map[string]string `yaml:"cronjob,omitempty"` @@ -425,7 +430,7 @@ func templateKubernetesJob(input Job) (batchv1.Job, error) { } maps.Copy(labels, input.Labels) - secretName := fmt.Sprintf("env-%s.%d", input.AppName, input.DeploymentID) + secretName := GetConfigSecretName(input.AppName) env := []corev1.EnvVar{} for key, value := range input.Env { diff --git a/plugins/scheduler-k3s/templates/chart/cron-job.yaml b/plugins/scheduler-k3s/templates/chart/cron-job.yaml index 4771674eb..e8cb4675a 100644 --- a/plugins/scheduler-k3s/templates/chart/cron-job.yaml +++ b/plugins/scheduler-k3s/templates/chart/cron-job.yaml @@ -77,7 +77,7 @@ spec: {{- end }} envFrom: - secretRef: - name: env-{{ $.Values.global.app_name }}.{{ $.Values.global.deployment_id }} + name: config-{{ $.Values.global.app_name }} optional: true image: {{ $.Values.global.image.name }} imagePullPolicy: Always @@ -127,12 +127,44 @@ spec: {{- if $.Values.global.image.working_dir }} workingDir: {{ $.Values.global.image.working_dir }} {{- end }} + {{- if $config.volumes }} + volumeMounts: + {{- range $volume := $config.volumes }} + - name: {{ $volume.name }} + mountPath: {{ $volume.mount_path }} + {{- if $volume.sub_path }} + subPath: {{ $volume.sub_path }} + {{- end }} + {{- if $volume.read_only }} + readOnly: true + {{- end }} + {{- end }} + {{- end }} {{- if $.Values.global.image.image_pull_secrets }} imagePullSecrets: - name: {{ $.Values.global.image.image_pull_secrets }} {{- end }} restartPolicy: Never serviceAccountName: {{ $.Values.global.app_name }} + {{- if $config.volumes }} + volumes: + {{- range $volume := $config.volumes }} + - name: {{ $volume.name }} + {{- if $volume.empty_dir }} + emptyDir: + {{- if $volume.empty_dir.medium }} + medium: {{ $volume.empty_dir.medium }} + {{- end }} + {{- if $volume.empty_dir.size_limit }} + sizeLimit: {{ $volume.empty_dir.size_limit }} + {{- end }} + {{- end }} + {{- if $volume.persistent_claim }} + persistentVolumeClaim: + claimName: {{ $volume.persistent_claim.claim_name }} + {{- end }} + {{- end }} + {{- end }} schedule: {{ $config.cron.schedule }} startingDeadlineSeconds: 60 successfulJobsHistoryLimit: 10 diff --git a/plugins/scheduler-k3s/templates/chart/deployment.yaml b/plugins/scheduler-k3s/templates/chart/deployment.yaml index da68f0f26..bee80b421 100644 --- a/plugins/scheduler-k3s/templates/chart/deployment.yaml +++ b/plugins/scheduler-k3s/templates/chart/deployment.yaml @@ -74,7 +74,7 @@ spec: {{- end }} envFrom: - secretRef: - name: env-{{ $.Values.global.app_name }}.{{ $.Values.global.deployment_id }} + name: config-{{ $.Values.global.app_name }} optional: true image: {{ $.Values.global.image.name }} imagePullPolicy: Always @@ -152,6 +152,12 @@ spec: {{- range $volume := $config.volumes }} - name: {{ $volume.name }} mountPath: {{ $volume.mount_path }} + {{- if $volume.sub_path }} + subPath: {{ $volume.sub_path }} + {{- end }} + {{- if $volume.read_only }} + readOnly: true + {{- end }} {{- end }} {{- end }} {{- if $.Values.global.image.image_pull_secrets }} @@ -172,6 +178,10 @@ spec: sizeLimit: {{ $volume.empty_dir.size_limit }} {{- end }} {{- end }} + {{- if $volume.persistent_claim }} + persistentVolumeClaim: + claimName: {{ $volume.persistent_claim.claim_name }} + {{- end }} {{- end }} {{- end }} {{- end }} diff --git a/plugins/scheduler-k3s/templates/chart/image-pull-secret.yaml b/plugins/scheduler-k3s/templates/chart/image-pull-secret.yaml deleted file mode 100644 index 75d0f83e1..000000000 --- a/plugins/scheduler-k3s/templates/chart/image-pull-secret.yaml +++ /dev/null @@ -1,20 +0,0 @@ -{{- if $.Values.global.image.pull_secret_base64 }} ---- -apiVersion: v1 -kind: Secret -metadata: - annotations: - app.kubernetes.io/version: {{ $.Values.global.deployment_id | quote }} - dokku.com/managed: "true" - {{ include "print.annotations" (dict "config" $.Values.global "key" "secret") | indent 4 }} - labels: - app.kubernetes.io/instance: ims-{{ $.Values.global.app_name }}.{{ $.Values.global.deployment_id }} - app.kubernetes.io/name: ims-{{ $.Values.global.app_name }} - app.kubernetes.io/part-of: {{ $.Values.global.app_name }} - {{ include "print.labels" (dict "config" $.Values.global "key" "secret") | indent 4 }} - name: ims-{{ $.Values.global.app_name }}.{{ $.Values.global.deployment_id }} - namespace: {{ $.Values.global.namespace }} -data: - .dockerconfigjson: "{{ $.Values.global.image.pull_secret_base64 }}" -type: kubernetes.io/dockerconfigjson -{{- end }} diff --git a/plugins/scheduler-k3s/templates/chart/keda-scaled-object.yaml b/plugins/scheduler-k3s/templates/chart/keda-scaled-object.yaml index ac0f61f23..221f054e1 100644 --- a/plugins/scheduler-k3s/templates/chart/keda-scaled-object.yaml +++ b/plugins/scheduler-k3s/templates/chart/keda-scaled-object.yaml @@ -33,9 +33,17 @@ spec: pollingInterval: {{ $config.autoscaling.polling_interval_seconds }} minReplicaCount: {{ $config.autoscaling.min_replicas }} maxReplicaCount: {{ $config.autoscaling.max_replicas }} + {{- $hasFallbackTrigger := false }} + {{- range $config.autoscaling.triggers }} + {{- if and (ne .type "cpu") (ne .type "memory") }} + {{- $hasFallbackTrigger = true }} + {{- end }} + {{- end }} + {{- if $hasFallbackTrigger }} fallback: failureThreshold: 3 replicas: {{ $config.replicas }} + {{- end }} {{- if $config.autoscaling.triggers }} triggers: {{- with $config.autoscaling.triggers }} diff --git a/plugins/scheduler-k3s/templates/chart/secret.yaml b/plugins/scheduler-k3s/templates/chart/secret.yaml deleted file mode 100644 index 7a349f6fd..000000000 --- a/plugins/scheduler-k3s/templates/chart/secret.yaml +++ /dev/null @@ -1,19 +0,0 @@ ---- -apiVersion: v1 -kind: Secret -metadata: - annotations: - app.kubernetes.io/version: {{ $.Values.global.deployment_id | quote }} - dokku.com/managed: "true" - {{ include "print.annotations" (dict "config" $.Values.global "key" "secret") | indent 4 }} - labels: - app.kubernetes.io/instance: env-{{ $.Values.global.app_name }}.{{ $.Values.global.deployment_id }} - app.kubernetes.io/name: env-{{ $.Values.global.app_name }} - app.kubernetes.io/part-of: {{ $.Values.global.app_name }} - {{ include "print.labels" (dict "config" $.Values.global "key" "secret") | indent 4 }} - name: env-{{ $.Values.global.app_name }}.{{ $.Values.global.deployment_id }} - namespace: {{ $.Values.global.namespace }} -{{- with .Values.global.secrets }} -data: - {{- toYaml . | nindent 2 }} -{{- end }} diff --git a/plugins/scheduler-k3s/templates/config-secret-chart/templates/config-secret.yaml b/plugins/scheduler-k3s/templates/config-secret-chart/templates/config-secret.yaml new file mode 100644 index 000000000..cbb2ed52f --- /dev/null +++ b/plugins/scheduler-k3s/templates/config-secret-chart/templates/config-secret.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Secret +metadata: + name: config-{{ .Values.global.app_name }} + namespace: {{ .Values.global.namespace }} + labels: + app.kubernetes.io/name: config-{{ .Values.global.app_name }} + app.kubernetes.io/part-of: {{ .Values.global.app_name }} + {{- range $k, $v := .Values.global.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + annotations: + dokku.com/managed: "true" + {{- range $k, $v := .Values.global.annotations }} + {{ $k }}: {{ $v | quote }} + {{- end }} +{{- with .Values.global.secrets }} +data: + {{- toYaml . | nindent 2 }} +{{- end }} diff --git a/plugins/scheduler-k3s/templates/helm-config/vector.yaml b/plugins/scheduler-k3s/templates/helm-config/vector.yaml index e20ccf829..a6460eeec 100644 --- a/plugins/scheduler-k3s/templates/helm-config/vector.yaml +++ b/plugins/scheduler-k3s/templates/helm-config/vector.yaml @@ -5,32 +5,52 @@ tolerations: - effect: NoSchedule key: node-role.kubernetes.io/master operator: Exists + - effect: NoSchedule + key: node-role.kubernetes.io/control-plane + operator: Exists - effect: NoSchedule key: node-role.kubernetes.io/infra operator: Exists service: ports: - - name: prom-exporter - port: 9090 - protocol: TCP + - name: prom-exporter + port: 9090 + protocol: TCP + targetPort: 9090 + +livenessProbe: + httpGet: + path: /health + port: api +readinessProbe: + httpGet: + path: /health + port: api customConfig: data_dir: /vector-data-dir + api: + enabled: true + address: 0.0.0.0:8686 + playground: false + sources: kubernetes_logs: type: kubernetes_logs + internal_metrics: + type: internal_metrics + transforms: kubernetes_container_logs: type: remap inputs: - kubernetes_logs source: | - .message = .message .container = .kubernetes.container_name - .pod = .kubernetes.pod_name + .pod = .kubernetes.pod_name .namespace = .kubernetes.pod_namespace sinks: @@ -40,3 +60,9 @@ customConfig: - kubernetes_container_logs encoding: codec: json + + prom_exporter: + type: prometheus_exporter + inputs: + - internal_metrics + address: 0.0.0.0:9090 diff --git a/plugins/scheduler-k3s/templates/pull-secret-chart/templates/pull-secret.yaml b/plugins/scheduler-k3s/templates/pull-secret-chart/templates/pull-secret.yaml new file mode 100644 index 000000000..778c491b3 --- /dev/null +++ b/plugins/scheduler-k3s/templates/pull-secret-chart/templates/pull-secret.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Secret +type: kubernetes.io/dockerconfigjson +metadata: + name: pull-secret-{{ .Values.global.app_name }} + namespace: {{ .Values.global.namespace }} + labels: + app.kubernetes.io/name: pull-secret-{{ .Values.global.app_name }} + app.kubernetes.io/part-of: {{ .Values.global.app_name }} + {{- range $k, $v := .Values.global.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + annotations: + dokku.com/managed: "true" + {{- range $k, $v := .Values.global.annotations }} + {{ $k }}: {{ $v | quote }} + {{- end }} +data: + .dockerconfigjson: {{ .Values.global.pull_secret_base64 | quote }} diff --git a/plugins/scheduler-k3s/templates/storage-chart/templates/persistent-volume-claim.yaml b/plugins/scheduler-k3s/templates/storage-chart/templates/persistent-volume-claim.yaml new file mode 100644 index 000000000..34505bc96 --- /dev/null +++ b/plugins/scheduler-k3s/templates/storage-chart/templates/persistent-volume-claim.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ .Values.global.entry_name }} + namespace: {{ .Values.global.namespace }} + labels: + app.kubernetes.io/name: {{ .Values.global.entry_name }} + app.kubernetes.io/managed-by: dokku + {{- range $k, $v := .Values.global.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + annotations: + dokku.com/managed: "true" + {{- range $k, $v := .Values.global.annotations }} + {{ $k }}: {{ $v | quote }} + {{- end }} +spec: + accessModes: + - {{ .Values.global.access_mode | default "ReadWriteOnce" }} + resources: + requests: + storage: {{ .Values.global.size }} + {{- if .Values.global.storage_class }} + storageClassName: {{ .Values.global.storage_class }} + {{- end }} + {{- if .Values.global.host_path }} + volumeName: {{ .Values.global.entry_name }} + storageClassName: "" + {{- end }} diff --git a/plugins/scheduler-k3s/templates/storage-chart/templates/persistent-volume.yaml b/plugins/scheduler-k3s/templates/storage-chart/templates/persistent-volume.yaml new file mode 100644 index 000000000..92175f2d8 --- /dev/null +++ b/plugins/scheduler-k3s/templates/storage-chart/templates/persistent-volume.yaml @@ -0,0 +1,29 @@ +{{- if .Values.global.host_path }} +apiVersion: v1 +kind: PersistentVolume +metadata: + name: {{ .Values.global.entry_name }} + labels: + app.kubernetes.io/name: {{ .Values.global.entry_name }} + app.kubernetes.io/managed-by: dokku + {{- range $k, $v := .Values.global.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + annotations: + dokku.com/managed: "true" + {{- range $k, $v := .Values.global.annotations }} + {{ $k }}: {{ $v | quote }} + {{- end }} +spec: + capacity: + storage: {{ .Values.global.size }} + accessModes: + - {{ .Values.global.access_mode | default "ReadWriteOnce" }} + persistentVolumeReclaimPolicy: {{ .Values.global.reclaim_policy | default "Retain" }} + hostPath: + path: {{ .Values.global.host_path }} + storageClassName: "" + claimRef: + namespace: {{ .Values.global.namespace }} + name: {{ .Values.global.entry_name }} +{{- end }} diff --git a/plugins/scheduler-k3s/triggers.go b/plugins/scheduler-k3s/triggers.go index f95ccc2c9..ec455a304 100644 --- a/plugins/scheduler-k3s/triggers.go +++ b/plugins/scheduler-k3s/triggers.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "crypto/rand" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -170,6 +169,21 @@ func TriggerPostAppRenameSetup(oldAppName string, newAppName string) error { return err } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if _, err := DeleteTLSSecret(ctx, oldAppName); err != nil { + common.LogWarn(fmt.Sprintf("Error deleting TLS secret for old app name %s: %v", oldAppName, err)) + } + + if err := DeleteConfigSecret(ctx, oldAppName); err != nil { + common.LogWarn(fmt.Sprintf("Error deleting config secret for old app name %s: %v", oldAppName, err)) + } + + if err := DeleteImagePullSecret(ctx, oldAppName); err != nil { + common.LogWarn(fmt.Sprintf("Error deleting image pull secret for old app name %s: %v", oldAppName, err)) + } + return nil } @@ -403,8 +417,9 @@ func TriggerSchedulerDeploy(scheduler string, appName string, imageTag string) e } deploymentId := time.Now().Unix() - pullSecretBase64 := base64.StdEncoding.EncodeToString([]byte("")) imagePullSecrets := getComputedImagePullSecrets(appName) + dokkuManagedPullSecret := false + var dokkuPullSecretBytes []byte if imagePullSecrets == "" { dockerConfigPath := filepath.Join(registry.GetComputedAppRegistryConfigDir(appName), "config.json") if fi, err := os.Stat(dockerConfigPath); err == nil && !fi.IsDir() { @@ -413,12 +428,13 @@ func TriggerSchedulerDeploy(scheduler string, appName string, imageTag string) e return fmt.Errorf("Error reading docker config: %w", err) } - imagePullSecrets = fmt.Sprintf("ims-%s.%d", appName, deploymentId) - pullSecretBase64 = base64.StdEncoding.EncodeToString(b) + imagePullSecrets = GetImagePullSecretName(appName) + dokkuManagedPullSecret = true + dokkuPullSecretBytes = b } } - globalTemplateFiles := []string{"service-account", "secret", "image-pull-secret"} + globalTemplateFiles := []string{"service-account"} for _, templateName := range globalTemplateFiles { b, err := templates.ReadFile(fmt.Sprintf("templates/chart/%s.yaml", templateName)) if err != nil { @@ -541,7 +557,6 @@ func TriggerSchedulerDeploy(scheduler string, appName string, imageTag string) e Keda: kedaValues, Image: GlobalImage{ ImagePullSecrets: imagePullSecrets, - PullSecretBase64: pullSecretBase64, Name: image, Type: imageSourceType, WorkingDir: workingDir, @@ -553,7 +568,6 @@ func TriggerSchedulerDeploy(scheduler string, appName string, imageTag string) e PrimaryPort: primaryPort, PrimaryServicePort: primaryServicePort, }, - Secrets: map[string]string{}, SecurityContext: securityContext, }, Processes: map[string]ProcessValues{}, @@ -591,10 +605,19 @@ func TriggerSchedulerDeploy(scheduler string, appName string, imageTag string) e }) } + deployMountPairs, err := LoadAppMounts(appName, "deploy") + if err != nil { + return fmt.Errorf("error loading storage app mounts: %w", err) + } + deployVolumes, err := ToProcessVolumes(deployMountPairs) + if err != nil { + return err + } + processVolumes = append(processVolumes, deployVolumes...) + for processType, processCount := range processes { // todo: implement deployment annotations // todo: implement pod annotations - // todo: implement volumes healthchecks, ok := appJSON.Healthchecks[processType] if !ok { @@ -764,7 +787,6 @@ func TriggerSchedulerDeploy(scheduler string, appName string, imageTag string) e for _, cronTask := range cronTasks { // todo: implement deployment annotations // todo: implement pod annotations - // todo: implement volumes suffix := "" for _, cronJob := range cronJobs { if cronJob.Labels["dokku.com/cron-id"] == cronTask.ID { @@ -851,10 +873,6 @@ func TriggerSchedulerDeploy(scheduler string, appName string, imageTag string) e } } - for key, value := range env.Map() { - values.Global.Secrets[key] = base64.StdEncoding.EncodeToString([]byte(value)) - } - b, err := templates.ReadFile("templates/chart/_helpers.tpl") if err != nil { return fmt.Errorf("Error reading _helpers template: %w", err) @@ -925,6 +943,38 @@ func TriggerSchedulerDeploy(scheduler string, appName string, imageTag string) e } } + if err := CreateOrUpdateConfigSecret(ctx, CreateOrUpdateConfigSecretInput{ + AppName: appName, + Env: env.Map(), + Annotations: globalAnnotations.SecretAnnotations, + Labels: globalLabels.SecretLabels, + }); err != nil { + return fmt.Errorf("Error syncing config secret: %w", err) + } + + if dokkuManagedPullSecret { + if err := CreateOrUpdateImagePullSecret(ctx, CreateOrUpdateImagePullSecretInput{ + AppName: appName, + DockerConfigJSON: dokkuPullSecretBytes, + Annotations: globalAnnotations.SecretAnnotations, + Labels: globalLabels.SecretLabels, + }); err != nil { + return fmt.Errorf("Error syncing image pull secret: %w", err) + } + } else { + if err := DeleteImagePullSecret(ctx, appName); err != nil { + return fmt.Errorf("Error removing stale image pull secret: %w", err) + } + } + + keepImagePullSecrets := []string{} + if imagePullSecrets != "" { + keepImagePullSecrets = []string{imagePullSecrets} + } + if err := pruneStaleImagePullSecretsFromDeployments(ctx, clientset, namespace, appName, keepImagePullSecrets); err != nil { + return fmt.Errorf("Error pruning stale imagePullSecrets entries: %w", err) + } + kustomizeRootPath := "" if hasKustomizeDirectory(appName) { kustomizeRootPath = getProcessSpecificKustomizeRootPath(appName) @@ -1402,7 +1452,7 @@ func TriggerSchedulerRun(scheduler string, appName string, envCount int, args [] imagePullSecrets := getComputedImagePullSecrets(appName) if imagePullSecrets == "" { - imagePullSecrets = fmt.Sprintf("ims-%s.%d", appName, deploymentID) + imagePullSecrets = GetImagePullSecretName(appName) _, err := clientset.GetSecret(context.Background(), GetSecretInput{ Name: imagePullSecrets, Namespace: namespace, @@ -1723,6 +1773,14 @@ func TriggerSchedulerPostDelete(scheduler string, appName string) error { common.LogWarn(fmt.Sprintf("Error deleting TLS secret for %s: %v", appName, err)) } + if err := DeleteConfigSecret(ctx, appName); err != nil { + common.LogWarn(fmt.Sprintf("Error deleting config secret for %s: %v", appName, err)) + } + + if err := DeleteImagePullSecret(ctx, appName); err != nil { + common.LogWarn(fmt.Sprintf("Error deleting image pull secret for %s: %v", appName, err)) + } + return nil } diff --git a/plugins/scheduler/go.mod b/plugins/scheduler/go.mod index 8743699a7..a2173ef74 100644 --- a/plugins/scheduler/go.mod +++ b/plugins/scheduler/go.mod @@ -1,6 +1,6 @@ module github.com/dokku/dokku/plugins/scheduler -go 1.25.5 +go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 diff --git a/plugins/scheduler/go.sum b/plugins/scheduler/go.sum index c97fc9665..e973ec8a5 100644 --- a/plugins/scheduler/go.sum +++ b/plugins/scheduler/go.sum @@ -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= diff --git a/plugins/scheduler/report.go b/plugins/scheduler/report.go index 6b9548f29..095fe851b 100644 --- a/plugins/scheduler/report.go +++ b/plugins/scheduler/report.go @@ -6,14 +6,23 @@ import ( // ReportSingleApp is an internal function that displays the scheduler 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{ - "--scheduler-computed-selected": reportComputedSelected, - "--scheduler-global-selected": reportGlobalSelected, - "--scheduler-selected": reportSelected, + var flags map[string]common.ReportFunc + if appName == "--global" { + flags = map[string]common.ReportFunc{ + "--scheduler-global-selected": reportGlobalSelected, + } + } else { + flags = map[string]common.ReportFunc{ + "--scheduler-computed-selected": reportComputedSelected, + "--scheduler-global-selected": reportGlobalSelected, + "--scheduler-selected": reportSelected, + } } flagKeys := []string{} diff --git a/plugins/scheduler/scheduler.go b/plugins/scheduler/scheduler.go index 1bd7c0371..006a9af6f 100644 --- a/plugins/scheduler/scheduler.go +++ b/plugins/scheduler/scheduler.go @@ -4,10 +4,12 @@ var ( // DefaultProperties is a map of all valid scheduler properties with corresponding default property values DefaultProperties = map[string]string{ "selected": "docker-local", + "shell": "", } // GlobalProperties is a map of all valid global scheduler properties GlobalProperties = map[string]bool{ "selected": true, + "shell": true, } ) diff --git a/plugins/scheduler/src/subcommands/subcommands.go b/plugins/scheduler/src/subcommands/subcommands.go index 148c3d753..93d8c6865 100644 --- a/plugins/scheduler/src/subcommands/subcommands.go +++ b/plugins/scheduler/src/subcommands/subcommands.go @@ -21,11 +21,14 @@ func main() { case "report": args := flag.NewFlagSet("scheduler:report", flag.ExitOnError) format := args.String("format", "stdout", "format: [ stdout | json ]") - osArgs, infoFlag, flagErr := common.ParseReportArgs("scheduler", os.Args[2:]) + reportArgs, flagErr := common.ParseReportArgs("scheduler", os.Args[2:]) if flagErr == nil { - args.Parse(osArgs) + args.Parse(reportArgs.OSArgs) appName := args.Arg(0) - err = scheduler.CommandReport(appName, *format, infoFlag) + if reportArgs.IsGlobal { + appName = "--global" + } + err = scheduler.CommandReport(appName, *format, reportArgs.InfoFlag) } case "set": args := flag.NewFlagSet("scheduler:set", flag.ExitOnError) diff --git a/plugins/scheduler/triggers.go b/plugins/scheduler/triggers.go index a7f38ec5f..1c942d65f 100644 --- a/plugins/scheduler/triggers.go +++ b/plugins/scheduler/triggers.go @@ -1,7 +1,6 @@ package scheduler import ( - "errors" "fmt" "github.com/dokku/dokku/plugins/common" @@ -31,53 +30,19 @@ func TriggerInstall() error { return fmt.Errorf("Unable to install the scheduler plugin: %s", err.Error()) } - apps, err := common.UnfilteredDokkuApps() - if err != nil && !errors.Is(err, common.NoAppsExist) { - return nil - } - - results, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "config-get-global", - Args: []string{"DOKKU_SCHEDULER"}, - }) - globalScheduler := results.StdoutContents() - if globalScheduler != "" { - common.LogVerboseQuiet(fmt.Sprintf("Setting scheduler property 'selected' to %v", globalScheduler)) - if err := common.PropertyWrite("scheduler", "--global", "selected", globalScheduler); err != nil { - return err - } - - _, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "config-unset", - Args: []string{"--global", "DOKKU_SCHEDULER"}, - }) - if err != nil { - common.LogWarn(err.Error()) - } - } - - for _, appName := range apps { - results, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "config-get", - Args: []string{appName, "DOKKU_SCHEDULER"}, - }) - scheduler := results.StdoutContents() - if scheduler == "" { - continue - } - - common.LogVerboseQuiet(fmt.Sprintf("Setting %s scheduler property 'selected' to %v", appName, scheduler)) - if err := common.PropertyWrite("scheduler", appName, "selected", scheduler); err != nil { - return err - } - - _, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "config-unset", - Args: []string{appName, "DOKKU_SCHEDULER"}, - }) - if err != nil { - common.LogWarn(err.Error()) - } + if err := common.MigrateConfigToProperties("scheduler", []common.MigrateConfigEntry{ + { + ConfigVar: "DOKKU_SCHEDULER", + GlobalConfigVar: "DOKKU_SCHEDULER", + Property: "selected", + }, + { + ConfigVar: "DOKKU_APP_SHELL", + GlobalConfigVar: "DOKKU_APP_SHELL", + Property: "shell", + }, + }); err != nil { + return err } return nil diff --git a/plugins/storage/Makefile b/plugins/storage/Makefile index 754299f43..11829d5ec 100644 --- a/plugins/storage/Makefile +++ b/plugins/storage/Makefile @@ -1,6 +1,6 @@ GOARCH ?= amd64 -SUBCOMMANDS = subcommands/default subcommands/ensure-directory subcommands/list subcommands/mount subcommands/report subcommands/unmount -TRIGGERS = triggers/install triggers/storage-list +SUBCOMMANDS = subcommands/default subcommands/create subcommands/destroy subcommands/ensure-directory subcommands/exec subcommands/info subcommands/list subcommands/list-entries subcommands/migrate subcommands/mount subcommands/report subcommands/set subcommands/unmount subcommands/wait +TRIGGERS = triggers/install triggers/storage-list triggers/storage-app-mounts triggers/docker-args-deploy triggers/docker-args-run triggers/post-delete triggers/post-app-clone-setup triggers/post-app-rename-setup BUILD = commands subcommands triggers PLUGIN_NAME = storage diff --git a/plugins/storage/attachment.go b/plugins/storage/attachment.go new file mode 100644 index 000000000..f43070fab --- /dev/null +++ b/plugins/storage/attachment.go @@ -0,0 +1,221 @@ +package storage + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "github.com/dokku/dokku/plugins/common" +) + +// AttachmentsProperty is the property-list key used to store attachments +// for an app. All process types live under one list and carry their own +// ProcessType field; we filter on read. +const AttachmentsProperty = "mounts" + +// PluginName is the name we register with the property system. +const PluginName = "storage" + +// PhaseDeploy is the standard deploy phase identifier. +const PhaseDeploy = "deploy" + +// PhaseRun is the standard run phase identifier. +const PhaseRun = "run" + +// DefaultProcessType is the wildcard process type that applies to every +// process. Mirrors docker-options' DefaultProcessType. +const DefaultProcessType = "_default_" + +// Attachment is the source of truth for *how* an app uses a storage entry. +// One Attachment binds one entry into one container path on one app. +type Attachment struct { + EntryName string `json:"entry_name"` + ContainerPath string `json:"container_path"` + Phases []string `json:"phases"` + ProcessType string `json:"process_type,omitempty"` + Subpath string `json:"subpath,omitempty"` + Readonly bool `json:"readonly,omitempty"` + VolumeOptions string `json:"volume_options,omitempty"` + VolumeChown string `json:"volume_chown,omitempty"` +} + +// Validate checks an Attachment's fields against structural rules. +func (a *Attachment) Validate() error { + if a == nil { + return errors.New("attachment is nil") + } + if a.EntryName == "" { + return errors.New("attachment is missing entry name") + } + if a.ContainerPath == "" { + return errors.New("attachment is missing container path") + } + if !strings.HasPrefix(a.ContainerPath, "/") { + return fmt.Errorf("attachment container path %q must be absolute", a.ContainerPath) + } + if len(a.Phases) == 0 { + return errors.New("attachment must specify at least one phase") + } + for _, phase := range a.Phases { + if phase != PhaseDeploy && phase != PhaseRun { + return fmt.Errorf("attachment phase %q is not supported (must be %q or %q)", phase, PhaseDeploy, PhaseRun) + } + } + return nil +} + +// LoadAttachments returns every attachment registered against an app. +func LoadAttachments(appName string) ([]*Attachment, error) { + lines, err := common.PropertyListGet(PluginName, appName, AttachmentsProperty) + if err != nil { + return nil, fmt.Errorf("unable to read storage attachments for %q: %w", appName, err) + } + + attachments := []*Attachment{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + attachment := &Attachment{} + if err := json.Unmarshal([]byte(line), attachment); err != nil { + return nil, fmt.Errorf("unable to parse storage attachment for %q: %w", appName, err) + } + attachments = append(attachments, attachment) + } + return attachments, nil +} + +// SaveAttachments overwrites the entire attachment list for an app. +func SaveAttachments(appName string, attachments []*Attachment) error { + lines := make([]string, 0, len(attachments)) + for _, attachment := range attachments { + data, err := json.Marshal(attachment) + if err != nil { + return fmt.Errorf("unable to encode storage attachment for %q: %w", appName, err) + } + lines = append(lines, string(data)) + } + return common.PropertyListWrite(PluginName, appName, AttachmentsProperty, lines) +} + +// AddAttachment appends an attachment, rejecting duplicates of the same +// (entry_name, container_path, process_type) tuple. +func AddAttachment(appName string, attachment *Attachment) error { + if err := attachment.Validate(); err != nil { + return err + } + + attachments, err := LoadAttachments(appName) + if err != nil { + return err + } + + for _, existing := range attachments { + if existing.EntryName == attachment.EntryName && + existing.ContainerPath == attachment.ContainerPath && + existing.ProcessType == attachment.ProcessType { + return fmt.Errorf("storage entry %q is already mounted at %q for process type %q on app %q", + attachment.EntryName, attachment.ContainerPath, attachment.ProcessType, appName) + } + } + + attachments = append(attachments, attachment) + return SaveAttachments(appName, attachments) +} + +// RemoveAttachment removes the attachment matching the given entry and +// optional container path. If containerPath is empty and there is more +// than one match, returns an error. +func RemoveAttachment(appName string, entryName string, containerPath string) error { + attachments, err := LoadAttachments(appName) + if err != nil { + return err + } + + matches := []*Attachment{} + keep := []*Attachment{} + for _, attachment := range attachments { + if attachment.EntryName == entryName && (containerPath == "" || attachment.ContainerPath == containerPath) { + matches = append(matches, attachment) + continue + } + keep = append(keep, attachment) + } + + if len(matches) == 0 { + if containerPath == "" { + return fmt.Errorf("storage entry %q is not mounted on app %q", entryName, appName) + } + return fmt.Errorf("storage entry %q is not mounted at %q on app %q", entryName, containerPath, appName) + } + if len(matches) > 1 && containerPath == "" { + paths := []string{} + for _, attachment := range matches { + paths = append(paths, attachment.ContainerPath) + } + sort.Strings(paths) + return fmt.Errorf("storage entry %q is mounted at multiple paths on app %q (%s); pass --container-dir to disambiguate", + entryName, appName, strings.Join(paths, ", ")) + } + + return SaveAttachments(appName, keep) +} + +// AttachmentsForPhase returns the subset of an app's attachments that +// apply to the given phase, sorted for stable output. +func AttachmentsForPhase(appName string, phase string) ([]*Attachment, error) { + attachments, err := LoadAttachments(appName) + if err != nil { + return nil, err + } + + filtered := []*Attachment{} + for _, attachment := range attachments { + for _, p := range attachment.Phases { + if p == phase { + filtered = append(filtered, attachment) + break + } + } + } + + sort.Slice(filtered, func(i, j int) bool { + if filtered[i].EntryName != filtered[j].EntryName { + return filtered[i].EntryName < filtered[j].EntryName + } + return filtered[i].ContainerPath < filtered[j].ContainerPath + }) + return filtered, nil +} + +// AppsUsingEntry returns the list of app names that have at least one +// attachment referencing the given entry name. Used by storage:destroy +// to refuse removing an entry that's still mounted. +func AppsUsingEntry(entryName string) ([]string, error) { + apps, err := common.DokkuApps() + if err != nil { + if errors.Is(err, common.NoAppsExist) { + return nil, nil + } + return nil, err + } + + using := []string{} + for _, app := range apps { + attachments, err := LoadAttachments(app) + if err != nil { + return nil, err + } + for _, attachment := range attachments { + if attachment.EntryName == entryName { + using = append(using, app) + break + } + } + } + sort.Strings(using) + return using, nil +} diff --git a/plugins/storage/attachment_test.go b/plugins/storage/attachment_test.go new file mode 100644 index 000000000..6c698ff77 --- /dev/null +++ b/plugins/storage/attachment_test.go @@ -0,0 +1,161 @@ +package storage + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/gomega" +) + +// writeAttachmentsFile drops the JSON-encoded attachment list into the +// per-app property file directly, bypassing common.PropertyListWrite (which +// calls SetPermissions and chowns to dokku:dokku, which a developer machine +// or CI runner generally doesn't have). +func writeAttachmentsFile(t *testing.T, root string, appName string, attachments []*Attachment) { + t.Helper() + dir := filepath.Join(root, "config", "storage", appName) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("mkdir attachment dir: %v", err) + } + path := filepath.Join(dir, AttachmentsProperty) + f, err := os.Create(path) + if err != nil { + t.Fatalf("create attachment file: %v", err) + } + defer f.Close() + for _, a := range attachments { + data, err := json.Marshal(a) + if err != nil { + t.Fatalf("marshal attachment: %v", err) + } + f.Write(append(data, '\n')) + } +} + +func TestAttachmentValidate(t *testing.T) { + RegisterTestingT(t) + + good := &Attachment{ + EntryName: "demo-data", + ContainerPath: "/data", + Phases: []string{PhaseDeploy, PhaseRun}, + } + Expect(good.Validate()).To(Succeed()) + + noEntry := &Attachment{ContainerPath: "/data", Phases: []string{PhaseDeploy}} + Expect(noEntry.Validate()).To(HaveOccurred()) + + noPath := &Attachment{EntryName: "demo-data", Phases: []string{PhaseDeploy}} + Expect(noPath.Validate()).To(HaveOccurred()) + + relative := &Attachment{EntryName: "demo-data", ContainerPath: "data", Phases: []string{PhaseDeploy}} + Expect(relative.Validate()).To(HaveOccurred()) + + noPhases := &Attachment{EntryName: "demo-data", ContainerPath: "/data"} + Expect(noPhases.Validate()).To(HaveOccurred()) + + badPhase := &Attachment{EntryName: "demo-data", ContainerPath: "/data", Phases: []string{"build"}} + Expect(badPhase.Validate()).To(HaveOccurred()) +} + +func TestListAppMountEntriesDockerLocal(t *testing.T) { + RegisterTestingT(t) + root := withTempLibRoot(t) + + Expect(SaveEntry(&Entry{ + Name: "demo-data", + Scheduler: SchedulerDockerLocal, + HostPath: "/var/lib/dokku/data/storage/demo-data", + })).To(Succeed()) + + writeAttachmentsFile(t, root, "demo", []*Attachment{ + { + EntryName: "demo-data", + ContainerPath: "/data", + Phases: []string{PhaseDeploy, PhaseRun}, + Readonly: true, + }, + }) + + rows, err := ListAppMountEntries("demo", PhaseDeploy) + Expect(err).NotTo(HaveOccurred()) + Expect(rows).To(HaveLen(1)) + Expect(rows[0].EntryName).To(Equal("demo-data")) + Expect(rows[0].HostPath).To(Equal("/var/lib/dokku/data/storage/demo-data")) + Expect(rows[0].ContainerPath).To(Equal("/data")) + Expect(rows[0].VolumeOptions).To(Equal("ro")) + + // Run phase shows it too because the attachment includes both phases. + runRows, err := ListAppMountEntries("demo", PhaseRun) + Expect(err).NotTo(HaveOccurred()) + Expect(runRows).To(HaveLen(1)) + + // formatStorageListEntry produces the legacy colon form including options. + Expect(formatStorageListEntry(rows[0])).To(Equal("/var/lib/dokku/data/storage/demo-data:/data:ro")) +} + +func TestListAppMountEntriesK3sUsesEntryName(t *testing.T) { + RegisterTestingT(t) + root := withTempLibRoot(t) + + // k3s entry has no host path because the cluster provisions it. + Expect(SaveEntry(&Entry{ + Name: "demo-pvc", + Scheduler: SchedulerK3s, + Size: "2Gi", + StorageClass: "longhorn", + AccessMode: "ReadWriteOnce", + })).To(Succeed()) + + writeAttachmentsFile(t, root, "demo", []*Attachment{ + { + EntryName: "demo-pvc", + ContainerPath: "/data", + Phases: []string{PhaseDeploy}, + Subpath: "uploads", + }, + }) + + rows, err := ListAppMountEntries("demo", PhaseDeploy) + Expect(err).NotTo(HaveOccurred()) + Expect(rows).To(HaveLen(1)) + // HostPath falls back to the entry name so the colon form remains + // well-formed for callers that only know about the legacy shape. + Expect(rows[0].HostPath).To(Equal("demo-pvc")) + Expect(rows[0].EntryName).To(Equal("demo-pvc")) + Expect(rows[0].ContainerPath).To(Equal("/data")) + Expect(formatStorageListEntry(rows[0])).To(Equal("demo-pvc:/data")) +} + +func TestListAppMountEntriesPhaseFilter(t *testing.T) { + RegisterTestingT(t) + root := withTempLibRoot(t) + + Expect(SaveEntry(&Entry{ + Name: "demo-deploy-only", + Scheduler: SchedulerDockerLocal, + HostPath: "/srv/deploy", + })).To(Succeed()) + Expect(SaveEntry(&Entry{ + Name: "demo-run-only", + Scheduler: SchedulerDockerLocal, + HostPath: "/srv/run", + })).To(Succeed()) + + writeAttachmentsFile(t, root, "demo", []*Attachment{ + {EntryName: "demo-deploy-only", ContainerPath: "/d", Phases: []string{PhaseDeploy}}, + {EntryName: "demo-run-only", ContainerPath: "/r", Phases: []string{PhaseRun}}, + }) + + deployRows, err := ListAppMountEntries("demo", PhaseDeploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deployRows).To(HaveLen(1)) + Expect(deployRows[0].EntryName).To(Equal("demo-deploy-only")) + + runRows, err := ListAppMountEntries("demo", PhaseRun) + Expect(err).NotTo(HaveOccurred()) + Expect(runRows).To(HaveLen(1)) + Expect(runRows[0].EntryName).To(Equal("demo-run-only")) +} diff --git a/plugins/storage/commands_entries.go b/plugins/storage/commands_entries.go new file mode 100644 index 000000000..a79d1cdff --- /dev/null +++ b/plugins/storage/commands_entries.go @@ -0,0 +1,567 @@ +package storage + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/dokku/dokku/plugins/common" +) + +// CommandCreateInput captures the flags accepted by storage:create. +type CommandCreateInput struct { + Name string + Path string + Scheduler string + Size string + AccessMode string + StorageClass string + Namespace string + Chown string + ReclaimPolicy string + Annotations map[string]string + Labels map[string]string +} + +// CommandCreate registers a new storage entry. +func CommandCreate(input CommandCreateInput) error { + if err := ValidateEntryName(input.Name, false); err != nil { + return err + } + + scheduler := input.Scheduler + if scheduler == "" { + scheduler = SchedulerDockerLocal + } + + hostPath := input.Path + if scheduler == SchedulerDockerLocal && hostPath == "" { + hostPath = filepath.Join(GetStorageDirectory(), input.Name) + } + + entry := &Entry{ + Name: input.Name, + Scheduler: scheduler, + HostPath: hostPath, + Size: input.Size, + AccessMode: input.AccessMode, + StorageClass: input.StorageClass, + Namespace: input.Namespace, + Chown: input.Chown, + ReclaimPolicy: input.ReclaimPolicy, + Annotations: input.Annotations, + Labels: input.Labels, + SchemaVersion: SchemaVersion, + } + + if err := entry.Validate(); err != nil { + return err + } + + if EntryExists(entry.Name) { + existing, err := LoadEntry(entry.Name) + if err != nil { + return err + } + if existing.Scheduler != entry.Scheduler { + return fmt.Errorf("storage entry %q already exists with scheduler %q; refusing to redefine", entry.Name, existing.Scheduler) + } + common.LogInfo1Quiet(fmt.Sprintf("Storage entry %s already exists, leaving in place", entry.Name)) + } + + if scheduler == SchedulerDockerLocal { + if err := ensureDockerLocalPath(entry); err != nil { + return err + } + } + + if err := SaveEntry(entry); err != nil { + return err + } + + if scheduler == SchedulerK3s { + if err := callSchedulerCreateTrigger(entry); err != nil { + // Roll back the on-disk entry so the disk and the cluster stay in sync. + _ = DeleteEntry(entry.Name) + return fmt.Errorf("scheduler refused storage entry %q: %w", entry.Name, err) + } + } + + common.LogInfo1(fmt.Sprintf("Storage entry %s created", entry.Name)) + return nil +} + +// CommandDestroy removes a registered storage entry. Refuses to remove +// an entry that any app still has attached. +func CommandDestroy(name string) error { + if name == "" { + return errors.New("storage entry name is required") + } + if !EntryExists(name) { + return fmt.Errorf("storage entry %q does not exist", name) + } + + using, err := AppsUsingEntry(name) + if err != nil { + return err + } + if len(using) > 0 { + return fmt.Errorf("storage entry %q is still mounted by app(s): %s", name, strings.Join(using, ", ")) + } + + entry, err := LoadEntry(name) + if err != nil { + return err + } + + if entry.Scheduler == SchedulerK3s { + if err := callSchedulerDestroyTrigger(entry); err != nil { + return fmt.Errorf("scheduler refused to remove storage entry %q: %w", name, err) + } + } + + if err := DeleteEntry(name); err != nil { + return err + } + common.LogInfo1(fmt.Sprintf("Storage entry %s destroyed", name)) + return nil +} + +// CommandInfo prints a single entry's details, optionally as JSON. +func CommandInfo(name string, format string) error { + if name == "" { + return errors.New("storage entry name is required") + } + if !EntryExists(name) { + return fmt.Errorf("storage entry %q does not exist", name) + } + + entry, err := LoadEntry(name) + if err != nil { + return err + } + + if format == "json" { + data, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil + } + + common.LogInfo1Quiet(fmt.Sprintf("Storage entry %s", entry.Name)) + common.LogVerbose(fmt.Sprintf("Scheduler: %s", entry.Scheduler)) + if entry.HostPath != "" { + common.LogVerbose(fmt.Sprintf("Host path: %s", entry.HostPath)) + } + if entry.Size != "" { + common.LogVerbose(fmt.Sprintf("Size: %s", entry.Size)) + } + if entry.AccessMode != "" { + common.LogVerbose(fmt.Sprintf("Access mode: %s", entry.AccessMode)) + } + if entry.StorageClass != "" { + common.LogVerbose(fmt.Sprintf("Storage class: %s", entry.StorageClass)) + } + if entry.Namespace != "" { + common.LogVerbose(fmt.Sprintf("Namespace: %s", entry.Namespace)) + } + if entry.Chown != "" { + common.LogVerbose(fmt.Sprintf("Chown: %s", entry.Chown)) + } + if entry.ReclaimPolicy != "" { + common.LogVerbose(fmt.Sprintf("Reclaim policy: %s", entry.ReclaimPolicy)) + } + return nil +} + +// CommandSetInput captures the flags accepted by storage:set. +type CommandSetInput struct { + Name string + Size string + AccessMode string + StorageClass string + Namespace string + Chown string + ReclaimPolicy string + Annotations map[string]string + Labels map[string]string +} + +// CommandSet edits an existing entry's mutable fields and re-fires the +// scheduler-side helm release. Refuses changes Kubernetes can't apply +// in place (access-mode swap, storage-class swap, size shrink). +func CommandSet(input CommandSetInput) error { + if !EntryExists(input.Name) { + return fmt.Errorf("storage entry %q does not exist", input.Name) + } + entry, err := LoadEntry(input.Name) + if err != nil { + return err + } + + if input.AccessMode != "" && input.AccessMode != entry.AccessMode { + return fmt.Errorf("storage:set cannot change access-mode in place; recreate the entry") + } + if input.StorageClass != "" && input.StorageClass != entry.StorageClass { + return fmt.Errorf("storage:set cannot change storage-class-name in place; recreate the entry") + } + if input.Size != "" { + entry.Size = input.Size + } + if input.Namespace != "" { + entry.Namespace = input.Namespace + } + if input.Chown != "" { + entry.Chown = input.Chown + } + if input.ReclaimPolicy != "" { + entry.ReclaimPolicy = input.ReclaimPolicy + } + if input.Annotations != nil { + entry.Annotations = input.Annotations + } + if input.Labels != nil { + entry.Labels = input.Labels + } + + if err := entry.Validate(); err != nil { + return err + } + if err := SaveEntry(entry); err != nil { + return err + } + if entry.Scheduler == SchedulerK3s { + if err := callSchedulerCreateTrigger(entry); err != nil { + return fmt.Errorf("scheduler refused storage:set for %q: %w", entry.Name, err) + } + } + common.LogInfo1(fmt.Sprintf("Storage entry %s updated", entry.Name)) + return nil +} + +// CommandExecInput captures the storage:exec subcommand inputs. +type CommandExecInput struct { + Name string + Image string + AsUser string + Args []string +} + +// CommandExec delegates the actual exec to the scheduler plugin that owns +// the storage entry, by firing the scheduler-storage-exec plugn trigger +// with stdin/stdout/stderr streamed through. The handler decides between +// docker run, the k8s exec SDK, etc. - storage stays scheduler-agnostic. +// +// Exit codes from the underlying tool are propagated verbatim via +// os.Exit so callers in scripts see the right status. +func CommandExec(input CommandExecInput) error { + if !EntryExists(input.Name) { + return fmt.Errorf("storage entry %q does not exist", input.Name) + } + entry, err := LoadEntry(input.Name) + if err != nil { + return err + } + + image := input.Image + if image == "" { + image = "alpine:3" + } + + interactive, tty := stdinModes() + + triggerArgs := []string{ + entry.Scheduler, + entry.Name, + image, + } + triggerArgs = append(triggerArgs, fmt.Sprintf("--interactive=%t", interactive)) + triggerArgs = append(triggerArgs, fmt.Sprintf("--tty=%t", tty)) + if input.AsUser != "" { + triggerArgs = append(triggerArgs, "--as-user", input.AsUser) + } + if len(input.Args) > 0 { + triggerArgs = append(triggerArgs, "--") + triggerArgs = append(triggerArgs, input.Args...) + } + + results, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ + Trigger: "scheduler-storage-exec", + Args: triggerArgs, + StreamStdio: true, + }) + // common.CallExecCommand wraps a non-zero exit code as an error, but + // for storage:exec the underlying tool's exit code IS the signal we + // want to surface - it's how `dokku storage:exec demo -- exit 42` + // reaches the user as exit 42. Check ExitCode first so that branch + // runs before err collapses to LogFailWithError → exit 1. + if results.ExitCode != 0 { + os.Exit(results.ExitCode) + } + if err != nil { + return err + } + return nil +} + +// stdinModes inspects os.Stdin to decide whether docker / the k8s exec +// SDK should request an interactive session and a TTY. +func stdinModes() (interactive bool, tty bool) { + fi, err := os.Stdin.Stat() + if err != nil || fi == nil { + return false, false + } + mode := fi.Mode() + tty = mode&os.ModeCharDevice != 0 + hasStdinData := mode&os.ModeNamedPipe != 0 + interactive = tty || hasStdinData + return interactive, tty +} + +// CommandMigrate re-runs the legacy `-v` → entry/attachment migration +// for a single app or every app on the install. Useful when an operator +// has restored an app from a backup that predates the install-time +// migration, or when they've added `-v` lines via docker-options:add +// after the per-app flag was set. +func CommandMigrate(appName string, all bool) error { + if all && appName != "" { + return errors.New("storage:migrate accepts either an app name or --all, not both") + } + if !all && appName == "" { + return errors.New("storage:migrate requires an app name (or --all)") + } + if all { + apps, err := common.DokkuApps() + if err != nil { + if errors.Is(err, common.NoAppsExist) { + return nil + } + return err + } + for _, app := range apps { + if err := MigrateApp(app); err != nil { + return err + } + } + common.LogInfo1(fmt.Sprintf("Re-migrated %d app(s)", len(apps))) + return nil + } + if err := common.VerifyAppName(appName); err != nil { + return err + } + if err := MigrateApp(appName); err != nil { + return err + } + common.LogInfo1(fmt.Sprintf("Re-migrated %s", appName)) + return nil +} + +// CommandWait blocks until a k3s storage entry's PVC is bound. +func CommandWait(name string) error { + if !EntryExists(name) { + return fmt.Errorf("storage entry %q does not exist", name) + } + entry, err := LoadEntry(name) + if err != nil { + return err + } + if entry.Scheduler != SchedulerK3s { + // Docker-local entries are immediately "ready" once the directory exists. + if entry.HostPath != "" { + info, statErr := os.Stat(entry.HostPath) + if statErr != nil || !info.IsDir() { + return fmt.Errorf("storage entry %q host path %s is not present", name, entry.HostPath) + } + } + return nil + } + + data, err := json.Marshal(entry) + if err != nil { + return err + } + results, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ + Trigger: "storage-status", + Args: []string{entry.Name}, + Stdin: strings.NewReader(string(data)), + }) + if err != nil { + return err + } + status := strings.TrimSpace(results.StdoutContents()) + if status != "Bound" { + return fmt.Errorf("storage entry %q is %s, not Bound", name, status) + } + common.LogInfo1(fmt.Sprintf("Storage entry %s is bound", name)) + return nil +} + +// CommandReportGlobal prints a global storage report listing every entry +// and the apps that mount it. Output is text by default; pass +// format="json" for machine-readable output. +func CommandReportGlobal(format string) error { + entries, err := ListEntries() + if err != nil { + return err + } + type entryWithUse struct { + Entry *Entry `json:"entry"` + MountedBy []string `json:"mounted_by"` + } + rows := []entryWithUse{} + for _, entry := range entries { + using, err := AppsUsingEntry(entry.Name) + if err != nil { + return err + } + rows = append(rows, entryWithUse{Entry: entry, MountedBy: using}) + } + if format == "json" { + data, err := json.MarshalIndent(rows, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil + } + if len(rows) == 0 { + common.LogInfo1Quiet("No storage entries registered") + return nil + } + common.LogInfo1Quiet("Storage report (global):") + for _, row := range rows { + mountedBy := "—" + if len(row.MountedBy) > 0 { + mountedBy = strings.Join(row.MountedBy, ", ") + } + common.LogVerbose(fmt.Sprintf("%s\t%s\tmounted by: %s", row.Entry.Name, row.Entry.Scheduler, mountedBy)) + } + return nil +} + +// CommandListEntries lists registered entries (distinct from the legacy +// per-app storage:list which lists colon-form mounts). +func CommandListEntries(scheduler string, format string) error { + entries, err := ListEntries() + if err != nil { + return err + } + + filtered := []*Entry{} + for _, entry := range entries { + if scheduler != "" && entry.Scheduler != scheduler { + continue + } + filtered = append(filtered, entry) + } + + if format == "json" { + data, err := json.MarshalIndent(filtered, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil + } + + if len(filtered) == 0 { + common.LogInfo1Quiet("No storage entries registered") + return nil + } + common.LogInfo1Quiet("Storage entries:") + for _, entry := range filtered { + common.LogVerbose(fmt.Sprintf("%s\t%s\t%s", entry.Name, entry.Scheduler, entry.HostPath)) + } + return nil +} + +// ensureDockerLocalPath creates the host directory referenced by a +// docker-local entry if it doesn't already exist. Idempotent: a +// pre-existing directory is left in place. +func ensureDockerLocalPath(entry *Entry) error { + info, err := os.Stat(entry.HostPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("unable to stat %s: %w", entry.HostPath, err) + } + if err == nil && !info.IsDir() { + return fmt.Errorf("storage entry path %s exists but is not a directory", entry.HostPath) + } + if err != nil { + if mkdirErr := os.MkdirAll(entry.HostPath, 0755); mkdirErr != nil { + return fmt.Errorf("unable to create %s: %w", entry.HostPath, mkdirErr) + } + common.LogVerbose(fmt.Sprintf("Created %s", entry.HostPath)) + } + + if entry.Chown != "" && entry.Chown != "false" { + chownID, err := ResolveChownID(entry.Chown) + if err != nil { + return err + } + if chownID != "false" { + pluginPath := common.MustGetEnv("PLUGIN_AVAILABLE_PATH") + chownScript := filepath.Join(pluginPath, "storage", "bin", "chown-storage-dir") + result, err := common.CallExecCommand(common.ExecCommandInput{ + Command: "sudo", + Args: []string{chownScript, entry.HostPath, chownID}, + }) + if err != nil { + return fmt.Errorf("unable to chown %s: %w", entry.HostPath, err) + } + if result.ExitCode != 0 { + return fmt.Errorf("unable to chown %s: %s", entry.HostPath, result.StderrContents()) + } + } + } + return nil +} + +// callSchedulerCreateTrigger asks the scheduler plugin (k3s) to provision +// the underlying PVC/PV. The scheduler is responsible for any +// cluster-level validation (storage class existence, etc.). +func callSchedulerCreateTrigger(entry *Entry) error { + data, err := json.Marshal(entry) + if err != nil { + return err + } + results, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ + Trigger: "storage-create", + Args: []string{entry.Name}, + StreamStdio: true, + Stdin: strings.NewReader(string(data)), + }) + if err != nil { + return err + } + if results.ExitCode != 0 { + return fmt.Errorf("storage-create trigger exited with %d: %s", results.ExitCode, results.StderrContents()) + } + return nil +} + +// callSchedulerDestroyTrigger asks the scheduler plugin to release the +// underlying PVC/PV. +func callSchedulerDestroyTrigger(entry *Entry) error { + data, err := json.Marshal(entry) + if err != nil { + return err + } + results, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ + Trigger: "storage-destroy", + Args: []string{entry.Name}, + StreamStdio: true, + Stdin: strings.NewReader(string(data)), + }) + if err != nil { + return err + } + if results.ExitCode != 0 { + return fmt.Errorf("storage-destroy trigger exited with %d: %s", results.ExitCode, results.StderrContents()) + } + return nil +} diff --git a/plugins/storage/entry.go b/plugins/storage/entry.go new file mode 100644 index 000000000..470ac3781 --- /dev/null +++ b/plugins/storage/entry.go @@ -0,0 +1,294 @@ +package storage + +import ( + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/dokku/dokku/plugins/common" +) + +// SchemaVersion is the on-disk schema version emitted by this plugin. +// Bumped when the format of Entry / Attachment changes incompatibly. +const SchemaVersion = 1 + +// SchedulerDockerLocal is the default scheduler value for storage entries. +const SchedulerDockerLocal = "docker-local" + +// SchedulerK3s is the k3s scheduler value for storage entries. +const SchedulerK3s = "k3s" + +// LegacyEntryPrefix is reserved for migration-synthesized entries; users +// cannot create entries whose name starts with this prefix. +const LegacyEntryPrefix = "legacy-" + +// MaxEntryNameLength caps the storage entry name so that the helm release +// name "storage-" stays within Helm's practical 53-char ceiling. +const MaxEntryNameLength = 45 + +// ReclaimPolicyRetain keeps the underlying PV after the entry is destroyed. +const ReclaimPolicyRetain = "Retain" + +// ReclaimPolicyDelete deletes the underlying PV after the entry is destroyed. +const ReclaimPolicyDelete = "Delete" + +var ( + // dns1123LabelRegexp matches a Kubernetes DNS-1123 label, the format + // required for resource names like PVCs and Helm releases. + dns1123LabelRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) + + // dockerNamedVolumeRegexp matches a Docker named-volume token. Same + // rule docker uses to disambiguate volume names from bind paths. + dockerNamedVolumeRegexp = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) + + // supportedSchedulers lists the values accepted for --scheduler. + supportedSchedulers = map[string]bool{ + SchedulerDockerLocal: true, + SchedulerK3s: true, + } + + // supportedAccessModes lists the values accepted for --access-mode on k3s. + supportedAccessModes = map[string]bool{ + "ReadWriteOnce": true, + "ReadOnlyMany": true, + "ReadWriteMany": true, + "ReadWriteOncePod": true, + } +) + +// Entry is the source of truth for a storage volume. One file per entry +// lives at $DOKKU_LIB_ROOT/config/storage/entries/.json. +type Entry struct { + Name string `json:"name"` + Scheduler string `json:"scheduler"` + HostPath string `json:"host_path,omitempty"` + Size string `json:"size,omitempty"` + AccessMode string `json:"access_mode,omitempty"` + StorageClass string `json:"storage_class,omitempty"` + Namespace string `json:"namespace,omitempty"` + Chown string `json:"chown,omitempty"` + ReclaimPolicy string `json:"reclaim_policy,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + SchemaVersion int `json:"schema_version"` +} + +// RegistryDirectory returns the parent directory for storage-plugin +// state that lives outside the property store. Kept under data/ rather +// than config/ so it doesn't collide with the property-list paths +// `config/storage//` that the rest of the plugin +// relies on for per-app attachments. +func RegistryDirectory() string { + root := common.GetenvWithDefault("DOKKU_LIB_ROOT", "/var/lib/dokku") + return filepath.Join(root, "data", "storage-registry") +} + +// EntriesDirectory returns the directory holding per-entry JSON files. +func EntriesDirectory() string { + return filepath.Join(RegistryDirectory(), "entries") +} + +// entryPath returns the on-disk path for a named entry. +func entryPath(name string) string { + return filepath.Join(EntriesDirectory(), name+".json") +} + +// EnsureEntriesDirectory makes sure the entries directory exists with +// permissive enough mode that the dokku user can read it. +func EnsureEntriesDirectory() error { + dir := EntriesDirectory() + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("unable to create storage entries directory: %w", err) + } + return nil +} + +// EntryExists reports whether an entry with the given name is registered. +func EntryExists(name string) bool { + _, err := os.Stat(entryPath(name)) + return err == nil +} + +// LoadEntry reads a registered entry by name. +func LoadEntry(name string) (*Entry, error) { + data, err := os.ReadFile(entryPath(name)) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("storage entry %q does not exist", name) + } + return nil, fmt.Errorf("unable to read storage entry %q: %w", name, err) + } + + entry := &Entry{} + if err := json.Unmarshal(data, entry); err != nil { + return nil, fmt.Errorf("unable to parse storage entry %q: %w", name, err) + } + return entry, nil +} + +// SaveEntry persists an entry to its JSON file. +func SaveEntry(entry *Entry) error { + if err := EnsureEntriesDirectory(); err != nil { + return err + } + + if entry.SchemaVersion == 0 { + entry.SchemaVersion = SchemaVersion + } + + data, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return fmt.Errorf("unable to encode storage entry %q: %w", entry.Name, err) + } + + path := entryPath(entry.Name) + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("unable to write storage entry %q: %w", entry.Name, err) + } + return nil +} + +// DeleteEntry removes the on-disk record for an entry. +func DeleteEntry(name string) error { + if err := os.Remove(entryPath(name)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("unable to remove storage entry %q: %w", name, err) + } + return nil +} + +// ListEntries returns every registered entry sorted by name. +func ListEntries() ([]*Entry, error) { + dir := EntriesDirectory() + dirEntries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("unable to list storage entries: %w", err) + } + + entries := []*Entry{} + for _, dirEntry := range dirEntries { + if dirEntry.IsDir() || !strings.HasSuffix(dirEntry.Name(), ".json") { + continue + } + name := strings.TrimSuffix(dirEntry.Name(), ".json") + entry, err := LoadEntry(name) + if err != nil { + return nil, err + } + entries = append(entries, entry) + } + + sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) + return entries, nil +} + +// ValidateEntryName checks the user-supplied name against length and DNS +// label rules. Returns nil for legacy names if allowLegacyPrefix is true, +// which is only set during migration. +func ValidateEntryName(name string, allowLegacyPrefix bool) error { + if name == "" { + return errors.New("storage entry name is required") + } + if len(name) > MaxEntryNameLength { + return fmt.Errorf("storage entry name %q is too long (max %d characters); helm release names cap our budget", name, MaxEntryNameLength) + } + if !dns1123LabelRegexp.MatchString(name) { + return fmt.Errorf("storage entry name %q must be a DNS-1123 label (lowercase letters, digits, and dashes; cannot start or end with a dash)", name) + } + if !allowLegacyPrefix && strings.HasPrefix(name, LegacyEntryPrefix) { + return fmt.Errorf("storage entry name %q must not start with reserved prefix %q", name, LegacyEntryPrefix) + } + return nil +} + +// Validate checks an Entry's fields against the cross-field rules for its +// scheduler. It is reused by both storage:create and the legacy migration. +func (e *Entry) Validate() error { + if e == nil { + return errors.New("entry is nil") + } + if err := ValidateEntryName(e.Name, strings.HasPrefix(e.Name, LegacyEntryPrefix)); err != nil { + return err + } + + if !supportedSchedulers[e.Scheduler] { + return fmt.Errorf("storage entry %q has unsupported scheduler %q (supported: docker-local, k3s)", e.Name, e.Scheduler) + } + + switch e.Scheduler { + case SchedulerDockerLocal: + if e.HostPath == "" { + return fmt.Errorf("storage entry %q (docker-local) is missing host_path", e.Name) + } + if !filepath.IsAbs(e.HostPath) && !dockerNamedVolumeRegexp.MatchString(e.HostPath) { + return fmt.Errorf("storage entry %q host_path must be an absolute path or a docker named volume token, got %q", e.Name, e.HostPath) + } + if e.Size != "" { + return fmt.Errorf("storage entry %q (docker-local) does not accept --size", e.Name) + } + if e.StorageClass != "" { + return fmt.Errorf("storage entry %q (docker-local) does not accept --storage-class-name", e.Name) + } + if e.AccessMode != "" { + return fmt.Errorf("storage entry %q (docker-local) does not accept --access-mode", e.Name) + } + case SchedulerK3s: + if e.Size == "" { + return fmt.Errorf("storage entry %q (k3s) requires --size", e.Name) + } + if e.StorageClass != "" && e.HostPath != "" { + return fmt.Errorf("storage entry %q (k3s) cannot set both --storage-class-name and a host path; the cluster provisions class-backed volumes", e.Name) + } + if e.AccessMode != "" && !supportedAccessModes[e.AccessMode] { + return fmt.Errorf("storage entry %q has unsupported access mode %q", e.Name, e.AccessMode) + } + if e.HostPath != "" && !filepath.IsAbs(e.HostPath) { + return fmt.Errorf("storage entry %q host_path must be absolute, got %q", e.Name, e.HostPath) + } + if e.ReclaimPolicy != "" && e.ReclaimPolicy != ReclaimPolicyRetain && e.ReclaimPolicy != ReclaimPolicyDelete { + return fmt.Errorf("storage entry %q has unsupported reclaim policy %q", e.Name, e.ReclaimPolicy) + } + } + + return nil +} + +// LegacyMountToEntry synthesizes a deterministic Entry for a legacy +// "host:container[:options]" mount string. The synthesized name is +// stable across runs so re-running the migration is idempotent and so +// two apps mounting the same host path converge on a single entry. +func LegacyMountToEntry(mountPath string) *Entry { + parsed := ParseMountPath(mountPath) + host := parsed.HostPath + + hashInput := host + if !strings.HasPrefix(host, "/") { + hashInput = "vol:" + host + } + sum := sha1.Sum([]byte(hashInput)) + name := LegacyEntryPrefix + hex.EncodeToString(sum[:])[:10] + + hostPath := host + if !strings.HasPrefix(host, "/") { + // Named docker volumes pass through unchanged on docker-local; + // the docker engine resolves them. Store as-is so the args + // shim can emit the original token. + hostPath = host + } + + return &Entry{ + Name: name, + Scheduler: SchedulerDockerLocal, + HostPath: hostPath, + SchemaVersion: SchemaVersion, + } +} diff --git a/plugins/storage/entry_test.go b/plugins/storage/entry_test.go new file mode 100644 index 000000000..d317452dc --- /dev/null +++ b/plugins/storage/entry_test.go @@ -0,0 +1,205 @@ +package storage + +import ( + "path/filepath" + "strings" + "testing" + + . "github.com/onsi/gomega" +) + +func withTempLibRoot(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("DOKKU_LIB_ROOT", dir) + return dir +} + +func TestValidateEntryName(t *testing.T) { + RegisterTestingT(t) + + Expect(ValidateEntryName("foo", false)).To(Succeed()) + Expect(ValidateEntryName("foo-bar", false)).To(Succeed()) + Expect(ValidateEntryName("a", false)).To(Succeed()) + Expect(ValidateEntryName("foo123", false)).To(Succeed()) + + err := ValidateEntryName("", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("required")) + + err = ValidateEntryName("Foo", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("DNS-1123")) + + err = ValidateEntryName("foo_bar", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("DNS-1123")) + + err = ValidateEntryName("-foo", false) + Expect(err).To(HaveOccurred()) + + err = ValidateEntryName("foo-", false) + Expect(err).To(HaveOccurred()) + + long := strings.Repeat("a", MaxEntryNameLength+1) + err = ValidateEntryName(long, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("too long")) + + maxOK := strings.Repeat("a", MaxEntryNameLength) + Expect(ValidateEntryName(maxOK, false)).To(Succeed()) + + err = ValidateEntryName("legacy-foo", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("reserved")) + + Expect(ValidateEntryName("legacy-foo", true)).To(Succeed()) +} + +func TestEntryValidateDockerLocal(t *testing.T) { + RegisterTestingT(t) + + good := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "/var/lib/dokku/data/storage/foo"} + Expect(good.Validate()).To(Succeed()) + + noPath := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal} + Expect(noPath.Validate()).To(HaveOccurred()) + + // A leading-slash-less but DNS-1123-ish token is treated as a docker + // named volume and accepted. + namedVolume := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "myvolume"} + Expect(namedVolume.Validate()).To(Succeed()) + + // Slash-containing relative paths still fail, as do tokens with bad chars. + relativePath := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "relative/path"} + Expect(relativePath.Validate()).To(HaveOccurred()) + + badToken := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "with spaces"} + Expect(badToken.Validate()).To(HaveOccurred()) + + withSize := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "/data", Size: "2Gi"} + Expect(withSize.Validate()).To(HaveOccurred()) + + withClass := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "/data", StorageClass: "longhorn"} + Expect(withClass.Validate()).To(HaveOccurred()) +} + +func TestEntryValidateK3s(t *testing.T) { + RegisterTestingT(t) + + dynamic := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", StorageClass: "longhorn", AccessMode: "ReadWriteOnce"} + Expect(dynamic.Validate()).To(Succeed()) + + hostPath := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", HostPath: "/data/foo"} + Expect(hostPath.Validate()).To(Succeed()) + + noSize := &Entry{Name: "foo", Scheduler: SchedulerK3s, StorageClass: "longhorn"} + Expect(noSize.Validate()).To(HaveOccurred()) + + bothPathAndClass := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", HostPath: "/data", StorageClass: "longhorn"} + err := bothPathAndClass.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("storage-class-name")) + + badAccessMode := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", StorageClass: "longhorn", AccessMode: "Bogus"} + Expect(badAccessMode.Validate()).To(HaveOccurred()) + + badReclaim := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", StorageClass: "longhorn", ReclaimPolicy: "Recycle"} + Expect(badReclaim.Validate()).To(HaveOccurred()) +} + +func TestEntryValidateScheduler(t *testing.T) { + RegisterTestingT(t) + + bad := &Entry{Name: "foo", Scheduler: "nomad"} + err := bad.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("scheduler")) +} + +func TestEntryRoundTrip(t *testing.T) { + RegisterTestingT(t) + root := withTempLibRoot(t) + + original := &Entry{ + Name: "demo-data", + Scheduler: SchedulerK3s, + Size: "2Gi", + StorageClass: "longhorn", + AccessMode: "ReadWriteOnce", + Namespace: "dokku", + Annotations: map[string]string{"backup.velero.io/backup-volumes": "demo-data"}, + Labels: map[string]string{"app.kubernetes.io/managed-by": "dokku"}, + } + + Expect(SaveEntry(original)).To(Succeed()) + Expect(EntryExists("demo-data")).To(BeTrue()) + + loaded, err := LoadEntry("demo-data") + Expect(err).NotTo(HaveOccurred()) + Expect(loaded.Name).To(Equal(original.Name)) + Expect(loaded.Scheduler).To(Equal(original.Scheduler)) + Expect(loaded.Size).To(Equal(original.Size)) + Expect(loaded.StorageClass).To(Equal(original.StorageClass)) + Expect(loaded.AccessMode).To(Equal(original.AccessMode)) + Expect(loaded.Namespace).To(Equal(original.Namespace)) + Expect(loaded.Annotations).To(Equal(original.Annotations)) + Expect(loaded.Labels).To(Equal(original.Labels)) + Expect(loaded.SchemaVersion).To(Equal(SchemaVersion)) + + expectedPath := filepath.Join(root, "data", "storage-registry", "entries", "demo-data.json") + Expect(expectedPath).To(BeARegularFile()) + + Expect(DeleteEntry("demo-data")).To(Succeed()) + Expect(EntryExists("demo-data")).To(BeFalse()) +} + +func TestListEntries(t *testing.T) { + RegisterTestingT(t) + withTempLibRoot(t) + + entries, err := ListEntries() + Expect(err).NotTo(HaveOccurred()) + Expect(entries).To(BeEmpty()) + + Expect(SaveEntry(&Entry{Name: "b", Scheduler: SchedulerDockerLocal, HostPath: "/b"})).To(Succeed()) + Expect(SaveEntry(&Entry{Name: "a", Scheduler: SchedulerDockerLocal, HostPath: "/a"})).To(Succeed()) + + entries, err = ListEntries() + Expect(err).NotTo(HaveOccurred()) + Expect(entries).To(HaveLen(2)) + Expect(entries[0].Name).To(Equal("a")) + Expect(entries[1].Name).To(Equal("b")) +} + +func TestLegacyMountToEntry(t *testing.T) { + RegisterTestingT(t) + + a := LegacyMountToEntry("/host/path:/container/path") + b := LegacyMountToEntry("/host/path:/container/different") + c := LegacyMountToEntry("/host/path:/container/path:ro") + + // Same host path produces the same entry name regardless of container + // path or options - container path lives on the attachment, not the + // entry, so apps mounting the same source converge. + Expect(a.Name).To(Equal(b.Name)) + Expect(a.Name).To(Equal(c.Name)) + Expect(a.Name).To(HavePrefix(LegacyEntryPrefix)) + Expect(len(a.Name)).To(Equal(len(LegacyEntryPrefix) + 10)) + + // Different host paths produce different names. + other := LegacyMountToEntry("/different/path:/container/path") + Expect(other.Name).NotTo(Equal(a.Name)) + + // Named docker volumes are distinguished from absolute paths. + named := LegacyMountToEntry("my_volume:/container/path") + Expect(named.Name).NotTo(Equal(a.Name)) + Expect(named.HostPath).To(Equal("my_volume")) + + // The synthesized entry is a valid docker-local entry that passes + // Validate (other than the legacy- prefix, which Validate accepts). + abs := LegacyMountToEntry("/host/path:/container/path") + Expect(abs.Scheduler).To(Equal(SchedulerDockerLocal)) + Expect(abs.HostPath).To(Equal("/host/path")) + Expect(abs.Validate()).To(Succeed()) +} diff --git a/plugins/storage/go.mod b/plugins/storage/go.mod index 8aa062bb0..9713c5570 100644 --- a/plugins/storage/go.mod +++ b/plugins/storage/go.mod @@ -1,11 +1,11 @@ module github.com/dokku/dokku/plugins/storage -go 1.25.5 +go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 github.com/dokku/dokku/plugins/docker-options v0.0.0-00010101000000-000000000000 - github.com/onsi/gomega v1.39.1 + github.com/onsi/gomega v1.40.0 github.com/spf13/pflag v1.0.10 ) @@ -30,6 +30,7 @@ require ( golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect + mvdan.cc/sh/v3 v3.13.1 // indirect ) replace github.com/dokku/dokku/plugins/common => ../common diff --git a/plugins/storage/go.sum b/plugins/storage/go.sum index f356a46b2..f81958888 100644 --- a/plugins/storage/go.sum +++ b/plugins/storage/go.sum @@ -1,35 +1,31 @@ -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/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/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= 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 +36,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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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 +48,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 +59,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= -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= +mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= +mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/plugins/storage/migrate.go b/plugins/storage/migrate.go new file mode 100644 index 000000000..62fae8d8c --- /dev/null +++ b/plugins/storage/migrate.go @@ -0,0 +1,206 @@ +package storage + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/dokku/dokku/plugins/common" + dockeroptions "github.com/dokku/dokku/plugins/docker-options" +) + +// migrationFlagDir contains per-app flag files written after a successful +// per-app migration; presence means the app's legacy -v lines have been +// drained into the attachment store. Lives under the registry directory +// so it doesn't collide with the property-store path +// `config/storage/`. +func migrationFlagDir() string { + return filepath.Join(RegistryDirectory(), "migrations") +} + +func migrationFlagFile(appName string) string { + return filepath.Join(migrationFlagDir(), appName) +} + +// MigrateApp re-runs the legacy migration for a single app, ignoring +// the per-app flag file so an operator can force-rescan an app whose +// docker-options state changed after the bulk install-time pass. +// Used by `dokku storage:migrate `. +func MigrateApp(appName string) error { + if err := os.MkdirAll(migrationFlagDir(), 0755); err != nil { + return fmt.Errorf("unable to create migration flag dir: %w", err) + } + _ = os.Remove(migrationFlagFile(appName)) + if err := migrateApp(appName); err != nil { + return fmt.Errorf("storage migration failed for app %q: %w", appName, err) + } + return nil +} + +// MigrateLegacyMounts walks every app and converts its legacy `-v` +// docker-options entries into named storage entries plus attachments. +// Idempotent: per-app flag files short-circuit re-runs. +func MigrateLegacyMounts() error { + if err := os.MkdirAll(migrationFlagDir(), 0755); err != nil { + return fmt.Errorf("unable to create migration flag dir: %w", err) + } + + apps, err := common.DokkuApps() + if err != nil { + if errors.Is(err, common.NoAppsExist) { + return nil + } + return err + } + + for _, app := range apps { + if _, err := os.Stat(migrationFlagFile(app)); err == nil { + continue + } + if err := migrateApp(app); err != nil { + return fmt.Errorf("storage migration failed for app %q: %w", app, err) + } + } + return nil +} + +// migrateApp performs the per-app migration. Order is intentional: write +// the entry, write the attachment, drain the docker-options line. Any +// step failing aborts before drain, so the original behavior is +// preserved on partial failure. +func migrateApp(appName string) error { + deployLines, err := dockeroptions.GetDockerOptionsForPhase(appName, PhaseDeploy) + if err != nil { + return err + } + runLines, err := dockeroptions.GetDockerOptionsForPhase(appName, PhaseRun) + if err != nil { + return err + } + + deployMounts := filterMountLines(deployLines) + runMounts := filterMountLines(runLines) + + // Group identical mount strings across phases. + phaseMap := map[string][]string{} + for _, mount := range deployMounts { + phaseMap[mount] = appendUnique(phaseMap[mount], PhaseDeploy) + } + for _, mount := range runMounts { + phaseMap[mount] = appendUnique(phaseMap[mount], PhaseRun) + } + + mounts := make([]string, 0, len(phaseMap)) + for mount := range phaseMap { + mounts = append(mounts, mount) + } + sort.Strings(mounts) + + for _, mount := range mounts { + if err := migrateMount(appName, mount, phaseMap[mount]); err != nil { + return err + } + } + + return touchMigrationFlag(appName) +} + +func migrateMount(appName string, mount string, phases []string) error { + parsed := ParseMountPath(mount) + entry := LegacyMountToEntry(mount) + + if EntryExists(entry.Name) { + existing, err := LoadEntry(entry.Name) + if err != nil { + return err + } + if existing.HostPath != entry.HostPath || existing.Scheduler != entry.Scheduler { + return fmt.Errorf("legacy entry %q already exists with conflicting fields", entry.Name) + } + } else { + if err := SaveEntry(entry); err != nil { + return err + } + } + + containerPath := parsed.ContainerPath + if containerPath == "" { + // Defensive: legacy mounts must have a container path; skip if not. + return nil + } + + attachment := &Attachment{ + EntryName: entry.Name, + ContainerPath: containerPath, + Phases: phases, + ProcessType: DefaultProcessType, + } + if parsed.VolumeOptions != "" { + if parsed.VolumeOptions == "ro" { + attachment.Readonly = true + } else { + attachment.VolumeOptions = parsed.VolumeOptions + } + } + + existing, err := LoadAttachments(appName) + if err != nil { + return err + } + if !attachmentExists(existing, attachment) { + existing = append(existing, attachment) + if err := SaveAttachments(appName, existing); err != nil { + return err + } + } + + mountedPhases := []string{} + for _, phase := range phases { + mountedPhases = append(mountedPhases, phase) + } + if err := dockeroptions.RemoveDockerOptionFromPhases(appName, mountedPhases, fmt.Sprintf("-v %s", mount)); err != nil { + return err + } + return nil +} + +func touchMigrationFlag(appName string) error { + f, err := os.Create(migrationFlagFile(appName)) + if err != nil { + return err + } + return f.Close() +} + +func filterMountLines(lines []string) []string { + out := []string{} + for _, line := range lines { + if strings.HasPrefix(line, "-v ") { + out = append(out, strings.TrimPrefix(line, "-v ")) + } + } + return out +} + +func appendUnique(slice []string, value string) []string { + for _, existing := range slice { + if existing == value { + return slice + } + } + return append(slice, value) +} + +func attachmentExists(attachments []*Attachment, candidate *Attachment) bool { + for _, existing := range attachments { + if existing.EntryName == candidate.EntryName && + existing.ContainerPath == candidate.ContainerPath && + existing.ProcessType == candidate.ProcessType { + return true + } + } + return false +} diff --git a/plugins/storage/migrate_test.go b/plugins/storage/migrate_test.go new file mode 100644 index 000000000..41188e246 --- /dev/null +++ b/plugins/storage/migrate_test.go @@ -0,0 +1,317 @@ +package storage + +import ( + "os" + "os/user" + "path/filepath" + "sort" + "testing" + + "github.com/dokku/dokku/plugins/common" + dockeroptions "github.com/dokku/dokku/plugins/docker-options" +) + +// setupMigrationEnv points DOKKU_LIB_ROOT and DOKKU_ROOT at temp dirs and +// tells the permission helpers to chown to the current user (a no-op), +// so the test works without root and without the dokku system user. +// Mirrors the helper in docker-options/migration_test.go. +func setupMigrationEnv(t *testing.T) (libRoot string, dokkuRoot string) { + t.Helper() + libRoot = t.TempDir() + dokkuRoot = t.TempDir() + + t.Setenv("DOKKU_LIB_ROOT", libRoot) + t.Setenv("DOKKU_ROOT", dokkuRoot) + t.Setenv("PLUGIN_PATH", filepath.Join(libRoot, "plugins")) + + current, err := user.Current() + if err != nil { + t.Fatalf("user.Current: %v", err) + } + group, err := user.LookupGroupId(current.Gid) + if err != nil { + t.Fatalf("user.LookupGroupId: %v", err) + } + t.Setenv("DOKKU_SYSTEM_USER", current.Username) + t.Setenv("DOKKU_SYSTEM_GROUP", group.Name) + + // MigrateLegacyMounts creates the flag dir before iterating apps; + // migrateApp (the per-app helper) assumes it already exists. Pre- + // create it so tests that call migrateApp directly don't trip. + if err := os.MkdirAll(migrationFlagDir(), 0755); err != nil { + t.Fatalf("mkdir migration flag dir: %v", err) + } + + return libRoot, dokkuRoot +} + +// stageApp registers the named app under DOKKU_ROOT so DokkuApps can +// see it, and seeds its docker-options property files for the requested +// phases. Each map entry is a phase ("deploy", "run", "build") whose +// value is the full property-list contents (one option per line). +func stageApp(t *testing.T, dokkuRoot string, app string, options map[string][]string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dokkuRoot, app), 0755); err != nil { + t.Fatalf("mkdir app: %v", err) + } + for phase, lines := range options { + if err := dockeroptions.AddDockerOptionToPhases(app, []string{phase}, ""); err != nil { + t.Fatalf("seed property file: %v", err) + } + // Replace whatever AddDockerOptionToPhases left with the precise + // list the test wants, since it appends rather than overwriting. + key := "_default_." + phase + if err := common.PropertyListWrite("docker-options", app, key, lines); err != nil { + t.Fatalf("PropertyListWrite: %v", err) + } + } +} + +func phaseOptions(t *testing.T, app string, phase string) []string { + t.Helper() + got, err := dockeroptions.GetDockerOptionsForPhase(app, phase) + if err != nil { + t.Fatalf("GetDockerOptionsForPhase %s/%s: %v", app, phase, err) + } + return got +} + +func TestMigrateAppSinglePhase(t *testing.T) { + _, dokkuRoot := setupMigrationEnv(t) + stageApp(t, dokkuRoot, "alpha", map[string][]string{ + "deploy": {"-v /var/log:/log", "--restart=on-failure:5"}, + }) + + if err := migrateApp("alpha"); err != nil { + t.Fatalf("migrateApp: %v", err) + } + + entry := LegacyMountToEntry("/var/log:/log") + if !EntryExists(entry.Name) { + t.Fatalf("expected entry %s on disk", entry.Name) + } + loaded, err := LoadEntry(entry.Name) + if err != nil { + t.Fatalf("LoadEntry: %v", err) + } + if loaded.HostPath != "/var/log" { + t.Errorf("entry HostPath = %s, want /var/log", loaded.HostPath) + } + if loaded.Scheduler != SchedulerDockerLocal { + t.Errorf("entry Scheduler = %s, want %s", loaded.Scheduler, SchedulerDockerLocal) + } + + attachments, err := LoadAttachments("alpha") + if err != nil { + t.Fatalf("LoadAttachments: %v", err) + } + if len(attachments) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(attachments)) + } + att := attachments[0] + if att.EntryName != entry.Name || att.ContainerPath != "/log" { + t.Errorf("attachment = %+v, want entry=%s container=/log", att, entry.Name) + } + if len(att.Phases) != 1 || att.Phases[0] != PhaseDeploy { + t.Errorf("attachment phases = %v, want [deploy]", att.Phases) + } + + // docker-options drained the -v line but kept everything else. + deployAfter := phaseOptions(t, "alpha", "deploy") + if !equalSorted(deployAfter, []string{"--restart=on-failure:5"}) { + t.Errorf("deploy options after migration = %v, want [--restart=on-failure:5]", deployAfter) + } + + // Migration flag file is in place. + if _, err := os.Stat(migrationFlagFile("alpha")); err != nil { + t.Errorf("migration flag missing: %v", err) + } +} + +func TestMigrateAppCrossPhaseGroupsIntoOneAttachment(t *testing.T) { + _, dokkuRoot := setupMigrationEnv(t) + // Same -v in both phases groups into one attachment with phases = [deploy, run]. + stageApp(t, dokkuRoot, "alpha", map[string][]string{ + "deploy": {"-v /data:/d"}, + "run": {"-v /data:/d"}, + }) + + if err := migrateApp("alpha"); err != nil { + t.Fatalf("migrateApp: %v", err) + } + + attachments, err := LoadAttachments("alpha") + if err != nil { + t.Fatalf("LoadAttachments: %v", err) + } + if len(attachments) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(attachments)) + } + got := attachments[0].Phases + sort.Strings(got) + if !equalSorted(got, []string{PhaseDeploy, PhaseRun}) { + t.Errorf("phases = %v, want [deploy run]", got) + } + + // Drain affected both phases. + if got := phaseOptions(t, "alpha", "deploy"); len(got) != 0 { + t.Errorf("deploy options after migration = %v, want []", got) + } + if got := phaseOptions(t, "alpha", "run"); len(got) != 0 { + t.Errorf("run options after migration = %v, want []", got) + } +} + +func TestMigrateAppPreservesVolumeOptions(t *testing.T) { + _, dokkuRoot := setupMigrationEnv(t) + stageApp(t, dokkuRoot, "alpha", map[string][]string{ + "deploy": {"-v /ro:/r:ro", "-v /label:/l:Z"}, + }) + + if err := migrateApp("alpha"); err != nil { + t.Fatalf("migrateApp: %v", err) + } + + atts, err := LoadAttachments("alpha") + if err != nil { + t.Fatalf("LoadAttachments: %v", err) + } + byPath := map[string]*Attachment{} + for _, a := range atts { + byPath[a.ContainerPath] = a + } + + if a := byPath["/r"]; a == nil { + t.Fatalf("missing /r attachment") + } else if !a.Readonly || a.VolumeOptions != "" { + t.Errorf("/r attachment = readonly=%v opts=%q, want readonly=true opts=\"\"", a.Readonly, a.VolumeOptions) + } + + if a := byPath["/l"]; a == nil { + t.Fatalf("missing /l attachment") + } else if a.Readonly || a.VolumeOptions != "Z" { + t.Errorf("/l attachment = readonly=%v opts=%q, want readonly=false opts=\"Z\"", a.Readonly, a.VolumeOptions) + } +} + +func TestMigrateAppIdempotent(t *testing.T) { + _, dokkuRoot := setupMigrationEnv(t) + stageApp(t, dokkuRoot, "alpha", map[string][]string{ + "deploy": {"-v /var/log:/log"}, + }) + + if err := migrateApp("alpha"); err != nil { + t.Fatalf("first migrateApp: %v", err) + } + + // Re-stage the same -v line by hand to simulate someone re-adding + // the legacy form via docker-options:add after migration. The + // migration is gated by the per-app flag file at the + // MigrateLegacyMounts level, but migrateApp itself should still be + // safe to call directly: a second run produces no duplicates. + if err := common.PropertyListWrite("docker-options", "alpha", "_default_.deploy", []string{"-v /var/log:/log"}); err != nil { + t.Fatalf("re-stage: %v", err) + } + if err := migrateApp("alpha"); err != nil { + t.Fatalf("second migrateApp: %v", err) + } + + atts, err := LoadAttachments("alpha") + if err != nil { + t.Fatalf("LoadAttachments: %v", err) + } + if len(atts) != 1 { + t.Errorf("expected 1 attachment after rerun, got %d", len(atts)) + } +} + +func TestMigrateLegacyMountsBulkAndFlagFastPath(t *testing.T) { + _, dokkuRoot := setupMigrationEnv(t) + stageApp(t, dokkuRoot, "alpha", map[string][]string{ + "deploy": {"-v /alpha-data:/data"}, + }) + stageApp(t, dokkuRoot, "beta", map[string][]string{ + "deploy": {"-v /beta-data:/data"}, + "run": {"-v /beta-data:/data"}, + }) + + if err := MigrateLegacyMounts(); err != nil { + t.Fatalf("MigrateLegacyMounts: %v", err) + } + + for _, app := range []string{"alpha", "beta"} { + if _, err := os.Stat(migrationFlagFile(app)); err != nil { + t.Errorf("flag file missing for %s: %v", app, err) + } + atts, err := LoadAttachments(app) + if err != nil { + t.Fatalf("LoadAttachments %s: %v", app, err) + } + if len(atts) != 1 { + t.Errorf("%s: expected 1 attachment, got %d", app, len(atts)) + } + } + + // Re-stage a -v on alpha and rerun MigrateLegacyMounts. The flag + // file short-circuits per-app migration, so the new line stays in + // docker-options. + if err := common.PropertyListWrite("docker-options", "alpha", "_default_.deploy", []string{"-v /sneaky:/x"}); err != nil { + t.Fatalf("re-stage alpha: %v", err) + } + if err := MigrateLegacyMounts(); err != nil { + t.Fatalf("MigrateLegacyMounts (rerun): %v", err) + } + got := phaseOptions(t, "alpha", "deploy") + if !equalSorted(got, []string{"-v /sneaky:/x"}) { + t.Errorf("expected sneaky -v line untouched after second pass, got %v", got) + } + atts, err := LoadAttachments("alpha") + if err != nil { + t.Fatalf("LoadAttachments alpha rerun: %v", err) + } + if len(atts) != 1 { + t.Errorf("expected 1 attachment after rerun, got %d", len(atts)) + } +} + +func TestMigrateAppRefusesEntryNameCollision(t *testing.T) { + libRoot, dokkuRoot := setupMigrationEnv(t) + _ = libRoot + + // Plant a colliding entry at the legacy- name with conflicting + // fields so migration's "fields match" check trips. + collide := LegacyMountToEntry("/var/log:/log") + collide.HostPath = "/something/else" + if err := SaveEntry(collide); err != nil { + t.Fatalf("seed colliding entry: %v", err) + } + + stageApp(t, dokkuRoot, "alpha", map[string][]string{ + "deploy": {"-v /var/log:/log"}, + }) + + if err := migrateApp("alpha"); err == nil { + t.Fatalf("expected migrateApp to error on entry name collision") + } + + // Drain did not happen. + if got := phaseOptions(t, "alpha", "deploy"); len(got) != 1 || got[0] != "-v /var/log:/log" { + t.Errorf("expected legacy line preserved on collision, got %v", got) + } +} + +func equalSorted(a, b []string) bool { + if len(a) != len(b) { + return false + } + ax := append([]string(nil), a...) + bx := append([]string(nil), b...) + sort.Strings(ax) + sort.Strings(bx) + for i := range ax { + if ax[i] != bx[i] { + return false + } + } + return true +} diff --git a/plugins/storage/report.go b/plugins/storage/report.go index cdb46a3d5..d29c42e4e 100644 --- a/plugins/storage/report.go +++ b/plugins/storage/report.go @@ -3,14 +3,21 @@ package storage import "github.com/dokku/dokku/plugins/common" 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{ - "--storage-build-mounts": reportBuildMounts, - "--storage-deploy-mounts": reportDeployMounts, - "--storage-run-mounts": reportRunMounts, + var flags map[string]common.ReportFunc + if appName == "--global" { + flags = map[string]common.ReportFunc{} + } else { + flags = map[string]common.ReportFunc{ + "--storage-build-mounts": reportBuildMounts, + "--storage-deploy-mounts": reportDeployMounts, + "--storage-run-mounts": reportRunMounts, + } } flagKeys := []string{} diff --git a/plugins/storage/src/commands/commands.go b/plugins/storage/src/commands/commands.go index 8ad0b2de8..a026f0709 100644 --- a/plugins/storage/src/commands/commands.go +++ b/plugins/storage/src/commands/commands.go @@ -18,11 +18,19 @@ Manage mounted volumes Additional commands:` helpContent = ` - storage:ensure-directory [--chown option] , Creates a persistent storage directory in the recommended storage path + storage:create [] [flags], Register a named storage entry + storage:destroy , Remove a named storage entry (must be unmounted from every app first) + storage:ensure-directory [--chown option] , [DEPRECATED] use storage:create instead + storage:exec [-- ...], Run a command (or shell) in a temporary container that mounts the entry + storage:info [--format text|json], Show details for one storage entry storage:list [--format text|json], List bind mounts for app's container(s) (host:container) + storage:list-entries [--scheduler s] [--format text|json], List registered storage entries + storage:migrate [|--all], Re-run the legacy -v to attachment migration for an app storage:mount , Create a new bind mount storage:report [] [], Displays a storage report for one or more apps - storage:unmount , Remove an existing bind mount` + storage:set [flags], Update a storage entry in place + storage:unmount , Remove an existing bind mount + storage:wait , Wait for a storage entry's PVC to be bound (k3s)` ) func main() { diff --git a/plugins/storage/src/subcommands/subcommands.go b/plugins/storage/src/subcommands/subcommands.go index 87f00c831..3a5bb8d6b 100644 --- a/plugins/storage/src/subcommands/subcommands.go +++ b/plugins/storage/src/subcommands/subcommands.go @@ -19,39 +19,173 @@ func main() { switch subcommand { case "default": err = storage.CommandHelp() + case "create": + args := flag.NewFlagSet("storage:create", flag.ExitOnError) + scheduler := args.String("scheduler", storage.SchedulerDockerLocal, "--scheduler: target scheduler (docker-local, k3s)") + size := args.String("size", "", "--size: PVC size (k3s only, e.g. 2Gi)") + accessMode := args.String("access-mode", "", "--access-mode: PVC access mode (k3s only)") + storageClass := args.String("storage-class-name", "", "--storage-class-name: PVC storage class (k3s only)") + namespace := args.String("namespace", "", "--namespace: PVC namespace (k3s only)") + chown := args.String("chown", "", "--chown: chown option (docker-local only)") + reclaim := args.String("reclaim-policy", "", "--reclaim-policy: PV reclaim policy (Retain or Delete, k3s only)") + annotations := args.StringSlice("annotation", nil, "--annotation key=value: PVC annotation (repeatable)") + labels := args.StringSlice("label", nil, "--label key=value: PVC label (repeatable)") + args.Parse(os.Args[2:]) + name := args.Arg(0) + path := args.Arg(1) + annotMap, parseErr := parseKVPairs(*annotations) + if parseErr != nil { + err = parseErr + break + } + labelMap, parseErr := parseKVPairs(*labels) + if parseErr != nil { + err = parseErr + break + } + err = storage.CommandCreate(storage.CommandCreateInput{ + Name: name, + Path: path, + Scheduler: *scheduler, + Size: *size, + AccessMode: *accessMode, + StorageClass: *storageClass, + Namespace: *namespace, + Chown: *chown, + ReclaimPolicy: *reclaim, + Annotations: annotMap, + Labels: labelMap, + }) + case "destroy": + args := flag.NewFlagSet("storage:destroy", flag.ExitOnError) + args.Parse(os.Args[2:]) + err = storage.CommandDestroy(args.Arg(0)) case "ensure-directory": args := flag.NewFlagSet("storage:ensure-directory", flag.ExitOnError) chown := args.String("chown", "herokuish", "--chown: chown option (herokuish, heroku, paketo, root, false)") args.Parse(os.Args[2:]) directory := args.Arg(0) + common.LogWarn("storage:ensure-directory is deprecated; use storage:create instead.") err = storage.CommandEnsureDirectory(directory, *chown) + case "info": + args := flag.NewFlagSet("storage:info", flag.ExitOnError) + format := args.String("format", "text", "--format: output format (text, json)") + args.Parse(os.Args[2:]) + err = storage.CommandInfo(args.Arg(0), *format) case "list": args := flag.NewFlagSet("storage:list", flag.ExitOnError) format := args.String("format", "text", "--format: output format (text, json)") args.Parse(os.Args[2:]) appName := args.Arg(0) err = storage.CommandList(appName, *format) + case "set": + args := flag.NewFlagSet("storage:set", flag.ExitOnError) + size := args.String("size", "", "--size: new PVC size (k3s)") + accessMode := args.String("access-mode", "", "--access-mode: existing access mode (must match)") + storageClass := args.String("storage-class-name", "", "--storage-class-name: existing storage class (must match)") + namespace := args.String("namespace", "", "--namespace: new namespace") + chown := args.String("chown", "", "--chown: chown option") + reclaim := args.String("reclaim-policy", "", "--reclaim-policy: PV reclaim policy") + annotations := args.StringSlice("annotation", nil, "--annotation key=value: PVC annotation (repeatable, replaces all)") + labels := args.StringSlice("label", nil, "--label key=value: PVC label (repeatable, replaces all)") + args.Parse(os.Args[2:]) + annotMap, parseErr := parseKVPairs(*annotations) + if parseErr != nil { + err = parseErr + break + } + labelMap, parseErr := parseKVPairs(*labels) + if parseErr != nil { + err = parseErr + break + } + err = storage.CommandSet(storage.CommandSetInput{ + Name: args.Arg(0), + Size: *size, + AccessMode: *accessMode, + StorageClass: *storageClass, + Namespace: *namespace, + Chown: *chown, + ReclaimPolicy: *reclaim, + Annotations: annotMap, + Labels: labelMap, + }) + case "exec": + args := flag.NewFlagSet("storage:exec", flag.ExitOnError) + image := args.String("image", "", "--image: container image to use (default alpine:3)") + asUser := args.String("as-user", "", "--as-user: numeric uid to run the exec container as (overrides the entry's chown)") + args.Parse(os.Args[2:]) + positional := args.Args() + if len(positional) == 0 { + err = fmt.Errorf("storage:exec requires a storage entry name") + break + } + name := positional[0] + var cmd []string + if len(positional) > 1 { + cmd = positional[1:] + } + err = storage.CommandExec(storage.CommandExecInput{ + Name: name, + Image: *image, + AsUser: *asUser, + Args: cmd, + }) + case "migrate": + args := flag.NewFlagSet("storage:migrate", flag.ExitOnError) + all := args.Bool("all", false, "--all: re-migrate every app on this install") + args.Parse(os.Args[2:]) + err = storage.CommandMigrate(args.Arg(0), *all) + case "wait": + args := flag.NewFlagSet("storage:wait", flag.ExitOnError) + args.Parse(os.Args[2:]) + err = storage.CommandWait(args.Arg(0)) + case "list-entries": + args := flag.NewFlagSet("storage:list-entries", flag.ExitOnError) + scheduler := args.String("scheduler", "", "--scheduler: filter to a single scheduler") + format := args.String("format", "text", "--format: output format (text, json)") + args.Parse(os.Args[2:]) + err = storage.CommandListEntries(*scheduler, *format) case "mount": args := flag.NewFlagSet("storage:mount", flag.ExitOnError) + containerDir := args.String("container-dir", "", "--container-dir: container path (named-entry form)") + phases := args.StringSlice("phase", nil, "--phase: phase to mount in (deploy, run; default both)") + processType := args.String("process-type", "", "--process-type: process type to mount for") + subpath := args.String("volume-subpath", "", "--volume-subpath: subpath within the entry") + readonly := args.Bool("volume-readonly", false, "--volume-readonly: mount the volume read-only") + volumeChown := args.String("volume-chown", "", "--volume-chown: chown option applied at mount time") args.Parse(os.Args[2:]) - appName := args.Arg(0) - mountPath := args.Arg(1) - err = storage.CommandMount(appName, mountPath) + err = storage.CommandMount(storage.CommandMountInput{ + AppName: args.Arg(0), + NameOrPath: args.Arg(1), + ContainerDir: *containerDir, + Phases: *phases, + ProcessType: *processType, + Subpath: *subpath, + Readonly: *readonly, + VolumeChown: *volumeChown, + }) case "report": args := flag.NewFlagSet("storage:report", flag.ExitOnError) format := args.String("format", "stdout", "format: [ stdout | json ]") - osArgs, infoFlag, flagErr := common.ParseReportArgs("storage", os.Args[2:]) + reportArgs, flagErr := common.ParseReportArgs("storage", os.Args[2:]) if flagErr == nil { - args.Parse(osArgs) + args.Parse(reportArgs.OSArgs) appName := args.Arg(0) - err = storage.CommandReport(appName, *format, infoFlag) + if reportArgs.IsGlobal { + appName = "--global" + } + err = storage.CommandReport(appName, *format, reportArgs.InfoFlag) } case "unmount": args := flag.NewFlagSet("storage:unmount", flag.ExitOnError) + containerDir := args.String("container-dir", "", "--container-dir: container path (named-entry form, disambiguates duplicates)") args.Parse(os.Args[2:]) - appName := args.Arg(0) - mountPath := args.Arg(1) - err = storage.CommandUnmount(appName, mountPath) + err = storage.CommandUnmount(storage.CommandUnmountInput{ + AppName: args.Arg(0), + NameOrPath: args.Arg(1), + ContainerDir: *containerDir, + }) default: err = fmt.Errorf("Invalid plugin subcommand call: %s", subcommand) } @@ -60,3 +194,18 @@ func main() { common.LogFailWithError(err) } } + +func parseKVPairs(pairs []string) (map[string]string, error) { + if len(pairs) == 0 { + return nil, nil + } + out := map[string]string{} + for _, pair := range pairs { + idx := strings.Index(pair, "=") + if idx <= 0 { + return nil, fmt.Errorf("expected key=value, got %q", pair) + } + out[pair[:idx]] = pair[idx+1:] + } + return out, nil +} diff --git a/plugins/storage/src/triggers/triggers.go b/plugins/storage/src/triggers/triggers.go index 3d2dd3ebf..565891290 100644 --- a/plugins/storage/src/triggers/triggers.go +++ b/plugins/storage/src/triggers/triggers.go @@ -24,6 +24,27 @@ func main() { phase := flag.Arg(1) format := flag.Arg(2) err = storage.TriggerStorageList(appName, phase, format) + case "storage-app-mounts": + appName := flag.Arg(0) + phase := flag.Arg(1) + err = storage.TriggerStorageAppMounts(appName, phase) + case "docker-args-deploy": + appName := flag.Arg(0) + err = storage.TriggerDockerArgs(appName, storage.PhaseDeploy) + case "docker-args-run": + appName := flag.Arg(0) + err = storage.TriggerDockerArgs(appName, storage.PhaseRun) + case "post-delete": + appName := flag.Arg(0) + err = storage.TriggerPostDelete(appName) + case "post-app-clone-setup": + oldName := flag.Arg(0) + newName := flag.Arg(1) + err = storage.TriggerPostAppCloneSetup(oldName, newName) + case "post-app-rename-setup": + oldName := flag.Arg(0) + newName := flag.Arg(1) + err = storage.TriggerPostAppRenameSetup(oldName, newName) default: err = fmt.Errorf("Invalid plugin trigger call: %s", trigger) } diff --git a/plugins/storage/storage.go b/plugins/storage/storage.go index 386cec11d..3054688be 100644 --- a/plugins/storage/storage.go +++ b/plugins/storage/storage.go @@ -51,19 +51,18 @@ func CheckIfPathExists(appName string, mountPath string, phases []string) bool { return false } -// GetBindMounts returns the bind mounts for an app and phase +// GetBindMounts returns the bind mounts for an app and phase, synthesized +// from the attachment store. The returned strings use the legacy colon +// form (host:container[:options]) so existing display paths keep +// working unchanged. func GetBindMounts(appName string, phase string) ([]string, error) { - mounts := []string{} - options, err := dockeroptions.GetDockerOptionsForPhase(appName, phase) + entries, err := ListAppMountEntries(appName, phase) if err != nil { - return mounts, err + return nil, err } - - for _, option := range options { - if strings.HasPrefix(option, "-v ") { - mount := strings.TrimPrefix(option, "-v ") - mounts = append(mounts, mount) - } + mounts := make([]string, 0, len(entries)) + for _, entry := range entries { + mounts = append(mounts, formatStorageListEntry(entry)) } return mounts, nil } @@ -82,13 +81,71 @@ func GetBindMountsForDisplay(appName string, phase string) string { return strings.Join(result, " ") } -// StorageListEntry represents a storage mount entry for JSON output +// StorageListEntry represents a storage mount entry for JSON output. type StorageListEntry struct { + EntryName string `json:"entry_name,omitempty"` HostPath string `json:"host_path"` ContainerPath string `json:"container_path"` VolumeOptions string `json:"volume_options"` } +// ListAppMountEntries returns one StorageListEntry per attachment on +// an app for the requested phase, joining each attachment with its +// referenced storage entry. Used by storage:list and the deprecated +// storage-list trigger. +func ListAppMountEntries(appName string, phase string) ([]StorageListEntry, error) { + if phase == "" { + phase = PhaseDeploy + } + attachments, err := AttachmentsForPhase(appName, phase) + if err != nil { + return nil, err + } + + rows := make([]StorageListEntry, 0, len(attachments)) + for _, attachment := range attachments { + entry, err := LoadEntry(attachment.EntryName) + if err != nil { + return nil, fmt.Errorf("attachment on %q references missing entry %q: %w", appName, attachment.EntryName, err) + } + + host := entry.HostPath + if host == "" { + // k3s-only entries with no host path: surface the entry + // name as the host token so the colon-form output is + // well-formed and parseable. + host = entry.Name + } + + options := "" + switch { + case attachment.Readonly && attachment.VolumeOptions != "": + options = "ro," + attachment.VolumeOptions + case attachment.Readonly: + options = "ro" + case attachment.VolumeOptions != "": + options = attachment.VolumeOptions + } + + rows = append(rows, StorageListEntry{ + EntryName: entry.Name, + HostPath: host, + ContainerPath: attachment.ContainerPath, + VolumeOptions: options, + }) + } + return rows, nil +} + +// formatStorageListEntry renders a StorageListEntry into the legacy +// host:container[:options] colon form for textual output. +func formatStorageListEntry(entry StorageListEntry) string { + if entry.VolumeOptions == "" { + return fmt.Sprintf("%s:%s", entry.HostPath, entry.ContainerPath) + } + return fmt.Sprintf("%s:%s:%s", entry.HostPath, entry.ContainerPath, entry.VolumeOptions) +} + // ParseMountPath parses a mount path into its components func ParseMountPath(mountPath string) StorageListEntry { parts := strings.SplitN(mountPath, ":", 3) diff --git a/plugins/storage/subcommands.go b/plugins/storage/subcommands.go index 4ff579a65..2e92125ad 100644 --- a/plugins/storage/subcommands.go +++ b/plugins/storage/subcommands.go @@ -1,6 +1,7 @@ package storage import ( + "encoding/json" "errors" "fmt" "os" @@ -8,7 +9,6 @@ import ( "strings" "github.com/dokku/dokku/plugins/common" - dockeroptions "github.com/dokku/dokku/plugins/docker-options" ) const ( @@ -19,11 +19,19 @@ Manage mounted volumes Additional commands:` helpContent = ` - storage:ensure-directory [--chown option] , Creates a persistent storage directory in the recommended storage path + storage:create [] [flags], Register a named storage entry + storage:destroy , Remove a named storage entry (must be unmounted from every app first) + storage:ensure-directory [--chown option] , [DEPRECATED] use storage:create instead + storage:exec [-- ...], Run a command (or shell) in a temporary container that mounts the entry + storage:info [--format text|json], Show details for one storage entry storage:list [--format text|json], List bind mounts for app's container(s) (host:container) + storage:list-entries [--scheduler s] [--format text|json], List registered storage entries + storage:migrate [|--all], Re-run the legacy -v to attachment migration for an app storage:mount , Create a new bind mount storage:report [] [], Displays a storage report for one or more apps - storage:unmount , Remove an existing bind mount` + storage:set [flags], Update a storage entry in place + storage:unmount , Remove an existing bind mount + storage:wait , Wait for a storage entry's PVC to be bound (k3s)` ) // CommandHelp displays help for the storage plugin @@ -38,7 +46,7 @@ func CommandEnsureDirectory(directory string, chownFlag string) error { return err } - chownID, err := resolveChownID(chownFlag) + chownID, err := ResolveChownID(chownFlag) if err != nil { return err } @@ -72,8 +80,8 @@ func CommandEnsureDirectory(directory string, chownFlag string) error { return nil } -// resolveChownID converts a chown flag value to a numeric UID -func resolveChownID(chownFlag string) (string, error) { +// ResolveChownID converts a chown flag value to a numeric UID +func ResolveChownID(chownFlag string) (string, error) { var chownID string switch chownFlag { @@ -121,41 +129,167 @@ func isUserNamespacesEnabled() (bool, error) { return strings.TrimSpace(result.StdoutContents()) == "true", nil } -// CommandMount creates a new bind mount for an app -func CommandMount(appName string, mountPath string) error { - if err := common.VerifyAppName(appName); err != nil { +// CommandMountInput captures the optional flags accepted by storage:mount +// when the second argument is a named entry rather than a colon-form path. +type CommandMountInput struct { + AppName string + NameOrPath string + ContainerDir string + Phases []string + ProcessType string + Subpath string + Readonly bool + VolumeOptions string + VolumeChown string +} + +// CommandMount creates a new bind mount for an app. The second positional +// argument may be either a legacy host:container[:options] string (kept +// for back-compat on docker-local) or a registered storage entry name. +func CommandMount(input CommandMountInput) error { + if err := common.VerifyAppName(input.AppName); err != nil { return err } + // Legacy colon form: synthesize a legacy- entry plus an + // attachment so storage:list (now attachment-only) sees the mount. + // The storage docker-args trigger emits the corresponding -v flag at + // deploy time, so behavior at the docker-run boundary is unchanged. + if strings.Contains(input.NameOrPath, ":") { + return mountLegacyColon(input.AppName, input.NameOrPath) + } + + // Named-entry form: persist as an attachment. + if !EntryExists(input.NameOrPath) { + return fmt.Errorf("storage entry %q does not exist; create it first with `dokku storage:create`", input.NameOrPath) + } + if input.ContainerDir == "" { + return errors.New("--container-dir is required when mounting a named storage entry") + } + + entry, err := LoadEntry(input.NameOrPath) + if err != nil { + return err + } + + phases := input.Phases + if len(phases) == 0 { + phases = []string{PhaseDeploy, PhaseRun} + } + + processType := input.ProcessType + if processType == "" { + processType = DefaultProcessType + } + + attachment := &Attachment{ + EntryName: entry.Name, + ContainerPath: input.ContainerDir, + Phases: phases, + ProcessType: processType, + Subpath: input.Subpath, + Readonly: input.Readonly, + VolumeOptions: input.VolumeOptions, + VolumeChown: input.VolumeChown, + } + + return AddAttachment(input.AppName, attachment) +} + +// CommandUnmountInput captures the flags accepted by storage:unmount when +// the second argument is a named entry. +type CommandUnmountInput struct { + AppName string + NameOrPath string + ContainerDir string +} + +// CommandUnmount removes an existing bind mount from an app. +func CommandUnmount(input CommandUnmountInput) error { + if err := common.VerifyAppName(input.AppName); err != nil { + return err + } + + if strings.Contains(input.NameOrPath, ":") { + return unmountLegacyColon(input.AppName, input.NameOrPath) + } + + return RemoveAttachment(input.AppName, input.NameOrPath, input.ContainerDir) +} + +// mountLegacyColon translates a `:[:options]` mount +// string into a synthesized legacy- entry plus an attachment. +// Idempotent: re-running the same mount errors with the existing +// "already mounted" message via AddAttachment's duplicate check. +func mountLegacyColon(appName string, mountPath string) error { if err := VerifyPaths(mountPath); err != nil { return err } - - if CheckIfPathExists(appName, mountPath, MountPhases) { - return errors.New("Mount path already exists.") + parsed := ParseMountPath(mountPath) + if parsed.ContainerPath == "" { + return errors.New("Storage path must be two valid paths divided by colon.") } - return dockeroptions.AddDockerOptionToPhases(appName, MountPhases, fmt.Sprintf("-v %s", mountPath)) -} + entry := LegacyMountToEntry(mountPath) + if !EntryExists(entry.Name) { + if err := SaveEntry(entry); err != nil { + return err + } + } -// CommandUnmount removes an existing bind mount from an app -func CommandUnmount(appName string, mountPath string) error { - if err := common.VerifyAppName(appName); err != nil { + attachment := &Attachment{ + EntryName: entry.Name, + ContainerPath: parsed.ContainerPath, + Phases: []string{PhaseDeploy, PhaseRun}, + ProcessType: DefaultProcessType, + } + switch parsed.VolumeOptions { + case "": + case "ro": + attachment.Readonly = true + default: + attachment.VolumeOptions = parsed.VolumeOptions + } + + if err := AddAttachment(appName, attachment); err != nil { + // AddAttachment's duplicate error mentions the entry name, but + // the legacy form historically said "Mount path already + // exists." - preserve that exact wording so existing automation + // and the bats suite keep matching. + if strings.Contains(err.Error(), "is already mounted at") { + return errors.New("Mount path already exists.") + } return err } + return nil +} +// unmountLegacyColon is the inverse of mountLegacyColon. The legacy +// mount string identifies an entry+container-path tuple deterministically +// via LegacyMountToEntry, so we can route to RemoveAttachment. +func unmountLegacyColon(appName string, mountPath string) error { if err := VerifyPaths(mountPath); err != nil { return err } - - if !CheckIfPathExists(appName, mountPath, MountPhases) { - return errors.New("Mount path does not exist.") + parsed := ParseMountPath(mountPath) + if parsed.ContainerPath == "" { + return errors.New("Storage path must be two valid paths divided by colon.") } - return dockeroptions.RemoveDockerOptionFromPhases(appName, MountPhases, fmt.Sprintf("-v %s", mountPath)) + entry := LegacyMountToEntry(mountPath) + if err := RemoveAttachment(appName, entry.Name, parsed.ContainerPath); err != nil { + // Match the legacy wording for "not currently mounted". + if strings.Contains(err.Error(), "is not mounted") { + return errors.New("Mount path does not exist.") + } + return err + } + return nil } -// CommandList lists all bind mounts for an app +// CommandList lists all bind mounts for an app. Reads attachments +// directly from the storage plugin's own state rather than going through +// the deprecated `storage-list` plugn trigger. func CommandList(appName string, format string) error { if err := common.VerifyAppName(appName); err != nil { return err @@ -165,39 +299,45 @@ func CommandList(appName string, format string) error { return errors.New("Invalid --format value specified") } - if format == "text" { - common.LogInfo1Quiet(fmt.Sprintf("%s volume bind-mounts:", appName)) - } - - results, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ - Trigger: "storage-list", - Args: []string{appName, "deploy", format}, - }) + rows, err := ListAppMountEntries(appName, PhaseDeploy) if err != nil { return err } - output := results.StdoutContents() - if format == "text" && output != "" { - lines := strings.Split(strings.TrimSpace(output), "\n") - for _, line := range lines { - if line != "" { - if os.Getenv("DOKKU_QUIET_OUTPUT") != "" { - fmt.Println(line) - } else { - common.LogVerbose(line) - } - } + if format == "json" { + output, err := json.Marshal(rows) + if err != nil { + return err } - } else if output != "" { - fmt.Println(output) + fmt.Println(string(output)) + return nil } + common.LogInfo1Quiet(fmt.Sprintf("%s volume bind-mounts:", appName)) + for _, row := range rows { + line := formatStorageListEntry(row) + if os.Getenv("DOKKU_QUIET_OUTPUT") != "" { + fmt.Println(line) + } else { + common.LogVerbose(line) + } + } return nil } // CommandReport displays a storage report for one or more apps func CommandReport(appName string, format string, infoFlag string) error { + if appName == "--global" { + // Global storage report: list every registered entry and its + // attachment count; falls back to the legacy per-app loop when + // no entries exist so existing automation keeps working. + reportFormat := format + if reportFormat == "stdout" { + reportFormat = "text" + } + return CommandReportGlobal(reportFormat) + } + if appName == "" { apps, err := common.DokkuApps() if err != nil { diff --git a/plugins/storage/triggers.go b/plugins/storage/triggers.go index ac2c4d4e0..270b00812 100644 --- a/plugins/storage/triggers.go +++ b/plugins/storage/triggers.go @@ -6,12 +6,18 @@ import ( "os" "path/filepath" "runtime" + "strings" "github.com/dokku/dokku/plugins/common" ) -// TriggerInstall sets up the storage plugin on installation +// TriggerInstall sets up the storage plugin on installation and runs +// the bulk legacy-mount migration once per upgrade. func TriggerInstall() error { + if err := common.PropertySetup(PluginName); err != nil { + return fmt.Errorf("Unable to install the storage plugin: %s", err.Error()) + } + storageDir := GetStorageDirectory() if err := os.MkdirAll(storageDir, 0755); err != nil { @@ -25,6 +31,29 @@ func TriggerInstall() error { return fmt.Errorf("Unable to set storage directory permissions: %s", err.Error()) } + if err := EnsureEntriesDirectory(); err != nil { + return err + } + + // The install trigger runs as root; the directories it just made are + // root-owned, but the dokku user is the one that runs storage:create + // and writes entry / migration-flag files into them. + for _, dir := range []string{RegistryDirectory(), EntriesDirectory(), migrationFlagDir()} { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("Unable to create %s: %s", dir, err.Error()) + } + if err := common.SetPermissions(common.SetPermissionInput{ + Filename: dir, + Mode: 0755, + }); err != nil { + return fmt.Errorf("Unable to set permissions on %s: %s", dir, err.Error()) + } + } + + if err := MigrateLegacyMounts(); err != nil { + return fmt.Errorf("storage migration failed: %w", err) + } + distro := detectDistro() if distro == "" { return nil @@ -44,6 +73,52 @@ func TriggerInstall() error { return nil } +// TriggerPostDelete removes the attachment store for an app that's being +// destroyed. Entries are global and survive app deletion. The whole +// per-app property folder goes away rather than being rewritten as +// empty, matching what other plugins do on app deletion. +func TriggerPostDelete(appName string) error { + if appName == "" { + return nil + } + return common.PropertyDestroy(PluginName, appName) +} + +// TriggerPostAppCloneSetup copies attachments from the source app to the +// cloned app. Entries are global so no entry-level work is needed. +func TriggerPostAppCloneSetup(oldName string, newName string) error { + if oldName == "" || newName == "" { + return nil + } + if !common.PropertyExists(PluginName, oldName, AttachmentsProperty) { + return nil + } + attachments, err := LoadAttachments(oldName) + if err != nil { + return err + } + return SaveAttachments(newName, attachments) +} + +// TriggerPostAppRenameSetup moves attachments from the old name to the +// new name and removes the old per-app property folder. +func TriggerPostAppRenameSetup(oldName string, newName string) error { + if oldName == "" || newName == "" { + return nil + } + if !common.PropertyExists(PluginName, oldName, AttachmentsProperty) { + return nil + } + attachments, err := LoadAttachments(oldName) + if err != nil { + return err + } + if err := SaveAttachments(newName, attachments); err != nil { + return err + } + return common.PropertyDestroy(PluginName, oldName) +} + // detectDistro returns the Linux distribution name func detectDistro() string { if runtime.GOOS != "linux" { @@ -65,29 +140,114 @@ func detectDistro() string { return "" } -// TriggerStorageList outputs storage mounts for an app +// TriggerStorageList outputs storage mounts for an app. +// +// Deprecated: the storage-list plugn trigger is retained for back-compat +// with external plugins that may still call it, but in-process callers +// should use storage.ListAppMountEntries directly. A deprecation warning +// is emitted on every invocation. func TriggerStorageList(appName string, phase string, format string) error { - mounts, err := GetBindMounts(appName, phase) + common.LogWarn("the storage-list plugn trigger is deprecated; use the storage-app-mounts trigger or the storage Go package directly") + + rows, err := ListAppMountEntries(appName, phase) if err != nil { return err } if format == "json" { - entries := []StorageListEntry{} - for _, mount := range mounts { - entries = append(entries, ParseMountPath(mount)) - } - - output, err := json.Marshal(entries) + output, err := json.Marshal(rows) if err != nil { return err } fmt.Println(string(output)) - } else { - for _, mount := range mounts { - fmt.Println(mount) - } + return nil + } + for _, row := range rows { + fmt.Println(formatStorageListEntry(row)) } - return nil } + +// AppMountPair pairs an Attachment with the Entry it references; consumed +// by scheduler plugins via the storage-app-mounts trigger. +type AppMountPair struct { + Entry *Entry `json:"entry"` + Attachment *Attachment `json:"attachment"` +} + +// TriggerStorageAppMounts emits the (entry, attachment) pairs an app has +// for the given phase, in JSON. Schedulers consume this at deploy time. +func TriggerStorageAppMounts(appName string, phase string) error { + if phase == "" { + phase = PhaseDeploy + } + attachments, err := AttachmentsForPhase(appName, phase) + if err != nil { + return err + } + + pairs := []AppMountPair{} + for _, attachment := range attachments { + entry, err := LoadEntry(attachment.EntryName) + if err != nil { + return fmt.Errorf("attachment on %q references missing entry %q: %w", appName, attachment.EntryName, err) + } + pairs = append(pairs, AppMountPair{Entry: entry, Attachment: attachment}) + } + + output, err := json.Marshal(pairs) + if err != nil { + return err + } + fmt.Println(string(output)) + return nil +} + +// TriggerDockerArgs emits `-v` flags for each docker-local attachment in +// the requested phase. Plugn concatenates this with docker-options' +// equivalent trigger output, so docker-local apps continue to receive +// their bind mounts through the standard pipeline. +func TriggerDockerArgs(appName string, phase string) error { + attachments, err := AttachmentsForPhase(appName, phase) + if err != nil { + return err + } + + for _, attachment := range attachments { + entry, err := LoadEntry(attachment.EntryName) + if err != nil { + return fmt.Errorf("attachment on %q references missing entry %q: %w", appName, attachment.EntryName, err) + } + if entry.Scheduler != SchedulerDockerLocal { + continue + } + flag := buildDockerVFlag(entry, attachment) + if flag == "" { + continue + } + fmt.Printf(" %s", flag) + } + return nil +} + +// buildDockerVFlag formats the Docker -v argument for a docker-local +// attachment. +func buildDockerVFlag(entry *Entry, attachment *Attachment) string { + host := entry.HostPath + container := attachment.ContainerPath + if host == "" || container == "" { + return "" + } + options := []string{} + if attachment.Readonly { + options = append(options, "ro") + } + if attachment.VolumeOptions != "" { + options = append(options, attachment.VolumeOptions) + } + flag := fmt.Sprintf("-v %s:%s", host, container) + if len(options) > 0 { + flag = fmt.Sprintf("%s:%s", flag, strings.Join(options, ",")) + } + return flag +} diff --git a/plugins/traefik-vhosts/Dockerfile b/plugins/traefik-vhosts/Dockerfile index 9c6e6e314..eb844e50c 100644 --- a/plugins/traefik-vhosts/Dockerfile +++ b/plugins/traefik-vhosts/Dockerfile @@ -1 +1 @@ -FROM traefik:v3.6.14 +FROM traefik:v3.6.15 diff --git a/plugins/traefik-vhosts/command-functions b/plugins/traefik-vhosts/command-functions index f37149b72..aab85bf01 100755 --- a/plugins/traefik-vhosts/command-functions +++ b/plugins/traefik-vhosts/command-functions @@ -33,16 +33,12 @@ cmd-traefik-report() { declare desc="displays a traefik report for one or more apps" declare cmd="traefik:report" [[ "$1" == "$cmd" ]] && shift 1 - declare APP="$1" INFO_FLAG="$2" - local FORMAT="stdout" + fn-report-parse-args "$@" + set -- "${REPORT_ARGS[@]}" + declare APP="${1:-}" INFO_FLAG="${2:-}" - if [[ -n "$APP" ]] && [[ "$APP" == "--format" ]]; then - FORMAT="$INFO_FLAG" - APP="" - INFO_FLAG="" - elif [[ -n "$INFO_FLAG" ]] && [[ "$INFO_FLAG" == "--format" ]]; then - FORMAT="$3" - INFO_FLAG="" + if [[ "$REPORT_IS_GLOBAL" == "true" ]]; then + APP="--global" elif [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then INFO_FLAG="$APP" APP="" @@ -52,12 +48,14 @@ cmd-traefik-report() { INFO_FLAG="true" fi - if [[ -z "$APP" ]]; then + if [[ "$APP" == "--global" ]]; then + cmd-traefik-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" + elif [[ -z "$APP" ]]; then for app in $(dokku_apps); do - cmd-traefik-report-single "$app" "$INFO_FLAG" "$FORMAT" | tee || true + cmd-traefik-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true done else - cmd-traefik-report-single "$APP" "$INFO_FLAG" "$FORMAT" + cmd-traefik-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" fi } @@ -66,7 +64,9 @@ cmd-traefik-report-single() { if [[ "$INFO_FLAG" == "true" ]]; then INFO_FLAG="" fi - verify_app_name "$APP" + if [[ "$APP" != "--global" ]]; then + verify_app_name "$APP" + fi local flag_map=( "--traefik-api-enabled: $(fn-traefik-api-enabled)" "--traefik-api-entry-point: $(fn-traefik-api-entry-point)" @@ -85,7 +85,6 @@ cmd-traefik-report-single() { "--traefik-https-entry-point: $(fn-traefik-https-entry-point)" ) - # Get dns-provider-* env vars and add them (masked for stdout, unmasked for json) local dns_provider_env_vars dns_provider_env_vars="$(fn-traefik-dns-provider-env-vars)" if [[ -n "$dns_provider_env_vars" ]]; then @@ -103,17 +102,19 @@ cmd-traefik-report-single() { done <<<"$dns_provider_env_vars" fi + fn-report-validate-format "$FORMAT" "$INFO_FLAG" + if [[ "$FORMAT" == "json" ]]; then - local json_output="{}" - for flag in "${flag_map[@]}"; do - local key value - key="$(echo "$flag" | cut -d':' -f1 | sed 's/^--traefik-//')" - 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" - elif [[ -z "$INFO_FLAG" ]]; then - dokku_log_info2_quiet "${APP} traefik information" + fn-report-emit-json flag_map "traefik" + return + fi + + if [[ -z "$INFO_FLAG" ]]; then + if [[ "$APP" == "--global" ]]; then + dokku_log_info2_quiet "global traefik information" + else + dokku_log_info2_quiet "${APP} traefik information" + fi for flag in "${flag_map[@]}"; do key="$(echo "${flag#--}" | cut -f1 -d' ' | tr - ' ')" dokku_log_verbose "$(printf "%-30s %-25s" "${key^}" "${flag#*: }")" diff --git a/tests.mk b/tests.mk index c31d42db0..6178cae64 100644 --- a/tests.mk +++ b/tests.mk @@ -149,11 +149,13 @@ ci-go-coverage: @$(MAKE) ci-go-coverage-plugin PLUGIN_NAME=common @$(MAKE) ci-go-coverage-plugin PLUGIN_NAME=config @$(MAKE) ci-go-coverage-plugin PLUGIN_NAME=network + @$(MAKE) ci-go-coverage-plugin PLUGIN_NAME=buildpacks ci-go-coverage-plugin: mkdir -p test-results/coverage docker run --rm \ -e DOKKU_ROOT=/home/dokku \ + -e DOKKU_LIB_ROOT=/var/lib/dokku \ -e CODACY_TOKEN=$$CODACY_TOKEN \ -e CIRCLE_SHA1=$$CIRCLE_SHA1 \ -e GO111MODULE=on \ @@ -177,9 +179,10 @@ go-tests: @$(MAKE) go-test-plugin PLUGIN_NAME=common @$(MAKE) go-test-plugin PLUGIN_NAME=config @$(MAKE) go-test-plugin PLUGIN_NAME=network + @$(MAKE) go-test-plugin PLUGIN_NAME=buildpacks go-test-plugin: - cd plugins/$(PLUGIN_NAME) && go get github.com/onsi/gomega && DOKKU_ROOT=/home/dokku go test -v -p 1 -race -mod=readonly || exit $$? + cd plugins/$(PLUGIN_NAME) && go get github.com/onsi/gomega && DOKKU_ROOT=/home/dokku DOKKU_LIB_ROOT=/var/lib/dokku go test -v -p 1 -race -mod=readonly || exit $$? go-test-plugin-in-docker: @echo running go unit tests... diff --git a/tests/apps/dockerfile/app.json b/tests/apps/dockerfile/app.json index bd0d70165..e1b65e4e0 100644 --- a/tests/apps/dockerfile/app.json +++ b/tests/apps/dockerfile/app.json @@ -3,11 +3,6 @@ "dokku-postgres", "dokku-redis" ], - "buildpacks": [ - { - "url": "https://github.com/heroku/heroku-buildpack-nodejs" - } - ], "description": "Used in dokku's test suite", "env": { "SECRET_TOKEN": { @@ -16,7 +11,7 @@ }, "WEB_CONCURRENCY": { "description": "The number of processes to run.", - "generator": "echo 5" + "value": "5" } }, "healthchecks": { diff --git a/tests/apps/gogrpc/go.mod b/tests/apps/gogrpc/go.mod index dc68f7b6a..62b4802a6 100644 --- a/tests/apps/gogrpc/go.mod +++ b/tests/apps/gogrpc/go.mod @@ -1,6 +1,6 @@ module github.com/dokku/dokko/tests/apps/gorpc -go 1.25.5 +go 1.26.2 require ( golang.org/x/net v0.49.0 // indirect diff --git a/tests/apps/nodejs-express/app.json b/tests/apps/nodejs-express/app.json index 5eb61f5f5..702f97410 100644 --- a/tests/apps/nodejs-express/app.json +++ b/tests/apps/nodejs-express/app.json @@ -16,7 +16,7 @@ }, "WEB_CONCURRENCY": { "description": "The number of processes to run.", - "generator": "echo 5" + "value": "5" } }, "healthchecks": { diff --git a/tests/apps/python/app-cnb-cron-procfile.json b/tests/apps/python/app-cnb-cron-procfile.json new file mode 100644 index 000000000..892bc0bf1 --- /dev/null +++ b/tests/apps/python/app-cnb-cron-procfile.json @@ -0,0 +1,21 @@ +{ + "cron": [ + { + "command": "task", + "schedule": "5 5 5 5 5" + } + ], + "healthchecks": { + "web": [ + { + "attempts": 2, + "content": "python/http.server", + "name": "check-1", + "path": "/", + "timeout": 5, + "type": "startup", + "wait": 2 + } + ] + } +} diff --git a/tests/apps/python/app-env-required.json b/tests/apps/python/app-env-required.json new file mode 100644 index 000000000..c50087c77 --- /dev/null +++ b/tests/apps/python/app-env-required.json @@ -0,0 +1,8 @@ +{ + "env": { + "REQUIRED_VAR": { + "description": "A required variable with no default", + "required": true + } + } +} diff --git a/tests/apps/python/app-env.json b/tests/apps/python/app-env.json new file mode 100644 index 000000000..707768b88 --- /dev/null +++ b/tests/apps/python/app-env.json @@ -0,0 +1,27 @@ +{ + "env": { + "SIMPLE_VAR": "simple_value", + "OBJECT_VAR": { + "description": "A variable with a default value", + "value": "object_default" + }, + "SECRET_VAR": { + "description": "A generated secret", + "generator": "secret" + }, + "OPTIONAL_VAR": { + "description": "An optional variable", + "required": false + }, + "SYNC_VAR": { + "description": "A variable that syncs on every deploy", + "value": "sync_value", + "sync": true + } + }, + "scripts": { + "dokku": { + "predeploy": "echo ENV_CHECK: SIMPLE_VAR=$SIMPLE_VAR OBJECT_VAR=$OBJECT_VAR" + } + } +} diff --git a/tests/apps/zombies-buildpack/go.mod b/tests/apps/zombies-buildpack/go.mod index 004931b31..3c966508e 100644 --- a/tests/apps/zombies-buildpack/go.mod +++ b/tests/apps/zombies-buildpack/go.mod @@ -1,3 +1,3 @@ module dokku.me/web -go 1.25.5 +go 1.26.2 diff --git a/tests/apps/zombies-dockerfile-no-tini/go.mod b/tests/apps/zombies-dockerfile-no-tini/go.mod index 004931b31..3c966508e 100644 --- a/tests/apps/zombies-dockerfile-no-tini/go.mod +++ b/tests/apps/zombies-dockerfile-no-tini/go.mod @@ -1,3 +1,3 @@ module dokku.me/web -go 1.25.5 +go 1.26.2 diff --git a/tests/apps/zombies-dockerfile-tini/go.mod b/tests/apps/zombies-dockerfile-tini/go.mod index 004931b31..3c966508e 100644 --- a/tests/apps/zombies-dockerfile-tini/go.mod +++ b/tests/apps/zombies-dockerfile-tini/go.mod @@ -1,3 +1,3 @@ module dokku.me/web -go 1.25.5 +go 1.26.2 diff --git a/tests/unit/app-json-5.bats b/tests/unit/app-json-5.bats new file mode 100644 index 000000000..e0574d2ea --- /dev/null +++ b/tests/unit/app-json-5.bats @@ -0,0 +1,198 @@ +#!/usr/bin/env bats +load test_helper + +setup() { + global_setup + create_app +} + +teardown() { + destroy_app + global_teardown +} + +@test "(app-json) app.json env simple value" { + run /bin/bash -c "dokku app-json:set $TEST_APP appjson-path app-env.json" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "Processing app.json env vars" + assert_output_contains "Setting 4 env var(s) from app.json" + + run /bin/bash -c "dokku config:get $TEST_APP SIMPLE_VAR" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "simple_value" + + run /bin/bash -c "dokku config:get $TEST_APP OBJECT_VAR" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "object_default" +} + +@test "(app-json) app.json env secret generator" { + run /bin/bash -c "dokku app-json:set $TEST_APP appjson-path app-env.json" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku config:get $TEST_APP SECRET_VAR" + echo "output: $output" + echo "status: $status" + assert_success + # Secret should be 64 characters (hex encoded 32 bytes) + [[ ${#output} -eq 64 ]] +} + +@test "(app-json) app.json env does not overwrite on redeploy" { + run /bin/bash -c "dokku app-json:set $TEST_APP appjson-path app-env.json" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP + echo "output: $output" + echo "status: $status" + assert_success + + # Get the original secret + run /bin/bash -c "dokku config:get $TEST_APP SECRET_VAR" + echo "output: $output" + echo "status: $status" + assert_success + local original_secret="$output" + + # Change SIMPLE_VAR manually + run /bin/bash -c "dokku config:set --no-restart $TEST_APP SIMPLE_VAR=changed_value" + echo "output: $output" + echo "status: $status" + assert_success + + # Rebuild the app + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + # Verify SIMPLE_VAR was NOT overwritten + run /bin/bash -c "dokku config:get $TEST_APP SIMPLE_VAR" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "changed_value" + + # Verify SECRET_VAR was NOT regenerated + run /bin/bash -c "dokku config:get $TEST_APP SECRET_VAR" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "$original_secret" +} + +@test "(app-json) app.json env sync overwrites on redeploy" { + run /bin/bash -c "dokku app-json:set $TEST_APP appjson-path app-env.json" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP + echo "output: $output" + echo "status: $status" + assert_success + + # Verify SYNC_VAR is set + run /bin/bash -c "dokku config:get $TEST_APP SYNC_VAR" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "sync_value" + + # Change SYNC_VAR manually + run /bin/bash -c "dokku config:set --no-restart $TEST_APP SYNC_VAR=manual_value" + echo "output: $output" + echo "status: $status" + assert_success + + # Rebuild the app + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + # Verify SYNC_VAR WAS overwritten back to sync_value + run /bin/bash -c "dokku config:get $TEST_APP SYNC_VAR" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "sync_value" +} + +@test "(app-json) app.json env required without value fails" { + run /bin/bash -c "dokku app-json:set $TEST_APP appjson-path app-env-required.json" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "required env var REQUIRED_VAR has no value" +} + +@test "(app-json) app.json env skips optional without value" { + run /bin/bash -c "dokku app-json:set $TEST_APP appjson-path app-env.json" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP + echo "output: $output" + echo "status: $status" + assert_success + + # OPTIONAL_VAR should not be set since it has no default and is optional + # config:get returns exit code 1 when a variable is not set + run /bin/bash -c "dokku config:get $TEST_APP OPTIONAL_VAR" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output "" +} + +@test "(app-json) app.json env respects pre-set values on first deploy" { + run /bin/bash -c "dokku app-json:set $TEST_APP appjson-path app-env.json" + echo "output: $output" + echo "status: $status" + assert_success + + # Pre-set SIMPLE_VAR before first deploy + run /bin/bash -c "dokku config:set --no-restart $TEST_APP SIMPLE_VAR=preset_value" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP + echo "output: $output" + echo "status: $status" + assert_success + + # Verify SIMPLE_VAR was NOT overwritten + run /bin/bash -c "dokku config:get $TEST_APP SIMPLE_VAR" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "preset_value" +} diff --git a/tests/unit/app-json.bats b/tests/unit/app-json.bats index 4c899d979..0a0410cfa 100644 --- a/tests/unit/app-json.bats +++ b/tests/unit/app-json.bats @@ -139,7 +139,7 @@ teardown() { assert_success fi - run /bin/bash -c "dokku config:set $TEST_APP SECRET_KEY_BASE=derp OTP_SECRET=1234" + run /bin/bash -c "dokku config:set $TEST_APP SECRET_KEY_BASE=derp OTP_SECRET=1234 LOCAL_DOMAIN=dokku.me" echo "output: $output" echo "status: $status" assert_success diff --git a/tests/unit/apps_1.bats b/tests/unit/apps_1.bats index 9d6d3f425..d99132469 100644 --- a/tests/unit/apps_1.bats +++ b/tests/unit/apps_1.bats @@ -113,7 +113,7 @@ teardown() { } @test "(apps) app autocreate disabled" { - run /bin/bash -c "dokku config:set --no-restart --global DOKKU_DISABLE_APP_AUTOCREATION='true'" + run /bin/bash -c "dokku apps:set --global disable-autocreation true" echo "output: $output" echo "status: $status" assert_success @@ -122,7 +122,7 @@ teardown() { echo "output: $output" echo "status: $status" assert_failure - run /bin/bash -c "dokku config:unset --no-restart --global DOKKU_DISABLE_APP_AUTOCREATION" + run /bin/bash -c "dokku apps:set --global disable-autocreation" } @test "(apps) apps:destroy" { diff --git a/tests/unit/apps_2.bats b/tests/unit/apps_2.bats index a176cece0..1ab9549da 100644 --- a/tests/unit/apps_2.bats +++ b/tests/unit/apps_2.bats @@ -116,7 +116,7 @@ teardown() { echo "output: $output" echo "status: $status" assert_success - run /bin/bash -c "dokku config:set --no-restart $TEST_APP DOKKU_PROXY_SSL_PORT=443" + run /bin/bash -c "dokku proxy:set $TEST_APP proxy-ssl-port 443" echo "output: $output" echo "status: $status" assert_success @@ -132,10 +132,6 @@ teardown() { echo "output: $output" echo "status: $status" assert_output "http 80 5000" - run /bin/bash -c "dokku config:get app-without-ssl DOKKU_PROXY_SSL_PORT" - echo "output: $output" - echo "status: $status" - assert_output_not_exists run /bin/bash -c "dokku --force apps:destroy app-without-ssl" echo "output: $output" echo "status: $status" @@ -166,7 +162,7 @@ teardown() { assert_success assert_output "http://$TEST_APP.$DOKKU_DOMAIN" assert_http_localhost_response "http" "$TEST_APP.$DOKKU_DOMAIN" "80" "/hello" - assert_http_localhost_response "http" "great-test-name.dokku.me" "80" "/hello" "" "404" + assert_http_localhost_response "http" "great-test-name.dokku.me" "80" "/hello" "" "502" run /bin/bash -c "dokku --force apps:destroy great-test-name" echo "output: $output" echo "status: $status" diff --git a/tests/unit/builder-dockerfile.bats b/tests/unit/builder-dockerfile.bats index ca62231f0..cf4d88ac0 100644 --- a/tests/unit/builder-dockerfile.bats +++ b/tests/unit/builder-dockerfile.bats @@ -12,6 +12,31 @@ teardown() { destroy_app } +@test "(builder-dockerfile:report) --global --format json" { + run /bin/bash -c "dokku builder-dockerfile:report --global --format json | jq -e ." + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku builder-dockerfile:report --global --format json | jq -r '.\"global-dockerfile-path\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "Dockerfile" + + run /bin/bash -c "dokku builder-dockerfile:report --global --format json | jq -r 'has(\"dockerfile-path\")'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "false" + + run /bin/bash -c "dokku builder-dockerfile:report --global" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "global builder-dockerfile information" +} + @test "(builder-dockerfile:set)" { run deploy_app dockerfile echo "output: $output" diff --git a/tests/unit/builder-pack.bats b/tests/unit/builder-pack.bats index 3721b2e62..d3d030f64 100644 --- a/tests/unit/builder-pack.bats +++ b/tests/unit/builder-pack.bats @@ -177,6 +177,39 @@ teardown() { assert_output "['task.py', 'some', 'cron', 'task']" } +@test "(builder-pack) cron:run with Procfile reference" { + run /bin/bash -c "dokku config:set $TEST_APP SECRET_KEY=fjdkslafjdk" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku builder:set $TEST_APP selected pack" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP cron_run_procfile_wrapper + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains 'from cnb stack' + assert_output_contains 'Building with buildpack 1' 0 + assert_output_contains 'Installing dependencies using pip' + + cron_id="$(dokku cron:list $TEST_APP --format json | jq -r '.[0].id')" + run /bin/bash -c "echo $cron_id" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_exists + + run /bin/bash -c "dokku --quiet cron:run $TEST_APP $cron_id" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "['task.py', 'test']" +} + cron_run_wrapper() { local APP="$1" local APP_REPO_DIR="$2" @@ -187,3 +220,14 @@ cron_run_wrapper() { ls -lah "$APP_REPO_DIR" mv -f "$APP_REPO_DIR/app-cnb-cron.json" "$APP_REPO_DIR/app.json" } + +cron_run_procfile_wrapper() { + local APP="$1" + local APP_REPO_DIR="$2" + [[ -z "$APP" ]] && local APP="$TEST_APP" + APP_REPO_DIR="$(realpath "$APP_REPO_DIR")" + + add_requirements_txt "$APP" "$APP_REPO_DIR" + ls -lah "$APP_REPO_DIR" + mv -f "$APP_REPO_DIR/app-cnb-cron-procfile.json" "$APP_REPO_DIR/app.json" +} diff --git a/tests/unit/builder.bats b/tests/unit/builder.bats index 6cbaa790e..770b9f0a7 100644 --- a/tests/unit/builder.bats +++ b/tests/unit/builder.bats @@ -175,6 +175,40 @@ teardown() { assert_line 0 "herokuish" } +@test "(builder:report) --global --format json" { + run /bin/bash -c "dokku builder:set --global selected dockerfile" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku builder:report --global --format json | jq -e ." + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku builder:report --global --format json | jq -r '.\"builder-global-selected\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "dockerfile" + + run /bin/bash -c "dokku builder:report --global --format json | jq -r 'has(\"builder-selected\")'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "false" + + run /bin/bash -c "dokku builder:report --global" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku builder:set --global selected" + echo "output: $output" + echo "status: $status" + assert_success +} + @test "(builder:set)" { run deploy_app python echo "output: $output" diff --git a/tests/unit/buildpacks.bats b/tests/unit/buildpacks.bats index e4d24fb39..59e9b4438 100644 --- a/tests/unit/buildpacks.bats +++ b/tests/unit/buildpacks.bats @@ -336,6 +336,33 @@ teardown() { assert_output_contains "heroku-buildpack-python" } +@test "(buildpacks) deploy with buildpacks from app.json" { + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP template_buildpacks_appjson + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "heroku-buildpack-python" +} + +template_buildpacks_appjson() { + local APP="$1" + local APP_REPO_DIR="$2" + [[ -z "$APP" ]] && local APP="$TEST_APP" + echo "injecting app.json with buildpacks -> $APP_REPO_DIR/app.json" + cat <"$APP_REPO_DIR/app.json" +{ + "buildpacks": [ + { + "url": "heroku/python" + } + ] +} +EOF + + echo "injecting requirements.txt -> $APP_REPO_DIR/requirements.txt" + touch "$APP_REPO_DIR/requirements.txt" +} + template_buildpacks_cleanup() { local APP="$1" local APP_REPO_DIR="$2" diff --git a/tests/unit/builds.bats b/tests/unit/builds.bats new file mode 100644 index 000000000..366658ccf --- /dev/null +++ b/tests/unit/builds.bats @@ -0,0 +1,333 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + global_setup + create_app +} + +teardown() { + rm -rf "$DOKKU_LIB_ROOT/data/builds/$TEST_APP" || true + destroy_app + global_teardown +} + +write_finished_record() { + local app="$1" id="$2" status="$3" source="${4:-git-hook}" pid="${5:-99999}" kind="${6:-build}" exit_code="${7:-0}" + local dir="$DOKKU_LIB_ROOT/data/builds/$app" + mkdir -p "$dir" + local now + now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + cat >"$dir/$id.json" <"$dir/$id.log" + chown -R dokku:dokku "$DOKKU_LIB_ROOT/data/builds" +} + +write_running_record() { + local app="$1" id="$2" pid="$3" source="${4:-git-hook}" kind="${5:-build}" + local dir="$DOKKU_LIB_ROOT/data/builds/$app" + mkdir -p "$dir" + local now + now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + cat >"$dir/$id.json" <"$dir/$id.log" + chown -R dokku:dokku "$DOKKU_LIB_ROOT/data/builds" +} + +@test "(builds) builds:help" { + run /bin/bash -c "dokku builds" + echo "output: $output" + echo "status: $status" + assert_output_contains "Manage running and historical builds" + + run /bin/bash -c "dokku builds:help" + echo "output: $output" + echo "status: $status" + assert_output_contains "Manage running and historical builds" +} + +@test "(builds:list) returns empty list when no builds have run" { + run /bin/bash -c "dokku builds:list $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "No builds recorded" +} + +@test "(builds:list) shows a finished record after a successful deploy" { + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku builds:list $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "succeeded" + assert_output_contains "git-hook" +} + +@test "(builds:list) shows an abandoned record when the PID is gone but the record is still running" { + write_running_record "$TEST_APP" "ghost001" 99999 "git-hook" + + run /bin/bash -c "dokku builds:list $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "abandoned" +} + +@test "(builds:list --kind=build) filters to build-kind records" { + write_finished_record "$TEST_APP" "b1" "succeeded" "git-hook" 1001 "build" + write_finished_record "$TEST_APP" "d1" "succeeded" "ps:restart" 1002 "deploy" + + run /bin/bash -c "dokku builds:list $TEST_APP --kind build" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "b1" + assert_output_not_contains "d1" +} + +@test "(builds:list --kind=deploy) filters to deploy-kind records" { + write_finished_record "$TEST_APP" "b1" "succeeded" "git-hook" 1001 "build" + write_finished_record "$TEST_APP" "d1" "succeeded" "ps:restart" 1002 "deploy" + + run /bin/bash -c "dokku builds:list $TEST_APP --kind deploy" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "d1" + assert_output_not_contains "b1" +} + +@test "(builds:list --kind=invalid) fails with a usage error" { + run /bin/bash -c "dokku builds:list $TEST_APP --kind=garbage" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "Invalid --kind" +} + +@test "(builds:set) writes a per-app retention" { + run /bin/bash -c "dokku builds:set $TEST_APP retention 5" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku builds:report $TEST_APP --builds-retention" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "5" +} + +@test "(builds:set --global) writes the global retention" { + run /bin/bash -c "dokku builds:set --global retention 7" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku builds:report $TEST_APP --builds-computed-retention" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "7" + + dokku builds:set --global retention "" || true +} + +@test "(builds:set) clears the override when called with no value" { + dokku builds:set "$TEST_APP" retention 5 + + run /bin/bash -c "dokku builds:set $TEST_APP retention" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku builds:report $TEST_APP --builds-retention" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "" +} + +@test "(builds:set) rejects non-positive-integer values" { + run /bin/bash -c "dokku builds:set $TEST_APP retention 0" + echo "output: $output" + echo "status: $status" + assert_failure + + run /bin/bash -c "dokku builds:set $TEST_APP retention abc" + echo "output: $output" + echo "status: $status" + assert_failure +} + +@test "(builds:info) returns non-zero for a missing build-id" { + run /bin/bash -c "dokku builds:info $TEST_APP nonexistent" + echo "output: $output" + echo "status: $status" + assert_failure +} + +@test "(builds:info) returns the recorded fields including --format json" { + write_finished_record "$TEST_APP" "info001" "succeeded" "git-hook" 1234 "build" + + run /bin/bash -c "dokku builds:info $TEST_APP info001" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "info001" -1 + assert_output_contains "succeeded" + assert_output_contains "Log:" + + run /bin/bash -c "dokku builds:info $TEST_APP info001 --format json" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "\"id\":\"info001\"" + assert_output_contains "\"log_path\":" +} + +@test "(builds:cancel) returns 0 with a friendly message when no lock is present" { + run /bin/bash -c "dokku builds:cancel $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "App not currently deploying" +} + +@test "(builds:cancel) refuses to cancel a record whose status is not running" { + write_finished_record "$TEST_APP" "done01" "succeeded" "git-hook" 1234 "build" + mkdir -p "$DOKKU_LIB_ROOT/data/apps/$TEST_APP" + echo "done01" >"$DOKKU_LIB_ROOT/data/apps/$TEST_APP/.deploy.lock" + chown dokku:dokku "$DOKKU_LIB_ROOT/data/apps/$TEST_APP/.deploy.lock" + + run /bin/bash -c "dokku builds:cancel $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "no longer running" + + rm -f "$DOKKU_LIB_ROOT/data/apps/$TEST_APP/.deploy.lock" +} + +@test "(builds:cancel) marks an abandoned record as failed instead of canceled" { + write_running_record "$TEST_APP" "abnd01" 99999 "git-hook" + mkdir -p "$DOKKU_LIB_ROOT/data/apps/$TEST_APP" + echo "abnd01" >"$DOKKU_LIB_ROOT/data/apps/$TEST_APP/.deploy.lock" + chown dokku:dokku "$DOKKU_LIB_ROOT/data/apps/$TEST_APP/.deploy.lock" + + run /bin/bash -c "dokku builds:cancel $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "already terminated" + + run /bin/bash -c "dokku builds:info $TEST_APP abnd01" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "failed" +} + +@test "(builds:output) cats the log file for a finished build" { + write_finished_record "$TEST_APP" "out001" "succeeded" "git-hook" 1234 "build" + echo "build log line" >"$DOKKU_LIB_ROOT/data/builds/$TEST_APP/out001.log" + chown dokku:dokku "$DOKKU_LIB_ROOT/data/builds/$TEST_APP/out001.log" + + run /bin/bash -c "dokku builds:output $TEST_APP out001" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "build log line" +} + +@test "(builds:prune) prunes an app's records to retention" { + dokku builds:set "$TEST_APP" retention 2 + + for i in 1 2 3 4 5; do + write_finished_record "$TEST_APP" "p$i" "succeeded" "git-hook" $((1000 + i)) "build" + sleep 1 + done + + run /bin/bash -c "dokku builds:prune $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + count="$(ls $DOKKU_LIB_ROOT/data/builds/$TEST_APP/*.json 2>/dev/null | wc -l)" + echo "remaining: $count" + [[ "$count" -le 2 ]] || (echo "expected <=2 records, got $count" && false) +} + +@test "(builds:prune) reaps an abandoned record and finalizes it as failed" { + write_running_record "$TEST_APP" "ghost02" 99999 "git-hook" + + run /bin/bash -c "dokku builds:prune $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku builds:info $TEST_APP ghost02" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "failed" +} + +@test "(builds:prune) skips live-running records" { + local pid=$$ + write_running_record "$TEST_APP" "alive02" "$pid" "git-hook" + + run /bin/bash -c "dokku builds:prune $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku builds:info $TEST_APP alive02" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "running" +} + +@test "(builds) [storage] directory is removed on apps:destroy" { + write_finished_record "$TEST_APP" "del01" "succeeded" "git-hook" 1234 "build" + [[ -d "$DOKKU_LIB_ROOT/data/builds/$TEST_APP" ]] + + run /bin/bash -c "dokku --force apps:destroy $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + [[ ! -d "$DOKKU_LIB_ROOT/data/builds/$TEST_APP" ]] + + # Recreate so teardown can run cleanly. + create_app +} diff --git a/tests/unit/certs.bats b/tests/unit/certs.bats index ba8f7fe5f..6a5b16d1d 100644 --- a/tests/unit/certs.bats +++ b/tests/unit/certs.bats @@ -41,6 +41,20 @@ teardown() { assert_output "$help_output" } +@test "(certs:report) --global --format json" { + run /bin/bash -c "dokku certs:report --global --format json | jq -e ." + echo "output: $output" + echo "status: $status" + assert_success + assert_output "{}" + + run /bin/bash -c "dokku certs:report --global" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "global ssl information" +} + @test "(certs) certs:add" { run /bin/bash -c "dokku certs:add $TEST_APP $BATS_TMPDIR/tls/server.crt $BATS_TMPDIR/tls/server.key" echo "output: $output" diff --git a/tests/unit/checks.bats b/tests/unit/checks.bats index 35e0576fa..b73094c7a 100644 --- a/tests/unit/checks.bats +++ b/tests/unit/checks.bats @@ -16,6 +16,41 @@ teardown() { global_teardown } +@test "(checks:report) --global --format json" { + run /bin/bash -c "dokku checks:set --global wait-to-retire 90" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku checks:report --global --format json | jq -e ." + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku checks:report --global --format json | jq -r '.\"global-wait-to-retire\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "90" + + run /bin/bash -c "dokku checks:report --global --format json | jq -r 'has(\"wait-to-retire\")'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "false" + + run /bin/bash -c "dokku checks:report --global" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "global checks information" + + run /bin/bash -c "dokku checks:set --global wait-to-retire" + echo "output: $output" + echo "status: $status" + assert_success +} + @test "(checks) checks:help" { run /bin/bash -c "dokku checks" echo "output: $output" @@ -36,7 +71,7 @@ teardown() { echo "status: $status" assert_success - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_DISABLED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-disabled-list" echo "output: $output" echo "status: $status" assert_output "_all_" @@ -48,30 +83,30 @@ teardown() { echo "status: $status" assert_success - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_DISABLED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-disabled-list" echo "output: $output" echo "status: $status" assert_output "_all_" - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_SKIPPED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-skipped-list" echo "output: $output" echo "status: $status" - assert_output_not_exists + assert_output "none" run /bin/bash -c "dokku checks:enable $TEST_APP" echo "output: $output" echo "status: $status" assert_success - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_DISABLED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-disabled-list" echo "output: $output" echo "status: $status" - assert_output_not_exists + assert_output "none" - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_SKIPPED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-skipped-list" echo "output: $output" echo "status: $status" - assert_output_not_exists + assert_output "none" } @test "(checks) checks:disable -> checks:skip" { @@ -80,27 +115,27 @@ teardown() { echo "status: $status" assert_success - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_DISABLED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-disabled-list" echo "output: $output" echo "status: $status" assert_output "web,worker,urgentworker,notifications" - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_SKIPPED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-skipped-list" echo "output: $output" echo "status: $status" - assert_output_not_exists + assert_output "none" run /bin/bash -c "dokku checks:skip $TEST_APP urgentworker,worker" echo "output: $output" echo "status: $status" assert_success - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_SKIPPED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-skipped-list" echo "output: $output" echo "status: $status" assert_output "urgentworker,worker" - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_DISABLED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-disabled-list" echo "output: $output" echo "status: $status" assert_output "web,notifications" @@ -112,7 +147,7 @@ teardown() { echo "status: $status" assert_success - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_SKIPPED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-skipped-list" echo "output: $output" echo "status: $status" assert_output "_all_" @@ -124,30 +159,30 @@ teardown() { echo "status: $status" assert_success - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_SKIPPED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-skipped-list" echo "output: $output" echo "status: $status" assert_output "_all_" - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_DISABLED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-disabled-list" echo "output: $output" echo "status: $status" - assert_output_not_exists + assert_output "none" run /bin/bash -c "dokku checks:enable $TEST_APP" echo "output: $output" echo "status: $status" assert_success - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_SKIPPED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-skipped-list" echo "output: $output" echo "status: $status" - assert_output_not_exists + assert_output "none" - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_DISABLED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-disabled-list" echo "output: $output" echo "status: $status" - assert_output_not_exists + assert_output "none" } @test "(checks) checks:skip -> checks:disable" { @@ -156,27 +191,27 @@ teardown() { echo "status: $status" assert_success - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_SKIPPED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-skipped-list" echo "output: $output" echo "status: $status" assert_output "web,worker,urgentworker,notifications" - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_DISABLED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-disabled-list" echo "output: $output" echo "status: $status" - assert_output_not_exists + assert_output "none" run /bin/bash -c "dokku checks:disable $TEST_APP urgentworker,worker" echo "output: $output" echo "status: $status" assert_success - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_DISABLED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-disabled-list" echo "output: $output" echo "status: $status" assert_output "urgentworker,worker" - run /bin/bash -c "dokku config:get $TEST_APP DOKKU_CHECKS_SKIPPED" + run /bin/bash -c "dokku checks:report $TEST_APP --checks-skipped-list" echo "output: $output" echo "status: $status" assert_output "web,notifications" diff --git a/tests/unit/config-oddities.bats b/tests/unit/config-oddities.bats index 011d46e16..7ec94b7f1 100644 --- a/tests/unit/config-oddities.bats +++ b/tests/unit/config-oddities.bats @@ -4,16 +4,16 @@ load test_helper setup() { global_setup - [[ -f ${DOKKU_ROOT}/ENV ]] && mv -f ${DOKKU_ROOT}/ENV ${DOKKU_ROOT}/ENV.bak - sudo -H -u dokku /bin/bash -c "echo 'export global_test=true' > ${DOKKU_ROOT}/ENV" + mkdir -p "${DOKKU_LIB_ROOT}/config/--global" + [[ -f ${DOKKU_LIB_ROOT}/config/--global/ENV ]] && mv -f ${DOKKU_LIB_ROOT}/config/--global/ENV ${DOKKU_LIB_ROOT}/config/--global/ENV.bak + sudo -H -u dokku /bin/bash -c "echo 'export global_test=true' > ${DOKKU_LIB_ROOT}/config/--global/ENV" create_app } teardown() { destroy_app - ls -la ${DOKKU_ROOT} - if [[ -f ${DOKKU_ROOT}/ENV.bak ]]; then - mv -f ${DOKKU_ROOT}/ENV.bak ${DOKKU_ROOT}/ENV + if [[ -f ${DOKKU_LIB_ROOT}/config/--global/ENV.bak ]]; then + mv -f ${DOKKU_LIB_ROOT}/config/--global/ENV.bak ${DOKKU_LIB_ROOT}/config/--global/ENV fi global_teardown } diff --git a/tests/unit/config.bats b/tests/unit/config.bats index e9bfb1fce..d29605468 100644 --- a/tests/unit/config.bats +++ b/tests/unit/config.bats @@ -4,16 +4,16 @@ load test_helper setup() { global_setup - [[ -f ${DOKKU_ROOT}/ENV ]] && mv -f ${DOKKU_ROOT}/ENV ${DOKKU_ROOT}/ENV.bak - sudo -H -u dokku /bin/bash -c "echo 'export global_test=true' > ${DOKKU_ROOT}/ENV" + mkdir -p "${DOKKU_LIB_ROOT}/config/--global" + [[ -f ${DOKKU_LIB_ROOT}/config/--global/ENV ]] && mv -f ${DOKKU_LIB_ROOT}/config/--global/ENV ${DOKKU_LIB_ROOT}/config/--global/ENV.bak + sudo -H -u dokku /bin/bash -c "echo 'export global_test=true' > ${DOKKU_LIB_ROOT}/config/--global/ENV" create_app } teardown() { destroy_app - ls -la ${DOKKU_ROOT} - if [[ -f ${DOKKU_ROOT}/ENV.bak ]]; then - mv -f ${DOKKU_ROOT}/ENV.bak ${DOKKU_ROOT}/ENV + if [[ -f ${DOKKU_LIB_ROOT}/config/--global/ENV.bak ]]; then + mv -f ${DOKKU_LIB_ROOT}/config/--global/ENV.bak ${DOKKU_LIB_ROOT}/config/--global/ENV fi global_teardown } diff --git a/tests/unit/cron.bats b/tests/unit/cron.bats index 5eb2bc34a..c579d4496 100644 --- a/tests/unit/cron.bats +++ b/tests/unit/cron.bats @@ -102,6 +102,24 @@ teardown() { assert_output_contains "python3 task.py daily" } +@test "(cron:report) --global --format json" { + run /bin/bash -c "dokku cron:report --global --format json | jq -e ." + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku cron:report --global --format json | jq -r 'has(\"cron-maintenance\")'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "false" + + run /bin/bash -c "dokku cron:report --global" + echo "output: $output" + echo "status: $status" + assert_success +} + @test "(cron) create [multiple]" { run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP template_cron_file_valid_multiple echo "output: $output" diff --git a/tests/unit/docker-options-2.bats b/tests/unit/docker-options-2.bats new file mode 100644 index 000000000..f7f7f289f --- /dev/null +++ b/tests/unit/docker-options-2.bats @@ -0,0 +1,266 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + global_setup + create_app +} + +teardown() { + destroy_app + global_teardown +} + +@test "(docker-options:add) --process scopes option to one process type" { + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:list $TEST_APP --process web --phase deploy" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "-p 8080:5000" + + run /bin/bash -c "dokku docker-options:list $TEST_APP --phase deploy" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "-p 8080:5000" 0 +} + +@test "(docker-options:add) multiple --process flags add to each process" { + run /bin/bash -c "dokku docker-options:add --process web --process worker $TEST_APP deploy '-v /shared:/shared'" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:list $TEST_APP --process web --phase deploy" + echo "output: $output" + assert_output "-v /shared:/shared" + + run /bin/bash -c "dokku docker-options:list $TEST_APP --process worker --phase deploy" + echo "output: $output" + assert_output "-v /shared:/shared" +} + +@test "(docker-options:add) without --process keeps default-scope behaviour" { + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy '-v /tmp/shared:/shared'" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:list $TEST_APP --phase deploy" + echo "output: $output" + assert_output_contains "-v /tmp/shared:/shared" + + run /bin/bash -c "dokku docker-options:report $TEST_APP --docker-options-deploy" + echo "output: $output" + assert_output_contains "-v /tmp/shared:/shared" +} + +@test "(docker-options:add) rejects --process for non-deploy phases" { + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP build '--shm-size 256m'" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "deploy phase" + + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP run '--shm-size 256m'" + echo "output: $output" + echo "status: $status" + assert_failure + + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP build,deploy '--shm-size 256m'" + echo "output: $output" + echo "status: $status" + assert_failure +} + +@test "(docker-options:add) rejects reserved _default_ process name" { + run /bin/bash -c "dokku docker-options:add --process _default_ $TEST_APP deploy '-v /tmp:/tmp'" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "_default_" +} + +@test "(docker-options:add) warns on process types missing from Procfile" { + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:add --process nonexistent $TEST_APP deploy '-v /tmp:/tmp'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "nonexistent" +} + +@test "(docker-options:remove) --process removes from a single process" { + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:add --process worker $TEST_APP deploy '-p 9000:9000'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:remove --process web $TEST_APP deploy '-p 8080:5000'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:list $TEST_APP --process web --phase deploy" + echo "output: $output" + assert_output "" + + run /bin/bash -c "dokku docker-options:list $TEST_APP --process worker --phase deploy" + echo "output: $output" + assert_output "-p 9000:9000" +} + +@test "(docker-options:clear) --process clears only that process" { + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:add --process worker $TEST_APP deploy '-p 9000:9000'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:clear --process web $TEST_APP deploy" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:list $TEST_APP --process web --phase deploy" + echo "output: $output" + assert_output "" + + run /bin/bash -c "dokku docker-options:list $TEST_APP --process worker --phase deploy" + echo "output: $output" + assert_output "-p 9000:9000" +} + +@test "(docker-options:list) --phase is required" { + run /bin/bash -c "dokku docker-options:list $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "phase" +} + +@test "(docker-options:report) exposes dynamic per-process keys" { + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:report $TEST_APP --docker-options-deploy.web" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "-p 8080:5000" + + run /bin/bash -c "dokku docker-options:report $TEST_APP" + echo "output: $output" + assert_success + assert_output_contains "Docker options deploy web" + assert_output_contains "-p 8080:5000" +} + +@test "(docker-options:report) --format json carries process-scoped options" { + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy '-v /logs:/logs'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:report $TEST_APP --format json" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains '"docker-options-deploy.web"' + assert_output_contains "-p 8080:5000" + assert_output_contains '"docker-options-deploy"' + assert_output_contains "-v /logs:/logs" +} + +@test "(docker-options) clone copies default and per-process options" { + local CLONE_APP="${TEST_APP}-clone" + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + assert_success + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy '-v /logs:/logs'" + assert_success + + run /bin/bash -c "dokku apps:clone --skip-deploy $TEST_APP $CLONE_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:list $CLONE_APP --process web --phase deploy" + echo "output: $output" + assert_output "-p 8080:5000" + + run /bin/bash -c "dokku docker-options:list $CLONE_APP --phase deploy" + echo "output: $output" + assert_output_contains "-v /logs:/logs" + + dokku --force apps:destroy "$CLONE_APP" || true +} + +@test "(docker-options) rename moves default and per-process options" { + local RENAMED_APP="${TEST_APP}-renamed" + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + assert_success + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy '-v /logs:/logs'" + assert_success + + run /bin/bash -c "dokku apps:rename $TEST_APP $RENAMED_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:list $RENAMED_APP --process web --phase deploy" + assert_output "-p 8080:5000" + + run /bin/bash -c "dokku docker-options:list $RENAMED_APP --phase deploy" + assert_output_contains "-v /logs:/logs" + + [[ ! -d "/var/lib/dokku/config/docker-options/$TEST_APP" ]] + + TEST_APP="$RENAMED_APP" +} + +@test "(docker-options) deploy with web-only port mapping does not break worker" { + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + echo "output: $output" + assert_success + + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP web_worker_callback + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "App container failed to start" 0 + assert_output_contains "all ports are allocated" 0 + + run /bin/bash -c "dokku ps:report $TEST_APP --ps-status-web-1" + echo "output: $output" + assert_output_contains "running" + + run /bin/bash -c "dokku ps:report $TEST_APP --ps-status-worker-1" + echo "output: $output" + assert_output_contains "running" +} + +web_worker_callback() { + local APP="$1" + local APP_REPO_DIR="$2" + cat >"$APP_REPO_DIR/Procfile" <> $DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" - echo "output: $output" - echo "status: $status" - assert_success - run /bin/bash -c "echo '# comment' >> $DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy \"-v /tmp\"" echo "output: $output" echo "status: $status" assert_success @@ -295,11 +291,7 @@ teardown() { echo "output: $output" echo "status: $status" assert_success - run /bin/bash -c "echo '-v /tmp' >> $DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" - echo "output: $output" - echo "status: $status" - assert_success - run /bin/bash -c "echo '# comment' >> $DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy \"-v /tmp\"" echo "output: $output" echo "status: $status" assert_success @@ -370,3 +362,75 @@ teardown() { assert_success assert_output_contains "TOKEN is: hello" 2 } + +@test "(docker-options:add) splits multi-flag input into separate options" { + run /bin/bash -c "dokku docker-options:add $TEST_APP build --build-arg X=Y --link foo --link bar" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:list $TEST_APP --phase build" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "--build-arg X=Y" + assert_output_contains "--link foo" + assert_output_contains "--link bar" + + run /bin/bash -c "dokku docker-options:report $TEST_APP --docker-options-build" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "--build-arg X=Y" + assert_output_contains "--link foo" + assert_output_contains "--link bar" +} + +@test "(docker-options:remove) symmetrically removes multi-flag input" { + run /bin/bash -c "dokku docker-options:add $TEST_APP build --build-arg X=Y --link foo --link bar" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:remove $TEST_APP build --build-arg X=Y --link foo --link bar" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:list $TEST_APP --phase build" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "" +} + +@test "(docker-options:add) lifts misplaced --process out of option content" { + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy --link foo --process web" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:list $TEST_APP --process web --phase deploy" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "--link foo" + + run /bin/bash -c "dokku docker-options:list $TEST_APP --phase deploy" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "--link foo" 0 +} + +@test "(docker-options) dockerfile build skips unsupported flags from multi-flag input" { + run /bin/bash -c "dokku docker-options:add $TEST_APP build --build-arg PAYPAL_CLIENT_ID=abc --link postgres" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app dockerfile + echo "output: $output" + echo "status: $status" + assert_success +} diff --git a/tests/unit/domains.bats b/tests/unit/domains.bats index 33a88fa08..bbbe71aac 100644 --- a/tests/unit/domains.bats +++ b/tests/unit/domains.bats @@ -28,6 +28,25 @@ teardown() { assert_output "$help_output" } +@test "(domains:report) --global --format json" { + run /bin/bash -c "dokku domains:report --global --format json | jq -e ." + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku domains:report --global --format json | jq -r 'keys[]' | grep -v 'global-' | wc -l" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "0" + + run /bin/bash -c "dokku domains:report --global" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "Global domains information" +} + @test "(domains) domains" { run /bin/bash -c "dokku domains:report $TEST_APP 2>/dev/null | grep ${TEST_APP}.${DOKKU_DOMAIN}" echo "output: $output" @@ -393,6 +412,62 @@ teardown() { assert_success } +@test "(domains) app rename preserves all subdomains across multiple global vhosts" { + run /bin/bash -c "dokku domains:set-global ${DOKKU_DOMAIN} dokku.test" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku domains:set $TEST_APP $TEST_APP.${DOKKU_DOMAIN} $TEST_APP.dokku.test custom.example.com" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku apps:rename $TEST_APP other-name" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku domains:report other-name --domains-app-vhosts" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "other-name.${DOKKU_DOMAIN} other-name.dokku.test custom.example.com" + + run /bin/bash -c "dokku apps:rename other-name $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success +} + +@test "(domains) app rename preserves custom domains when no global vhost is set" { + run /bin/bash -c "dokku domains:clear-global" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku domains:set $TEST_APP custom.example.com other.example.org" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku apps:rename $TEST_APP other-name" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku domains:report other-name --domains-app-vhosts" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "custom.example.com other.example.org" + + run /bin/bash -c "dokku apps:rename other-name $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success +} + @test "(domains) verify warning on ipv4/ipv6 domain name" { touch /etc/nginx/sites-enabled/default rm "$DOKKU_ROOT/VHOST" diff --git a/tests/unit/git_3.bats b/tests/unit/git_3.bats index 8e7119d41..064d8e9a1 100644 --- a/tests/unit/git_3.bats +++ b/tests/unit/git_3.bats @@ -93,6 +93,71 @@ teardown() { echo "status: $status" assert_success assert_output_contains "Setting netrc auth entry for host github.com" + + run /bin/bash -c "printf 'piped-password' | dokku git:auth github.com piped-username" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "Setting netrc auth entry for host github.com" + + run /bin/bash -c "netrc get --netrc-file ${DOKKU_ROOT}/.netrc github.com" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "piped-username:piped-password" +} + +@test "(git) git:auth-status" { + run /bin/bash -c "dokku git:auth-status" + echo "output: $output" + echo "status: $status" + assert_failure + + run /bin/bash -c "dokku git:auth-status github.com" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku git:auth github.com username password" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku git:auth-status github.com" + echo "output: $output" + echo "status: $status" + assert_failure + + run /bin/bash -c "dokku git:auth-status github.com username password" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku git:auth-status github.com username wrong-password" + echo "output: $output" + echo "status: $status" + assert_failure + + run /bin/bash -c "dokku git:auth-status github.com other-username password" + echo "output: $output" + echo "status: $status" + assert_failure + + run /bin/bash -c "dokku git:auth-status github.com username" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "Missing password for netrc auth entry" + + run /bin/bash -c "printf 'password' | dokku git:auth-status github.com username" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "printf 'wrong-password' | dokku git:auth-status github.com username" + echo "output: $output" + echo "status: $status" + assert_failure } @test "(git) git:sync new [errors]" { @@ -731,3 +796,66 @@ teardown() { assert_success assert_output "master" } + +@test "(git:report) --format json" { + run /bin/bash -c "dokku git:set $TEST_APP deploy-branch main" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku git:report $TEST_APP --format json | jq -r '.\"deploy-branch\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "main" + + run /bin/bash -c "dokku git:report $TEST_APP --format json | jq -r '.\"global-deploy-branch\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "master" + + run /bin/bash -c "dokku git:report $TEST_APP --format json | jq -e ." + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku git:report --format json | jq -e ." + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku git:report $TEST_APP --format json --git-deploy-branch" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "--format flag cannot be specified when specifying an info flag" +} + +@test "(git:report) --global" { + run /bin/bash -c "dokku git:report --global" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "global git information" + assert_output_contains "Git global deploy branch" +} + +@test "(git:report) --global --format json" { + run /bin/bash -c "dokku git:report --global --format json | jq -e ." + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku git:report --global --format json | jq -r '.\"global-deploy-branch\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "master" + + run /bin/bash -c "dokku git:report --global --format json | jq -r 'has(\"deploy-branch\")'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "false" +} diff --git a/tests/unit/network.bats b/tests/unit/network.bats index db2831c65..311fa3154 100644 --- a/tests/unit/network.bats +++ b/tests/unit/network.bats @@ -31,6 +31,41 @@ teardown() { assert_output "$help_output" } +@test "(network:report) --global --format json" { + run /bin/bash -c "dokku network:set --global tld dokku.test" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku network:report --global --format json | jq -e ." + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku network:report --global --format json | jq -r '.\"network-global-tld\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "dokku.test" + + run /bin/bash -c "dokku network:report --global --format json | jq -r 'has(\"network-tld\")'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "false" + + run /bin/bash -c "dokku network:report --global" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "--global" + + run /bin/bash -c "dokku network:set --global tld" + echo "output: $output" + echo "status: $status" + assert_success +} + @test "(network) network:set bind-all-interfaces" { run deploy_app echo "output: $output" @@ -327,7 +362,7 @@ teardown() { } @test "(network) handle single-udp app" { - run /bin/bash -c "dokku docker-options:add $TEST_APP -p 1194:1194" + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy -p 1194:1194" echo "output: $output" echo "status: $status" assert_success diff --git a/tests/unit/nginx-vhosts_1.bats b/tests/unit/nginx-vhosts_1.bats index 265a84202..6d11422dd 100644 --- a/tests/unit/nginx-vhosts_1.bats +++ b/tests/unit/nginx-vhosts_1.bats @@ -28,6 +28,59 @@ teardown() { assert_output "$help_output" } +@test "(nginx:report) --format json" { + run create_app + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku nginx:set $TEST_APP bind-address-ipv4 127.0.0.1" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku nginx:report $TEST_APP --format json | jq -r '.\"bind-address-ipv4\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "127.0.0.1" + + run /bin/bash -c "dokku nginx:report $TEST_APP --format json | jq -e ." + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku nginx:report --format json | jq -e ." + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku nginx:report $TEST_APP --format json --nginx-bind-address-ipv4" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "--format flag cannot be specified when specifying an info flag" +} + +@test "(nginx:report) --global --format json" { + run /bin/bash -c "dokku nginx:report --global --format json | jq -e ." + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku nginx:report --global --format json | jq -r 'keys[]' | grep -v 'global-' | wc -l" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "0" + + run /bin/bash -c "dokku nginx:report --global" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "global nginx information" +} + @test "(nginx-vhosts) proxy:build-config (domains:disable/enable)" { run deploy_app echo "output: $output" diff --git a/tests/unit/nginx-vhosts_13.bats b/tests/unit/nginx-vhosts_13.bats new file mode 100644 index 000000000..37845e6fc --- /dev/null +++ b/tests/unit/nginx-vhosts_13.bats @@ -0,0 +1,99 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + global_setup + [[ -f "$DOKKU_ROOT/VHOST" ]] && cp -fp "$DOKKU_ROOT/VHOST" "$DOKKU_ROOT/VHOST.bak" +} + +teardown() { + destroy_app + [[ -f "$DOKKU_ROOT/VHOST.bak" ]] && mv "$DOKKU_ROOT/VHOST.bak" "$DOKKU_ROOT/VHOST" && chown dokku:dokku "$DOKKU_ROOT/VHOST" + global_teardown +} + +@test "(nginx-vhosts) proxy:build-config generates 502 config for undeployed app" { + run create_app + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "test -f $DOKKU_ROOT/$TEST_APP/nginx.conf" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku nginx:show-config $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "return 502" -1 +} + +@test "(nginx-vhosts) proxy:build-config undeployed app returns 502" { + run create_app + echo "output: $output" + echo "status: $status" + assert_success + sleep 2 + + assert_http_localhost_response "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/" "" "502" +} + +@test "(nginx-vhosts) proxy:build-config 502 config replaced after deploy" { + run deploy_app + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku nginx:show-config $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "return 502" 0 + assert_output_contains "proxy_pass" -1 + + assert_http_success "http://${TEST_APP}.${DOKKU_DOMAIN}" +} + +@test "(nginx-vhosts) proxy:build-config domains:add updates 502 config" { + run create_app + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku domains:add $TEST_APP custom.${DOKKU_DOMAIN}" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku nginx:show-config $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "custom.${DOKKU_DOMAIN}" -1 + assert_output_contains "return 502" -1 +} + +@test "(nginx-vhosts) proxy:build-config no nginx.conf when proxy disabled" { + run create_app + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "test -f $DOKKU_ROOT/$TEST_APP/nginx.conf" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku proxy:disable $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "test -f $DOKKU_ROOT/$TEST_APP/nginx.conf" + echo "output: $output" + echo "status: $status" + assert_failure +} diff --git a/tests/unit/nginx-vhosts_14.bats b/tests/unit/nginx-vhosts_14.bats new file mode 100644 index 000000000..51046b609 --- /dev/null +++ b/tests/unit/nginx-vhosts_14.bats @@ -0,0 +1,98 @@ +#!/usr/bin/env bats + +# Tests for the nginx:reload command and the catch-all default site shipped +# at /etc/nginx/conf.d/00-default-vhost.conf. Postinst-driven behavior +# (debconf flag, fresh-install detection, sites-enabled/default rename) is +# not exercised here because the bats harness does not reinstall the apt +# package; the rename logic is exercised manually by simulating the +# conflicting upstream vhost. + +load test_helper + +NGINX_DEFAULT_VHOST_PATH="/etc/nginx/conf.d/00-default-vhost.conf" +NGINX_DEFAULT_VHOST_SOURCE="/var/lib/dokku/core-plugins/available/nginx-vhosts/templates/default-site.conf" + +setup() { + global_setup + [[ -f "$DOKKU_ROOT/VHOST" ]] && cp -fp "$DOKKU_ROOT/VHOST" "$DOKKU_ROOT/VHOST.bak" + [[ -f "$NGINX_DEFAULT_VHOST_PATH" ]] && cp -fp "$NGINX_DEFAULT_VHOST_PATH" "${NGINX_DEFAULT_VHOST_PATH}.bats-bak" + rm -f "$NGINX_DEFAULT_VHOST_PATH" +} + +teardown() { + rm -f "$NGINX_DEFAULT_VHOST_PATH" + if [[ -f "${NGINX_DEFAULT_VHOST_PATH}.bats-bak" ]]; then + mv "${NGINX_DEFAULT_VHOST_PATH}.bats-bak" "$NGINX_DEFAULT_VHOST_PATH" + fi + rm -f /etc/nginx/sites-enabled/default.bats-stub + [[ -f "$DOKKU_ROOT/VHOST.bak" ]] && mv "$DOKKU_ROOT/VHOST.bak" "$DOKKU_ROOT/VHOST" && chown dokku:dokku "$DOKKU_ROOT/VHOST" + if sudo nginx -t &>/dev/null; then + sudo systemctl reload nginx || true + fi + global_teardown +} + +@test "(nginx-vhosts:reload) reloads with valid config" { + run /bin/bash -c "dokku nginx:reload" + echo "output: $output" + echo "status: $status" + assert_success +} + +@test "(nginx-vhosts:reload) fails with invalid config without restarting" { + local invalid_conf="/etc/nginx/conf.d/dokku-bats-invalid.conf" + echo "this is not valid nginx config" | sudo tee "$invalid_conf" >/dev/null + + run /bin/bash -c "dokku nginx:reload" + echo "output: $output" + echo "status: $status" + assert_failure + + sudo rm -f "$invalid_conf" +} + +@test "(nginx-vhosts) [default-site] shipped template installs and validates" { + [[ -f "$NGINX_DEFAULT_VHOST_SOURCE" ]] || skip "default-site template not installed; run 'sudo make copyfiles'" + + sudo install -m 0644 -o root -g root "$NGINX_DEFAULT_VHOST_SOURCE" "$NGINX_DEFAULT_VHOST_PATH" + + run /bin/bash -c "sudo grep -F 'ssl_reject_handshake on' '$NGINX_DEFAULT_VHOST_PATH'" + assert_success + run /bin/bash -c "sudo grep -F 'return 444' '$NGINX_DEFAULT_VHOST_PATH'" + assert_success + run /bin/bash -c "sudo grep -F 'default_server' '$NGINX_DEFAULT_VHOST_PATH'" + assert_success + + run /bin/bash -c "sudo nginx -t" + echo "output: $output" + echo "status: $status" + assert_success +} + +@test "(nginx-vhosts) [default-site] coexists with stock sites-enabled/default" { + [[ -f "$NGINX_DEFAULT_VHOST_SOURCE" ]] || skip "default-site template not installed; run 'sudo make copyfiles'" + + sudo mkdir -p /etc/nginx/sites-enabled + sudo tee /etc/nginx/sites-enabled/default.bats-stub >/dev/null <<'STOCK' +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + return 200 "stock default"; +} +STOCK + + sudo install -m 0644 -o root -g root "$NGINX_DEFAULT_VHOST_SOURCE" "$NGINX_DEFAULT_VHOST_PATH" + + run /bin/bash -c "sudo nginx -t" + echo "output: $output" + echo "status: $status" + assert_failure + + sudo rm -f /etc/nginx/sites-enabled/default.bats-stub + + run /bin/bash -c "sudo nginx -t" + echo "output: $output" + echo "status: $status" + assert_success +} diff --git a/tests/unit/nginx-vhosts_15.bats b/tests/unit/nginx-vhosts_15.bats new file mode 100644 index 000000000..359d52dbb --- /dev/null +++ b/tests/unit/nginx-vhosts_15.bats @@ -0,0 +1,60 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + global_setup + [[ -f "$DOKKU_ROOT/VHOST" ]] && cp -fp "$DOKKU_ROOT/VHOST" "$DOKKU_ROOT/VHOST.bak" + create_app +} + +teardown() { + destroy_app + [[ -f "$DOKKU_ROOT/VHOST.bak" ]] && mv "$DOKKU_ROOT/VHOST.bak" "$DOKKU_ROOT/VHOST" && chown dokku:dokku "$DOKKU_ROOT/VHOST" + global_teardown +} + +@test "(nginx-vhosts) pre-validate fails fast on broken nginx.conf.sigil" { + run deploy_app nodejs-express dokku@$DOKKU_DOMAIN:$TEST_APP bad_custom_nginx_template + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "Pre-validating custom nginx.conf.sigil" + assert_output_contains "Custom nginx.conf.sigil failed nginx -t validation" + assert_output_not_contains "Building $TEST_APP" + assert_output_not_contains "Releasing $TEST_APP" +} + +@test "(nginx-vhosts) pre-validate succeeds on first deploy with valid nginx.conf.sigil" { + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP custom_nginx_template + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "Pre-validating custom nginx.conf.sigil" +} + +@test "(nginx-vhosts) pre-validate is skipped when disable-custom-config=true" { + run /bin/bash -c "dokku nginx:set $TEST_APP disable-custom-config true" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app nodejs-express dokku@$DOKKU_DOMAIN:$TEST_APP bad_custom_nginx_template + echo "output: $output" + echo "status: $status" + assert_success + assert_output_not_contains "Pre-validating custom nginx.conf.sigil" +} + +@test "(nginx-vhosts) pre-validate is skipped when proxy is not nginx" { + run /bin/bash -c "dokku proxy:set $TEST_APP caddy" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app nodejs-express dokku@$DOKKU_DOMAIN:$TEST_APP bad_custom_nginx_template + echo "output: $output" + echo "status: $status" + assert_success + assert_output_not_contains "Pre-validating custom nginx.conf.sigil" +} diff --git a/tests/unit/nginx-vhosts_5.bats b/tests/unit/nginx-vhosts_5.bats index 2a0d171e1..9af7c2ea0 100644 --- a/tests/unit/nginx-vhosts_5.bats +++ b/tests/unit/nginx-vhosts_5.bats @@ -60,9 +60,9 @@ teardown() { assert_success } -@test "(nginx-vhosts) proxy:build-config (global DOKKU_PROXY_PORT)" { +@test "(nginx-vhosts) proxy:build-config (global proxy-port)" { local GLOBAL_PORT=30999 - run /bin/bash -c "dokku config:set --global DOKKU_PROXY_PORT=${GLOBAL_PORT}" + run /bin/bash -c "dokku proxy:set --global proxy-port ${GLOBAL_PORT}" echo "output: $output" echo "status: $status" assert_success @@ -74,7 +74,7 @@ teardown() { check_urls "http://${TEST_APP}.${DOKKU_DOMAIN}:${GLOBAL_PORT}" assert_http_success "http://${TEST_APP}.${DOKKU_DOMAIN}:${GLOBAL_PORT}" - run /bin/bash -c "dokku config:unset --global DOKKU_PROXY_PORT" + run /bin/bash -c "dokku proxy:set --global proxy-port" echo "output: $output" echo "status: $status" assert_success diff --git a/tests/unit/nginx-vhosts_7.bats b/tests/unit/nginx-vhosts_7.bats index 0f0a4ffb0..b72896822 100644 --- a/tests/unit/nginx-vhosts_7.bats +++ b/tests/unit/nginx-vhosts_7.bats @@ -96,4 +96,6 @@ teardown() { echo "output: $output" echo "status: $status" assert_failure + assert_output_contains "Pre-validating custom nginx.conf.sigil" + assert_output_contains "Custom nginx.conf.sigil failed nginx -t validation" } diff --git a/tests/unit/resource_3.bats b/tests/unit/resource_3.bats new file mode 100644 index 000000000..687c1a512 --- /dev/null +++ b/tests/unit/resource_3.bats @@ -0,0 +1,131 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + global_setup + create_app +} + +teardown() { + destroy_app + global_teardown +} + +@test "(resource) resource:limit --process-type build (herokuish)" { + run /bin/bash -c "dokku resource:limit --memory 256m --cpu 1 --memory-swap 512m --nvidia-gpu 1 --process-type build $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku resource:report --resource-build.limit.memory $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_output "256m" + + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH PLUGIN_CORE_AVAILABLE_PATH=$PLUGIN_CORE_AVAILABLE_PATH DOKKU_LIB_ROOT=$DOKKU_LIB_ROOT plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "--memory=256m" + assert_output_contains "--cpus=1" + assert_output_contains "--memory-swap=512m" + assert_output_contains "--gpus=1" +} + +@test "(resource) resource:limit --process-type build (dockerfile filters cpu and gpu)" { + run /bin/bash -c "dokku resource:limit --memory 512m --cpu 1 --nvidia-gpu 1 --memory-swap 1g --process-type build $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH PLUGIN_CORE_AVAILABLE_PATH=$PLUGIN_CORE_AVAILABLE_PATH DOKKU_LIB_ROOT=$DOKKU_LIB_ROOT plugn trigger docker-args-process-build $TEST_APP dockerfile < /dev/null" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "--memory=512m" + assert_output_contains "--memory-swap=1g" + assert_output_not_contains "--cpus=" + assert_output_not_contains "--gpus=" +} + +@test "(resource) resource:limit --process-type build (pack/lambda emit nothing)" { + run /bin/bash -c "dokku resource:limit --memory 512m --process-type build $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH PLUGIN_CORE_AVAILABLE_PATH=$PLUGIN_CORE_AVAILABLE_PATH DOKKU_LIB_ROOT=$DOKKU_LIB_ROOT plugn trigger docker-args-process-build $TEST_APP pack < /dev/null" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_not_contains "--memory" + + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH PLUGIN_CORE_AVAILABLE_PATH=$PLUGIN_CORE_AVAILABLE_PATH DOKKU_LIB_ROOT=$DOKKU_LIB_ROOT plugn trigger docker-args-process-build $TEST_APP lambda < /dev/null" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_not_contains "--memory" +} + +@test "(resource) resource:limit defaults do not leak into build trigger" { + run /bin/bash -c "dokku resource:limit --memory 128m $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH PLUGIN_CORE_AVAILABLE_PATH=$PLUGIN_CORE_AVAILABLE_PATH DOKKU_LIB_ROOT=$DOKKU_LIB_ROOT plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_not_contains "--memory" +} + +@test "(resource) resource:limit reservations do not apply at build time" { + run /bin/bash -c "dokku resource:reserve --memory 128m --process-type build $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH PLUGIN_CORE_AVAILABLE_PATH=$PLUGIN_CORE_AVAILABLE_PATH DOKKU_LIB_ROOT=$DOKKU_LIB_ROOT plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_not_contains "--memory-reservation" + assert_output_not_contains "--memory" +} + +@test "(resource) DOKKU_OMIT_RESOURCE_ARGS suppresses build trigger" { + run /bin/bash -c "dokku resource:limit --memory 256m --process-type build $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH PLUGIN_CORE_AVAILABLE_PATH=$PLUGIN_CORE_AVAILABLE_PATH DOKKU_LIB_ROOT=$DOKKU_LIB_ROOT DOKKU_OMIT_RESOURCE_ARGS=1 plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_not_contains "--memory" +} + +@test "(resource) resource:limit-clear --process-type build" { + run /bin/bash -c "dokku resource:limit --memory 256m --process-type build $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku resource:report --resource-build.limit.memory $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_output "256m" + + run /bin/bash -c "dokku resource:limit-clear --process-type build $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku resource:report --resource-build.limit.memory $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_failure +} diff --git a/tests/unit/scheduler-docker-local.bats b/tests/unit/scheduler-docker-local.bats index d14ea9ed4..88bafef00 100644 --- a/tests/unit/scheduler-docker-local.bats +++ b/tests/unit/scheduler-docker-local.bats @@ -175,3 +175,136 @@ teardown() { assert_output_contains "Deploys may fail when publishing ports and scaling to multiple containers" 0 assert_output_contains "Deploys may fail when publishing ports and enabling zero downtime" 0 } + +@test "(scheduler-docker-local) sends SIGTERM immediately on deploy" { + run create_app + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku checks:set $TEST_APP wait-to-retire 600" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app + echo "output: $output" + echo "status: $status" + assert_success + + local old_cid + old_cid="$(docker ps --filter label=com.dokku.app-name=$TEST_APP --filter label=com.dokku.process-type=web --format '{{.ID}}')" + [[ -n "$old_cid" ]] + + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + sleep 5 + + run /bin/bash -c "docker inspect --format '{{.State.Status}}' $old_cid" + echo "output: $output" + echo "status: $status" + assert_output "exited" + + run /bin/bash -c "docker ps --filter label=com.dokku.app-name=$TEST_APP --filter label=com.dokku.process-type=web --format '{{.Names}}'" + echo "output: $output" + echo "status: $status" + assert_output "$TEST_APP.web.1" +} + +@test "(scheduler-docker-local) scale down retires orphaned containers" { + run create_app + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku checks:set $TEST_APP wait-to-retire 1" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP web=2" + echo "output: $output" + echo "status: $status" + assert_success + + sleep 2 + + run /bin/bash -c "dokku ps:retire" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "docker ps --filter label=com.dokku.app-name=$TEST_APP --filter label=com.dokku.process-type=web --format '{{.Names}}' | wc -l" + echo "output: $output" + echo "status: $status" + assert_output "2" + + run /bin/bash -c "dokku ps:scale $TEST_APP web=1" + echo "output: $output" + echo "status: $status" + assert_success + + sleep 2 + + run /bin/bash -c "dokku ps:retire" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "docker ps --filter label=com.dokku.app-name=$TEST_APP --filter label=com.dokku.process-type=web --format '{{.Names}}'" + echo "output: $output" + echo "status: $status" + assert_output "$TEST_APP.web.1" +} + +@test "(scheduler-docker-local) ps:rebuild with image-based deploy keeps running image" { + run create_app + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku checks:set $TEST_APP wait-to-retire 1" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku git:from-image $TEST_APP cockpithq/cockpit:core-latest" + echo "output: $output" + echo "status: $status" + assert_success + + local running_image + running_image="$(docker container inspect "$TEST_APP.web.1" --format '{{.Image}}' | cut -d: -f2)" + [[ -n "$running_image" ]] + + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + sleep 3 + + run /bin/bash -c "dokku ps:retire" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "has running containers, skipping rm" 0 + + run /bin/bash -c "grep -F \"$running_image\" /var/lib/dokku/data/scheduler-docker-local/dead-images 2>/dev/null || true" + echo "output: $output" + echo "status: $status" + assert_output "" + + run /bin/bash -c "docker container inspect $TEST_APP.web.1 --format '{{.State.Status}}'" + echo "output: $output" + echo "status: $status" + assert_output "running" +} diff --git a/tests/unit/scheduler-k3s-4.bats b/tests/unit/scheduler-k3s-4.bats index 193ec1736..740f56901 100644 --- a/tests/unit/scheduler-k3s-4.bats +++ b/tests/unit/scheduler-k3s-4.bats @@ -52,6 +52,66 @@ teardown() { assert_output "5000" } +@test "(scheduler-k3s) deploy autoscaling cpu-only trigger [keda-fallback]" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=nginx install_k3s + + run /bin/bash -c "dokku apps:create $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP inject_cpu_autoscaling_app_json + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get scaledobjects.keda.sh $TEST_APP-web -o=jsonpath='{.spec.fallback}'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "" + + run /bin/bash -c "kubectl get scaledobjects.keda.sh $TEST_APP-web -o=jsonpath='{.spec.triggers[0].type}'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "cpu" +} + +@test "(scheduler-k3s) deploy autoscaling external trigger [keda-fallback]" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=nginx install_k3s + + run /bin/bash -c "dokku apps:create $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP inject_prometheus_autoscaling_app_json + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get scaledobjects.keda.sh $TEST_APP-web -o=jsonpath='{.spec.fallback.failureThreshold}'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "3" + + run /bin/bash -c "kubectl get scaledobjects.keda.sh $TEST_APP-web -o=jsonpath='{.spec.fallback.replicas}'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "1" +} + inject_app_json() { local APP="$1" local APP_REPO_DIR="$2" @@ -68,3 +128,60 @@ inject_app_json() { } EOF } + +inject_cpu_autoscaling_app_json() { + local APP="$1" + local APP_REPO_DIR="$2" + [[ -z "$APP" ]] && local APP="$TEST_APP" + cat <"$APP_REPO_DIR/app.json" +{ + "formation": { + "web": { + "autoscaling": { + "min_quantity": 1, + "max_quantity": 3, + "triggers": [ + { + "name": "cpu-trigger", + "type": "cpu", + "metadata": { + "value": "80" + } + } + ] + } + } + } +} +EOF +} + +inject_prometheus_autoscaling_app_json() { + local APP="$1" + local APP_REPO_DIR="$2" + [[ -z "$APP" ]] && local APP="$TEST_APP" + cat <"$APP_REPO_DIR/app.json" +{ + "formation": { + "web": { + "autoscaling": { + "min_quantity": 1, + "max_quantity": 3, + "triggers": [ + { + "name": "prom-trigger", + "type": "prometheus", + "metadata": { + "serverAddress": "http://prometheus.example.com:9090", + "metricName": "http_requests_total", + "threshold": "100", + "query": "sum(rate(http_requests_total{deployment=\"[[ .DEPLOYMENT_NAME ]]\"}[2m]))" + } + } + ] + } + } + } +} +EOF +} diff --git a/tests/unit/scheduler-k3s-secrets-deploy.bats b/tests/unit/scheduler-k3s-secrets-deploy.bats new file mode 100644 index 000000000..58295bb95 --- /dev/null +++ b/tests/unit/scheduler-k3s-secrets-deploy.bats @@ -0,0 +1,166 @@ +#!/usr/bin/env bats + +load test_helper + +TEST_APP="rdmtestapp" + +setup() { + uninstall_k3s || true + global_setup + dokku nginx:stop + export KUBECONFIG="/etc/rancher/k3s/k3s.yaml" +} + +teardown() { + global_teardown + dokku nginx:start + uninstall_k3s || true +} + +@test "(scheduler-k3s:secrets) helm rollback of app chart leaves config and pull secret releases intact" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=nginx install_k3s + + run /bin/bash -c "dokku apps:create $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku domains:set $TEST_APP $TEST_APP.dokku.me" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku config:set --no-restart $TEST_APP HELLO=world" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "sleep 30" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "helm rollback $TEST_APP 1 -n default" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get secret config-$TEST_APP -n default" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get secret pull-secret-$TEST_APP -n default" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get deployment ${TEST_APP}-web -n default -o jsonpath='{.spec.template.spec.containers[0].envFrom[0].secretRef.name}'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "config-$TEST_APP" +} + +@test "(scheduler-k3s:secrets) user image-pull-secrets override skips dokku-managed pull secret release" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=nginx install_k3s + + run /bin/bash -c "dokku apps:create $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku domains:set $TEST_APP $TEST_APP.dokku.me" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku scheduler-k3s:set $TEST_APP image-pull-secrets my-custom-pull-secret" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get secret pull-secret-$TEST_APP -n default" + echo "output: $output" + echo "status: $status" + assert_failure + + run /bin/bash -c "kubectl get deployment ${TEST_APP}-web -n default -o jsonpath='{.spec.template.spec.imagePullSecrets[*].name}'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "my-custom-pull-secret" +} + +@test "(scheduler-k3s:secrets) leaked imagePullSecrets entries get pruned on next deploy" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=nginx install_k3s + + run /bin/bash -c "dokku apps:create $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku domains:set $TEST_APP $TEST_APP.dokku.me" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl patch deployment ${TEST_APP}-web -n default --type=json -p='[{\"op\":\"add\",\"path\":\"/spec/template/spec/imagePullSecrets/-\",\"value\":{\"name\":\"ims-${TEST_APP}.111\"}},{\"op\":\"add\",\"path\":\"/spec/template/spec/imagePullSecrets/-\",\"value\":{\"name\":\"ims-${TEST_APP}.222\"}}]'" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get deployment ${TEST_APP}-web -n default -o jsonpath='{.spec.template.spec.imagePullSecrets[*].name}'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "ims-${TEST_APP}.111" + + run /bin/bash -c "dokku ps:rebuild $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "sleep 30" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get deployment ${TEST_APP}-web -n default -o jsonpath='{.spec.template.spec.imagePullSecrets[*].name}'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "pull-secret-$TEST_APP" +} diff --git a/tests/unit/scheduler-k3s-secrets.bats b/tests/unit/scheduler-k3s-secrets.bats new file mode 100644 index 000000000..830e6759a --- /dev/null +++ b/tests/unit/scheduler-k3s-secrets.bats @@ -0,0 +1,162 @@ +#!/usr/bin/env bats + +load test_helper + +TEST_APP="rdmtestapp" + +setup() { + uninstall_k3s || true + global_setup + dokku nginx:stop + export KUBECONFIG="/etc/rancher/k3s/k3s.yaml" +} + +teardown() { + global_teardown + dokku nginx:start + uninstall_k3s || true +} + +@test "(scheduler-k3s:secrets) deploy creates stable config and pull secrets" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=nginx install_k3s + + run /bin/bash -c "dokku apps:create $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku domains:set $TEST_APP $TEST_APP.dokku.me" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku config:set --no-restart $TEST_APP HELLO=world" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get secret config-$TEST_APP -n default" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get secret pull-secret-$TEST_APP -n default" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get deployment ${TEST_APP}-web -n default -o jsonpath='{.spec.template.spec.containers[0].envFrom[0].secretRef.name}'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "config-$TEST_APP" + + run /bin/bash -c "kubectl get deployment ${TEST_APP}-web -n default -o jsonpath='{.spec.template.spec.imagePullSecrets[*].name}'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "pull-secret-$TEST_APP" + + run /bin/bash -c "kubectl get secret config-$TEST_APP -n default -o jsonpath='{.data.HELLO}' | base64 --decode" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "world" +} + +@test "(scheduler-k3s:secrets) config:set updates secret in place" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=nginx install_k3s + + run /bin/bash -c "dokku apps:create $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku domains:set $TEST_APP $TEST_APP.dokku.me" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku config:set --no-restart $TEST_APP HELLO=world" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku config:set $TEST_APP HELLO=mars" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "sleep 10" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get secret config-$TEST_APP -n default -o jsonpath='{.data.HELLO}' | base64 --decode" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "mars" +} + +@test "(scheduler-k3s:secrets) apps:destroy cleans up secret releases" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=nginx install_k3s + + run /bin/bash -c "dokku apps:create $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku domains:set $TEST_APP $TEST_APP.dokku.me" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get secret config-$TEST_APP -n default" + assert_success + + run /bin/bash -c "kubectl get secret pull-secret-$TEST_APP -n default" + assert_success + + run /bin/bash -c "dokku --force apps:destroy $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get secret config-$TEST_APP -n default" + echo "output: $output" + echo "status: $status" + assert_failure + + run /bin/bash -c "kubectl get secret pull-secret-$TEST_APP -n default" + echo "output: $output" + echo "status: $status" + assert_failure +} diff --git a/tests/unit/storage.bats b/tests/unit/storage.bats index 9687d0e73..83af1fc59 100644 --- a/tests/unit/storage.bats +++ b/tests/unit/storage.bats @@ -183,3 +183,217 @@ teardown() { assert_success assert_output "mount_volume:/mount" } + +@test "(storage) storage:create / storage:list-entries / storage:destroy" { + run /bin/bash -c "dokku storage:create rdmtest-entry" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku storage:list-entries --format json | jq -r '.[].name' | grep '^rdmtest-entry$'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "rdmtest-entry" + + run /bin/bash -c "dokku storage:info rdmtest-entry --format json | jq -r '.scheduler'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "docker-local" + + run /bin/bash -c "dokku storage:destroy rdmtest-entry" + echo "output: $output" + echo "status: $status" + assert_success +} + +@test "(storage) storage:create rejects invalid names" { + # underscore: rejected + run /bin/bash -c "dokku storage:create rdmtest_invalid" + echo "output: $output" + echo "status: $status" + assert_failure + + # uppercase: rejected + run /bin/bash -c "dokku storage:create RdmTest" + echo "output: $output" + echo "status: $status" + assert_failure + + # 46 chars: too long + long_name=$(printf 'a%.0s' {1..46}) + run /bin/bash -c "dokku storage:create $long_name" + echo "output: $output" + echo "status: $status" + assert_failure + + # legacy- prefix: reserved + run /bin/bash -c "dokku storage:create legacy-foo" + echo "output: $output" + echo "status: $status" + assert_failure +} + +@test "(storage) storage:create + storage:mount with named entry attaches multiple entries to one app" { + run /bin/bash -c "dokku storage:create rdmtest-data" + assert_success + run /bin/bash -c "dokku storage:create rdmtest-cache" + assert_success + + run /bin/bash -c "dokku storage:mount $TEST_APP rdmtest-data --container-dir /data" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku storage:mount $TEST_APP rdmtest-cache --container-dir /cache" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku storage:list $TEST_APP --format json | jq -r '. | length'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "2" + + run /bin/bash -c "dokku storage:list $TEST_APP --format json | jq -r '.[].entry_name' | sort | xargs" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "rdmtest-cache rdmtest-data" + + # cleanup + run /bin/bash -c "dokku storage:unmount $TEST_APP rdmtest-data" + assert_success + run /bin/bash -c "dokku storage:unmount $TEST_APP rdmtest-cache" + assert_success + + run /bin/bash -c "dokku storage:list $TEST_APP --format json | jq -r '. | length'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "0" + + run /bin/bash -c "dokku storage:destroy rdmtest-data" + assert_success + run /bin/bash -c "dokku storage:destroy rdmtest-cache" + assert_success +} + +@test "(storage) storage:destroy refuses to remove a still-mounted entry" { + run /bin/bash -c "dokku storage:create rdmtest-busy" + assert_success + run /bin/bash -c "dokku storage:mount $TEST_APP rdmtest-busy --container-dir /data" + assert_success + + run /bin/bash -c "dokku storage:destroy rdmtest-busy" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "still mounted" + + run /bin/bash -c "dokku storage:unmount $TEST_APP rdmtest-busy" + assert_success + run /bin/bash -c "dokku storage:destroy rdmtest-busy" + assert_success +} + +@test "(storage) storage:exec runs a non-interactive command and propagates exit code" { + run /bin/bash -c "dokku storage:create rdmtest-exec" + assert_success + + # docker pull image up-front so the test isn't gated on registry latency. + run /bin/bash -c "docker pull alpine:3 >/dev/null" + assert_success + + run /bin/bash -c "dokku storage:exec rdmtest-exec -- /bin/sh -c 'touch /data/marker && ls /data/marker'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "/data/marker" + + run /bin/bash -c "dokku storage:exec rdmtest-exec -- /bin/sh -c 'exit 42'" + echo "output: $output" + echo "status: $status" + assert_equal "$status" 42 + + run /bin/bash -c "dokku storage:destroy rdmtest-exec" + assert_success +} + +@test "(storage) storage:migrate converts legacy docker-options -v lines into attachments" { + # Stage a legacy `-v` line directly via docker-options:add. This + # bypasses the new storage:mount path so the line lives in + # docker-options without a corresponding attachment, simulating an + # app upgraded from a pre-PR Dokku version. + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy,run \"-v /tmp/legacy-mount:/legacy\"" + echo "output: $output" + echo "status: $status" + assert_success + + # Before migration, storage:list shows nothing (attachment-only) and + # docker-options has the -v line. + run /bin/bash -c "dokku storage:list $TEST_APP --format json | jq -r '. | length'" + assert_success + assert_output "0" + + run /bin/bash -c "dokku docker-options:report $TEST_APP --docker-options-deploy" + assert_success + assert_output_contains "-v /tmp/legacy-mount:/legacy" + + # Run the migration for this app. + run /bin/bash -c "dokku storage:migrate $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + # storage:list now surfaces the synthesized colon form. + run /bin/bash -c "dokku --quiet storage:list $TEST_APP" + assert_success + assert_output "/tmp/legacy-mount:/legacy" + + # The synthesized entry shows up under the legacy- prefix. + run /bin/bash -c "dokku storage:list-entries --format json | jq -r '.[].name' | grep '^legacy-' | head -1" + echo "output: $output" + echo "status: $status" + assert_success + + # docker-options no longer holds the -v line on either phase. + run /bin/bash -c "dokku docker-options:report $TEST_APP --docker-options-deploy" + assert_success + assert_output_not_contains "-v /tmp/legacy-mount:/legacy" + + run /bin/bash -c "dokku docker-options:report $TEST_APP --docker-options-run" + assert_success + assert_output_not_contains "-v /tmp/legacy-mount:/legacy" + + # Idempotency: re-running storage:migrate is a no-op (still one + # attachment, no errors). + run /bin/bash -c "dokku storage:migrate $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku storage:list $TEST_APP --format json | jq -r '. | length'" + assert_success + assert_output "1" + + # Cleanup: unmount + destroy the synthesized legacy entry. We need + # the entry name from list-entries since legacy- is content-derived. + legacy_name=$(dokku storage:list-entries --format json | jq -r '.[] | select(.name | startswith("legacy-")) | .name' | head -1) + if [[ -n "$legacy_name" ]]; then + run /bin/bash -c "dokku storage:unmount $TEST_APP $legacy_name --container-dir /legacy" + assert_success + run /bin/bash -c "dokku storage:destroy $legacy_name" + assert_success + fi +} + +@test "(storage) storage:ensure-directory emits deprecation warning" { + run /bin/bash -c "dokku storage:ensure-directory $TEST_APP 2>&1" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "deprecated" +}