From 8240096ddac6fae789da2148442a8371143d2215 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Tue, 10 Mar 2026 15:37:49 -0400 Subject: [PATCH 001/116] feat: upgrade ingress-nginx chart from 4.10.0 to 4.14.4 --- plugins/scheduler-k3s/scheduler_k3s.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scheduler-k3s/scheduler_k3s.go b/plugins/scheduler-k3s/scheduler_k3s.go index 190571c5f..2d7cd5df8 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.14.4", }, { ChartPath: "keda", From 2b0f96dc1abbac33e2319712acae8d298874d160 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Tue, 10 Mar 2026 15:38:33 -0400 Subject: [PATCH 002/116] feat: upgrade vector chart from 0.42.0 to 0.50.0 --- plugins/scheduler-k3s/scheduler_k3s.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scheduler-k3s/scheduler_k3s.go b/plugins/scheduler-k3s/scheduler_k3s.go index 190571c5f..c97b3d4c2 100644 --- a/plugins/scheduler-k3s/scheduler_k3s.go +++ b/plugins/scheduler-k3s/scheduler_k3s.go @@ -140,7 +140,7 @@ var HelmCharts = []HelmChart{ Namespace: "vector", ReleaseName: "vector", RepoURL: "https://helm.vector.dev", - Version: "0.42.0", + Version: "0.50.0", }, } From 5a787af3ae5e5858a6c2e6809f4ce94d0f8ede03 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 23 Nov 2025 01:39:14 -0500 Subject: [PATCH 003/116] feat: add support for specifying buildpacks via app.json The order of detection is: - buildpacks:set - app.json - .buildpacks - auto-detected from codebase Closes #7102 --- plugins/app-json/appjson.go | 9 +++++++++ plugins/buildpacks/functions.go | 33 +++++++++++++++++++++++++++++++++ plugins/buildpacks/go.mod | 6 ++++++ plugins/buildpacks/go.sum | 6 ++++++ plugins/buildpacks/triggers.go | 11 +++++++++-- plugins/scheduler-k3s/go.mod | 16 ++++++++-------- 6 files changed, 71 insertions(+), 10 deletions(-) diff --git a/plugins/app-json/appjson.go b/plugins/app-json/appjson.go index 3133330c1..ddb045a11 100644 --- a/plugins/app-json/appjson.go +++ b/plugins/app-json/appjson.go @@ -26,6 +26,9 @@ var ( // 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"` @@ -51,6 +54,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/buildpacks/functions.go b/plugins/buildpacks/functions.go index 47acbbc78..37d8e9515 100644 --- a/plugins/buildpacks/functions.go +++ b/plugins/buildpacks/functions.go @@ -8,9 +8,38 @@ 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 + } + + 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 +56,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..ae4000145 100644 --- a/plugins/buildpacks/go.mod +++ b/plugins/buildpacks/go.mod @@ -3,6 +3,7 @@ module github.com/dokku/dokku/plugins/buildpacks go 1.25.5 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/spf13/pflag v1.0.10 ) @@ -12,6 +13,7 @@ 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/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 +23,13 @@ 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 golang.org/x/crypto v0.50.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.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..1db1549b6 100644 --- a/plugins/buildpacks/go.sum +++ b/plugins/buildpacks/go.sum @@ -10,6 +10,8 @@ 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/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -36,6 +38,8 @@ 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= @@ -53,3 +57,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= +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/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/scheduler-k3s/go.mod b/plugins/scheduler-k3s/go.mod index d7868e783..f19ee7996 100644 --- a/plugins/scheduler-k3s/go.mod +++ b/plugins/scheduler-k3s/go.mod @@ -5,14 +5,14 @@ go 1.25.5 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/fatih/color v1.19.0 github.com/fluxcd/pkg/kustomize v1.29.0 github.com/go-openapi/jsonpointer v0.23.0 From 44dbbe6a3473b21d9beb6e6d313c2fbab9ab80d2 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Fri, 28 Nov 2025 02:31:13 -0500 Subject: [PATCH 004/116] fix: return buildpacks specified by the buildpacks plugin --- plugins/buildpacks/functions.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/buildpacks/functions.go b/plugins/buildpacks/functions.go index 37d8e9515..7037cf516 100644 --- a/plugins/buildpacks/functions.go +++ b/plugins/buildpacks/functions.go @@ -24,6 +24,10 @@ func getBuildpacks(appName string) ([]string, error) { return buildpacks, err } + if len(buildpacks) > 0 { + return buildpacks, nil + } + appJSON, err := appjson.GetAppJSON(appName) if err != nil { return buildpacks, err From b0b11bcf263df643ee70bb8dc602da31882d4f0a Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 18 Apr 2026 05:43:44 -0400 Subject: [PATCH 005/116] fix: remove buildpacks from dockerfile test app and add tests for app.json buildpacks The buildpacks entry in tests/apps/dockerfile/app.json caused the post-extract trigger to write a .buildpacks file during Dockerfile-based deploys, breaking builder detection. Remove the entry and add Go unit tests for getBuildpacks/validBuildpackURL, a bats integration test for deploying with app.json buildpacks, and documentation for the new feature. --- docs/appendices/file-formats/app-json.md | 19 +++ .../builders/buildpack-management.md | 18 +++ plugins/buildpacks/buildpacks_test.go | 141 ++++++++++++++++++ plugins/buildpacks/go.mod | 5 + plugins/buildpacks/go.sum | 1 + tests.mk | 4 +- tests/apps/dockerfile/app.json | 5 - tests/unit/buildpacks.bats | 27 ++++ 8 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 plugins/buildpacks/buildpacks_test.go diff --git a/docs/appendices/file-formats/app-json.md b/docs/appendices/file-formats/app-json.md index 53924fabc..6532c93b2 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 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/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/go.mod b/plugins/buildpacks/go.mod index ae4000145..5afa00299 100644 --- a/plugins/buildpacks/go.mod +++ b/plugins/buildpacks/go.mod @@ -11,6 +11,7 @@ require ( 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 @@ -18,15 +19,19 @@ require ( 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/onsi/gomega v1.39.1 // 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 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 ) diff --git a/plugins/buildpacks/go.sum b/plugins/buildpacks/go.sum index 1db1549b6..957619840 100644 --- a/plugins/buildpacks/go.sum +++ b/plugins/buildpacks/go.sum @@ -55,6 +55,7 @@ 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/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= diff --git a/tests.mk b/tests.mk index c31d42db0..d13240a98 100644 --- a/tests.mk +++ b/tests.mk @@ -149,6 +149,7 @@ 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 @@ -177,9 +178,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..39127109e 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": { 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" From 08596c9648af38bb87c5822538a30519e63fb2b5 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 18 Apr 2026 20:29:05 -0400 Subject: [PATCH 006/116] fix: skip app.json buildpacks for image-based deploys When deploying via git:from-image, the source image may contain an app.json with buildpacks entries that are not relevant. Check for the source-image property and skip app.json buildpacks when set. --- plugins/buildpacks/functions.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugins/buildpacks/functions.go b/plugins/buildpacks/functions.go index 7037cf516..89beebc3a 100644 --- a/plugins/buildpacks/functions.go +++ b/plugins/buildpacks/functions.go @@ -28,6 +28,14 @@ func getBuildpacks(appName string) ([]string, error) { 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 From 2993fa800ce228cb31186a5bbdd367a4e3b23c8a Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Fri, 24 Apr 2026 02:10:30 -0400 Subject: [PATCH 007/116] feat(nginx): generate 502 config for apps without web listeners When an app has no web listeners (not yet deployed, no web process type, or web processes not running), generate a minimal nginx config that returns 502 Bad Gateway instead of having no config at all. This ensures domains resolve, monitoring tools detect non-200 status codes, and SSL certificate provisioning tools like letsencrypt can function. --- docs/networking/proxies/nginx.md | 15 +++ plugins/nginx-vhosts/functions | 81 +++++++++++++-- plugins/nginx-vhosts/post-create | 21 ++++ plugins/nginx-vhosts/proxy-build-config | 2 +- .../nginx-vhosts/templates/nginx.conf.sigil | 12 +++ tests/unit/nginx-vhosts_13.bats | 98 +++++++++++++++++++ 6 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 plugins/nginx-vhosts/post-create create mode 100644 tests/unit/nginx-vhosts_13.bats diff --git a/docs/networking/proxies/nginx.md b/docs/networking/proxies/nginx.md index 330f5fa0f..8ca02d0f6 100644 --- a/docs/networking/proxies/nginx.md +++ b/docs/networking/proxies/nginx.md @@ -27,6 +27,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.37.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] diff --git a/plugins/nginx-vhosts/functions b/plugins/nginx-vhosts/functions index 0f81141c4..dd5fdf453 100755 --- a/plugins/nginx-vhosts/functions +++ b/plugins/nginx-vhosts/functions @@ -306,9 +306,19 @@ nginx_build_config() { 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 + 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")" + + 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 + fi + + if [[ -n "$DOKKU_APP_LISTENERS" ]] && (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) @@ -329,14 +339,6 @@ nginx_build_config() { local NGINX_TEMPLATE_SOURCE="app-supplied" 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" @@ -413,6 +415,65 @@ nginx_build_config() { plugn trigger nginx-pre-reload "$APP" "$DOKKU_APP_LISTEN_PORT" "$DOKKU_APP_LISTEN_IP" + dokku_log_verbose "Reloading nginx" + validate_nginx && restart_nginx >/dev/null + elif [[ -z "$DOKKU_APP_LISTENERS" ]] && [[ -z "$PASSED_LISTEN_IP_PORT" ]]; then + 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") + trap "rm -rf '$NGINX_CONF' '$NGINX_BUILD_CONFIG_TMP_WORK_DIR' >/dev/null" RETURN INT TERM EXIT + + local SIGIL_PARAMS=(-f "$NGINX_TEMPLATE" APP="$APP" DOKKU_ROOT="$DOKKU_ROOT" + NOSSL_SERVER_NAME="$NOSSL_SERVER_NAME" + DOKKU_APP_LISTENERS="" + DOKKU_APP_WEB_LISTENERS="" + DOKKU_LIB_ROOT="$DOKKU_LIB_ROOT" + 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_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" + 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") + + 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" + + dokku_log_info1 "Creating $SCHEME nginx.conf" + mkdir -p "$DOKKU_ROOT/$APP/nginx.conf.d" + mv "$NGINX_CONF" "$DOKKU_ROOT/$APP/nginx.conf" + + plugn trigger nginx-pre-reload "$APP" "$DOKKU_APP_LISTEN_PORT" "$DOKKU_APP_LISTEN_IP" + dokku_log_verbose "Reloading nginx" validate_nginx && restart_nginx >/dev/null fi diff --git a/plugins/nginx-vhosts/post-create b/plugins/nginx-vhosts/post-create new file mode 100644 index 000000000..161dd046e --- /dev/null +++ b/plugins/nginx-vhosts/post-create @@ -0,0 +1,21 @@ +#!/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 + + nginx_build_config "$APP" +} + +trigger-nginx-vhosts-post-create "$@" 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/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/tests/unit/nginx-vhosts_13.bats b/tests/unit/nginx-vhosts_13.bats new file mode 100644 index 000000000..049e1f46c --- /dev/null +++ b/tests/unit/nginx-vhosts_13.bats @@ -0,0 +1,98 @@ +#!/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 + + 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 +} From 5bf82d258512249d70ed08ed3264b4dea427ca87 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Fri, 24 Apr 2026 02:40:27 -0400 Subject: [PATCH 008/116] docs: add note about when the new feature lands --- docs/networking/proxies/nginx.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/networking/proxies/nginx.md b/docs/networking/proxies/nginx.md index 8ca02d0f6..9e6c63d56 100644 --- a/docs/networking/proxies/nginx.md +++ b/docs/networking/proxies/nginx.md @@ -30,7 +30,7 @@ Nginx will proxy the requests in a [round-robin balancing fashion](http://nginx. ### Nginx Configuration for Undeployed Apps > [!IMPORTANT] -> New as of 0.37.0 +> 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: From 20f2100d467acf8d6303ed7fc7d2c8c6429a5b71 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Fri, 24 Apr 2026 02:54:06 -0400 Subject: [PATCH 009/116] docs: add 0.38.0 migration guide with nginx.conf.sigil update notes Custom nginx.conf.sigil templates that reference DOKKU_APP_WEB_LISTENERS may now receive an empty value when rendered for apps without running web processes. The migration guide documents how to handle this with a conditional in the template. --- docs/README.md | 1 + docs/appendices/0.38.0-migration-guide.md | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 docs/appendices/0.38.0-migration-guide.md diff --git a/docs/README.md b/docs/README.md index 08ce6096d..b4e6d5c81 100644 --- a/docs/README.md +++ b/docs/README.md @@ -121,6 +121,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/appendices/0.38.0-migration-guide.md b/docs/appendices/0.38.0-migration-guide.md new file mode 100644 index 000000000..e547325ec --- /dev/null +++ b/docs/appendices/0.38.0-migration-guide.md @@ -0,0 +1,15 @@ +# 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 }} + } + ``` From 7529d8cb081efd5f6ca708d29b9474a77443df2a Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Fri, 24 Apr 2026 03:04:04 -0400 Subject: [PATCH 010/116] refactor(nginx): deduplicate sigil params in nginx_build_config Merge the deployed and empty-listeners branches into a single flow that shares the SIGIL_PARAMS array, temp dir setup, sigil rendering, and nginx reload. Branch only for deployed-specific logic: image verification, custom template lookup, per-process listeners, and HSTS. --- plugins/nginx-vhosts/functions | 137 +++++++++++---------------------- 1 file changed, 47 insertions(+), 90 deletions(-) diff --git a/plugins/nginx-vhosts/functions b/plugins/nginx-vhosts/functions index dd5fdf453..8ff9386f6 100755 --- a/plugins/nginx-vhosts/functions +++ b/plugins/nginx-vhosts/functions @@ -318,28 +318,38 @@ nginx_build_config() { dokku_log_warn_quiet "No web listeners specified for $APP" fi + local IS_DEPLOYED_WITH_LISTENERS=false if [[ -n "$DOKKU_APP_LISTENERS" ]] && (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" + IS_DEPLOYED_WITH_LISTENERS=true + fi + + if [[ "$IS_DEPLOYED_WITH_LISTENERS" == "true" ]] || { [[ -z "$DOKKU_APP_LISTENERS" ]] && [[ -z "$PASSED_LISTEN_IP_PORT" ]]; }; 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' + local NGINX_TEMPLATE="$CUSTOM_NGINX_TEMPLATE" + local NGINX_TEMPLATE_SOURCE="app-supplied" + fi + + eval "$(config_export app "$APP")" fi - 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 @@ -384,87 +394,30 @@ nginx_build_config() { 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 [[ "$IS_DEPLOYED_WITH_LISTENERS" == "true" ]]; then + 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" + 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 + else + SIGIL_PARAMS+=(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" - - dokku_log_info1 "Creating $SCHEME nginx.conf" - mv "$NGINX_CONF" "$DOKKU_ROOT/$APP/nginx.conf" - - fn-nginx-vhosts-manage-hsts "$APP" "$SSL_INUSE" - - plugn trigger nginx-pre-reload "$APP" "$DOKKU_APP_LISTEN_PORT" "$DOKKU_APP_LISTEN_IP" - - dokku_log_verbose "Reloading nginx" - validate_nginx && restart_nginx >/dev/null - elif [[ -z "$DOKKU_APP_LISTENERS" ]] && [[ -z "$PASSED_LISTEN_IP_PORT" ]]; then - 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") - trap "rm -rf '$NGINX_CONF' '$NGINX_BUILD_CONFIG_TMP_WORK_DIR' >/dev/null" RETURN INT TERM EXIT - - local SIGIL_PARAMS=(-f "$NGINX_TEMPLATE" APP="$APP" DOKKU_ROOT="$DOKKU_ROOT" - NOSSL_SERVER_NAME="$NOSSL_SERVER_NAME" - DOKKU_APP_LISTENERS="" - DOKKU_APP_WEB_LISTENERS="" - DOKKU_LIB_ROOT="$DOKKU_LIB_ROOT" - 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_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" - 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") - 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" @@ -472,6 +425,10 @@ nginx_build_config() { mkdir -p "$DOKKU_ROOT/$APP/nginx.conf.d" mv "$NGINX_CONF" "$DOKKU_ROOT/$APP/nginx.conf" + 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" dokku_log_verbose "Reloading nginx" From fc7ddf34ba151101b70a91d682fa0aff38dadbf3 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Fri, 24 Apr 2026 03:57:22 -0400 Subject: [PATCH 011/116] fix(nginx): make post-create executable and guard empty port maps The post-create trigger was not executable so plugn skipped it. Also skip generating the 502 config when PROXY_PORT_MAP is empty (e.g. after ports:clear) to avoid a sigil template index error. --- plugins/nginx-vhosts/functions | 2 +- plugins/nginx-vhosts/post-create | 0 2 files changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 plugins/nginx-vhosts/post-create diff --git a/plugins/nginx-vhosts/functions b/plugins/nginx-vhosts/functions index 8ff9386f6..152bec39f 100755 --- a/plugins/nginx-vhosts/functions +++ b/plugins/nginx-vhosts/functions @@ -323,7 +323,7 @@ nginx_build_config() { IS_DEPLOYED_WITH_LISTENERS=true fi - if [[ "$IS_DEPLOYED_WITH_LISTENERS" == "true" ]] || { [[ -z "$DOKKU_APP_LISTENERS" ]] && [[ -z "$PASSED_LISTEN_IP_PORT" ]]; }; then + 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") diff --git a/plugins/nginx-vhosts/post-create b/plugins/nginx-vhosts/post-create old mode 100644 new mode 100755 From dcce09f19f55cc88bcc2575e0676aa4273543fa8 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Fri, 24 Apr 2026 04:14:49 -0400 Subject: [PATCH 012/116] fix(nginx): skip post-create config when nginx is stopped When nginx is stopped (e.g. during traefik/caddy tests), the post-create trigger should not attempt to build and reload the nginx config as the service is not running. --- plugins/nginx-vhosts/post-create | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/nginx-vhosts/post-create b/plugins/nginx-vhosts/post-create index 161dd046e..04f15e915 100755 --- a/plugins/nginx-vhosts/post-create +++ b/plugins/nginx-vhosts/post-create @@ -15,6 +15,10 @@ trigger-nginx-vhosts-post-create() { return fi + if [[ "$(fn-plugin-property-get "nginx" "--global" "proxy-status")" == "stopped" ]]; then + return + fi + nginx_build_config "$APP" } From ddf470282e9d8c59c76b203c789f76b7f669e12e Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Fri, 24 Apr 2026 05:02:49 -0400 Subject: [PATCH 013/116] fix(tests): update config and nginx tests for post-create side effects The config:show and config:export tests now include DOKKU_PROXY_PORT in expected output since ports-configure runs at app creation time. Add sleep before HTTP assertion in nginx-vhosts_13 test to allow nginx async reload to complete. --- tests/unit/config.bats | 10 +++++----- tests/unit/nginx-vhosts_13.bats | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/unit/config.bats b/tests/unit/config.bats index e9bfb1fce..af758b96a 100644 --- a/tests/unit/config.bats +++ b/tests/unit/config.bats @@ -506,7 +506,7 @@ teardown() { run /bin/bash -c "dokku --app $TEST_APP config:show" echo "output: $output" echo "status: $status" - assert_output "=====> $TEST_APP env vars"$'\nBKEY: true\naKey: true\nbKey: true\nzKey: true' + assert_output "=====> $TEST_APP env vars"$'\nBKEY: true\nDOKKU_PROXY_PORT: 80\naKey: true\nbKey: true\nzKey: true' } @test "(config) config:export" { @@ -518,20 +518,20 @@ teardown() { run /bin/bash -c "dokku config:export --format docker-args $TEST_APP" echo "output: $output" echo "status: $status" - assert_output "--env=BKEY='true' --env=aKey='true' --env=bKey='true' --env=zKey='true'" + assert_output "--env=BKEY='true' --env=DOKKU_PROXY_PORT='80' --env=aKey='true' --env=bKey='true' --env=zKey='true'" run /bin/bash -c "dokku config:export --format shell $TEST_APP" echo "output: $output" echo "status: $status" - assert_output "BKEY='true' aKey='true' bKey='true' zKey='true' " + assert_output "BKEY='true' DOKKU_PROXY_PORT='80' aKey='true' bKey='true' zKey='true' " run /bin/bash -c "dokku config:export --format json $TEST_APP" echo "output: $output" echo "status: $status" - assert_output '{"BKEY":"true","aKey":"true","bKey":"true","zKey":"true"}' + assert_output '{"BKEY":"true","DOKKU_PROXY_PORT":"80","aKey":"true","bKey":"true","zKey":"true"}' run /bin/bash -c "dokku config:export --format json-list $TEST_APP" echo "output: $output" echo "status: $status" - assert_output '[{"name":"BKEY","value":"true"},{"name":"aKey","value":"true"},{"name":"bKey","value":"true"},{"name":"zKey","value":"true"}]' + assert_output '[{"name":"BKEY","value":"true"},{"name":"DOKKU_PROXY_PORT","value":"80"},{"name":"aKey","value":"true"},{"name":"bKey","value":"true"},{"name":"zKey","value":"true"}]' } diff --git a/tests/unit/nginx-vhosts_13.bats b/tests/unit/nginx-vhosts_13.bats index 049e1f46c..37845e6fc 100644 --- a/tests/unit/nginx-vhosts_13.bats +++ b/tests/unit/nginx-vhosts_13.bats @@ -36,6 +36,7 @@ teardown() { echo "output: $output" echo "status: $status" assert_success + sleep 2 assert_http_localhost_response "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/" "" "502" } From f3e29c7c94bbe616099761062573df7ec1f817de Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:53:53 +0000 Subject: [PATCH 014/116] chore(deps): bump dokku/openresty-docker-proxy Bumps dokku/openresty-docker-proxy from 0.9.3 to 0.10.0. --- updated-dependencies: - dependency-name: dokku/openresty-docker-proxy dependency-version: 0.10.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- plugins/openresty-vhosts/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 2745e4605479f27c990de89eee19a5fe8862f7cd Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 00:55:55 -0400 Subject: [PATCH 015/116] fix(tests): expect 502 for cloned app without deploy The apps:clone --skip-deploy test expected 404 for the cloned app's domain. With the new empty nginx config, undeployed apps now correctly return 502 Bad Gateway instead of falling through to nginx's default. --- tests/unit/apps_2.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/apps_2.bats b/tests/unit/apps_2.bats index a176cece0..661bc9822 100644 --- a/tests/unit/apps_2.bats +++ b/tests/unit/apps_2.bats @@ -166,7 +166,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" From 1308e219470e52a9c6d6f6d18c404af0f4ea304c Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 05:11:07 -0400 Subject: [PATCH 016/116] feat: migrate environment variables to plugin properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standardize how environment variables are migrated to properties during install triggers and migrate all remaining DOKKU_* config vars to their appropriate plugin properties. Adds a reusable MigrateConfigToProperties() function in the common package with Transform callback and ListProperty support, plus a migrate-config-to-property subcommand for the prop binary so shell plugins can use the same code path. Migrated variables and their new property owners: - DOKKU_APP_PROXY_TYPE/DOKKU_PROXY_TYPE → proxy type - DOKKU_DISABLE_PROXY → proxy disabled - DOKKU_PROXY_PORT → ports proxy-port - DOKKU_PROXY_SSL_PORT → ports proxy-ssl-port - DOKKU_APP_RESTORE → ps restore - DOKKU_SKIP_DEPLOY → ps skip-deploy - DOKKU_START_CMD → ps start-cmd - DOKKU_DOCKERFILE_START_CMD → ps dockerfile-start-cmd - DOKKU_DISABLE_APP_AUTOCREATION → apps disable-autocreation - DOKKU_APP_SHELL → scheduler shell - DOKKU_SKIP_CLEANUP → builder skip-cleanup - DOKKU_CHECKS_DISABLED → checks disabled - DOKKU_CHECKS_SKIPPED → checks skipped - DOKKU_CHECKS_WAIT → checks wait - DOKKU_CHECKS_TIMEOUT → checks timeout - DOKKU_CHECKS_ATTEMPTS → checks attempts - DOKKU_DEFAULT_CHECKS_WAIT → checks default-wait - DOKKU_SKIP_ALL_CHECKS → checks disabled (legacy) - DOKKU_SKIP_DEFAULT_CHECKS → checks skipped (legacy) Also refactors existing bespoke migration loops in scheduler, ports, ps, builder, and nginx-vhosts plugins to use the standardized utility. Removes DOKKU_PARALLEL_ARGUMENTS from documentation (unused in core). Deprecates fn-migrate-config-to-property bash function. closes #1558 --- docs/configuration/environment-variables.md | 50 ++++---- docs/deployment/application-deployment.md | 4 +- docs/deployment/zero-downtime-deploys.md | 14 +-- plugins/apps/apps.go | 2 + plugins/apps/functions.go | 6 +- plugins/apps/triggers.go | 9 ++ plugins/builder/builder.go | 12 +- plugins/builder/triggers.go | 35 ++---- plugins/checks/functions | 6 +- plugins/checks/install | 49 ++++---- plugins/checks/internal-functions | 4 +- plugins/checks/subcommands/disable | 18 +-- plugins/checks/subcommands/enable | 18 +-- plugins/checks/subcommands/skip | 18 +-- plugins/common/common.go | 31 +---- plugins/common/docker.go | 20 ++- plugins/common/functions | 6 +- plugins/common/properties.go | 115 ++++++++++++++++++ plugins/common/src/prop/prop.go | 70 +++++++++++ plugins/config/config.go | 14 ++- plugins/nginx-vhosts/install | 22 +--- plugins/ports/functions.go | 53 +++----- plugins/ports/proxy.go | 14 +++ plugins/ports/triggers.go | 81 ++++-------- plugins/proxy/Makefile | 2 +- plugins/proxy/functions.go | 6 +- plugins/proxy/proxy.go | 31 ++--- plugins/proxy/src/triggers/triggers.go | 13 ++ plugins/proxy/subcommands.go | 11 +- plugins/proxy/triggers.go | 50 ++++++++ plugins/ps/ps.go | 17 +-- plugins/ps/report.go | 13 +- plugins/ps/triggers.go | 84 +++++++------ .../bin/scheduler-deploy-process-container | 2 +- plugins/scheduler-docker-local/check-deploy | 12 +- .../scheduler-docker-local/scheduler-deploy | 2 +- .../scheduler-docker-local/scheduler-enter | 7 +- plugins/scheduler-docker-local/scheduler-run | 4 +- plugins/scheduler-k3s/functions.go | 16 +-- plugins/scheduler/scheduler.go | 2 + plugins/scheduler/triggers.go | 61 ++-------- tests/unit/apps_1.bats | 4 +- tests/unit/apps_2.bats | 6 +- tests/unit/checks.bats | 52 ++++---- tests/unit/nginx-vhosts_5.bats | 6 +- 45 files changed, 604 insertions(+), 468 deletions(-) diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index dd805bd0f..cfe98ae22 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -98,26 +98,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 ports:set proxy-port ` | +| `DOKKU_PROXY_SSL_PORT` | `dokku ports: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/zero-downtime-deploys.md b/docs/deployment/zero-downtime-deploys.md index e769b2193..5ef21b4ee 100644 --- a/docs/deployment/zero-downtime-deploys.md +++ b/docs/deployment/zero-downtime-deploys.md @@ -40,17 +40,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/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/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/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/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/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..6d55ba45b 100755 --- a/plugins/checks/internal-functions +++ b/plugins/checks/internal-functions @@ -72,7 +72,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 +80,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..21847aa7e 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 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..59ea62fc6 100755 --- a/plugins/common/functions +++ b/plugins/common/functions @@ -632,8 +632,8 @@ release_and_deploy() { 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"} @@ -917,7 +917,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 diff --git a/plugins/common/properties.go b/plugins/common/properties.go index c6c6e3b3e..7653e168b 100644 --- a/plugins/common/properties.go +++ b/plugins/common/properties.go @@ -548,6 +548,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/config.go b/plugins/config/config.go index 18d4d9403..45cbe9b97 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 diff --git a/plugins/nginx-vhosts/install b/plugins/nginx-vhosts/install index 748dec90e..636325e6d 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 ports proxy-port DOKKU_NGINX_PORT + "$PLUGIN_CORE_AVAILABLE_PATH/common/prop" migrate-config-to-property ports proxy-ssl-port DOKKU_NGINX_SSL_PORT } fn-nginx-vhosts-migrate-nginx-conf-sigil() { diff --git a/plugins/ports/functions.go b/plugins/ports/functions.go index 76aeaed3d..9698b2518 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("ports", "--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("ports", "--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("ports", 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("ports", 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("ports", 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("ports", 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/proxy.go b/plugins/ports/proxy.go index 8daf37c43..b17550010 100644 --- a/plugins/ports/proxy.go +++ b/plugins/ports/proxy.go @@ -4,6 +4,20 @@ import ( "fmt" ) +var ( + // DefaultProperties is a map of all valid ports properties with corresponding default property values + DefaultProperties = map[string]string{ + "proxy-port": "", + "proxy-ssl-port": "", + } + + // GlobalProperties is a map of all valid global ports properties + GlobalProperties = map[string]bool{ + "proxy-port": true, + "proxy-ssl-port": true, + } +) + // 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/triggers.go b/plugins/ports/triggers.go index c8c4288a0..192e8cc64 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,25 @@ 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, + }, + { + 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 @@ -185,34 +164,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("ports", 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("ports", appName, "proxy-port") + sslPort := common.PropertyGet("ports", appName, "proxy-ssl-port") portMaps := getPortMaps(appName) - toUnset := []string{} if port == "80" { - toUnset = append(toUnset, "DOKKU_PROXY_PORT") + common.PropertyDelete("ports", 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("ports", 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/proxy.go b/plugins/proxy/proxy.go index b674f72b0..9f64815f7 100644 --- a/plugins/proxy/proxy.go +++ b/plugins/proxy/proxy.go @@ -2,13 +2,25 @@ 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", + "type": "", + } + + // GlobalProperties is a map of all valid global proxy properties + GlobalProperties = map[string]bool{ + "type": true, + } +) + // BuildConfig rebuilds the proxy config for the specified app func BuildConfig(appName string) error { _, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ @@ -37,11 +49,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 +69,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 +83,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/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..b431a1284 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 @@ -99,12 +98,6 @@ func CommandSet(appName string, proxyType string) error { 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, "type", proxyType, DefaultProperties, GlobalProperties) + return nil } diff --git a/plugins/proxy/triggers.go b/plugins/proxy/triggers.go index 0dff90629..5a3eaeea8 100644 --- a/plugins/proxy/triggers.go +++ b/plugins/proxy/triggers.go @@ -2,8 +2,39 @@ 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 + }, + }, + }); 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 +53,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/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..a95dca567 100644 --- a/plugins/ps/report.go +++ b/plugins/ps/report.go @@ -132,18 +132,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/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/scheduler-docker-local/bin/scheduler-deploy-process-container b/plugins/scheduler-docker-local/bin/scheduler-deploy-process-container index 487dde936..8028b9eaf 100755 --- a/plugins/scheduler-docker-local/bin/scheduler-deploy-process-container +++ b/plugins/scheduler-docker-local/bin/scheduler-deploy-process-container @@ -122,7 +122,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..6dd794aff 100755 --- a/plugins/scheduler-docker-local/check-deploy +++ b/plugins/scheduler-docker-local/check-deploy @@ -59,21 +59,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 +117,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/scheduler-deploy b/plugins/scheduler-docker-local/scheduler-deploy index a3c246c7c..8daaa0532 100755 --- a/plugins/scheduler-docker-local/scheduler-deploy +++ b/plugins/scheduler-docker-local/scheduler-deploy @@ -35,7 +35,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 diff --git a/plugins/scheduler-docker-local/scheduler-enter b/plugins/scheduler-docker-local/scheduler-enter index e1a4e8def..61a3fa202 100755 --- a/plugins/scheduler-docker-local/scheduler-enter +++ b/plugins/scheduler-docker-local/scheduler-enter @@ -92,10 +92,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-run b/plugins/scheduler-docker-local/scheduler-run index b9b5cb9e3..27d4236e9 100755 --- a/plugins/scheduler-docker-local/scheduler-run +++ b/plugins/scheduler-docker-local/scheduler-run @@ -137,8 +137,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-k3s/functions.go b/plugins/scheduler-k3s/functions.go index 3cbbc3251..4d56fcb2d 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 } } 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/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/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..49c30f037 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 ports: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" diff --git a/tests/unit/checks.bats b/tests/unit/checks.bats index 35e0576fa..138b389c1 100644 --- a/tests/unit/checks.bats +++ b/tests/unit/checks.bats @@ -36,7 +36,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 +48,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 +80,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 +112,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 +124,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 +156,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/nginx-vhosts_5.bats b/tests/unit/nginx-vhosts_5.bats index 2a0d171e1..1c6ebc88f 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 ports: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 ports:set --global proxy-port" echo "output: $output" echo "status: $status" assert_success From 24f460f5d3ce748d1619f5db92beb1c93beffaf2 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 16:33:34 -0400 Subject: [PATCH 017/116] fix: address CI failures from env var migration - Add apps:set subcommand for managing apps properties (mirrors ps:set, scheduler:set, builder:set pattern) - Update nginx-vhosts functions/pre-disable-vhost/pre-enable-vhost to read proxy port from properties instead of config vars - Fix test commands to use correct property-setting approaches: prop binary for ports properties, apps:set for apps properties --- plugins/apps/Makefile | 2 +- plugins/apps/src/commands/commands.go | 1 + plugins/apps/src/subcommands/subcommands.go | 7 +++++++ plugins/apps/subcommands.go | 6 ++++++ plugins/nginx-vhosts/functions | 4 ++-- plugins/nginx-vhosts/pre-disable-vhost | 15 +++++---------- plugins/nginx-vhosts/pre-enable-vhost | 15 +++++---------- tests/unit/apps_2.bats | 2 +- tests/unit/nginx-vhosts_5.bats | 4 ++-- 9 files changed, 30 insertions(+), 26 deletions(-) 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/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..a61e46d45 100644 --- a/plugins/apps/src/subcommands/subcommands.go +++ b/plugins/apps/src/subcommands/subcommands.go @@ -72,6 +72,13 @@ func main() { appName := args.Arg(0) err = apps.CommandReport(appName, *format, infoFlag) } + case "set": + args := flag.NewFlagSet("apps:set", flag.ExitOnError) + args.Parse(os.Args[2:]) + appName := args.Arg(0) + property := args.Arg(1) + value := args.Arg(2) + 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/nginx-vhosts/functions b/plugins/nginx-vhosts/functions index 0f81141c4..1b15cde8e 100755 --- a/plugins/nginx-vhosts/functions +++ b/plugins/nginx-vhosts/functions @@ -227,8 +227,8 @@ 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 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 while read -r PORT_MAP; do diff --git a/plugins/nginx-vhosts/pre-disable-vhost b/plugins/nginx-vhosts/pre-disable-vhost index 4aa94cec9..027dedcd6 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 "ports" "$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 "ports" "$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..11615f4be 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" @@ -14,19 +14,14 @@ trigger-nginx-vhosts-pre-enable-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 "ports" "$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 "ports" "$APP" "proxy-ssl-port" fi # clear ports if there is a mapping starting with http:80 or https:443 diff --git a/tests/unit/apps_2.bats b/tests/unit/apps_2.bats index 49c30f037..0926fe33c 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 ports:set $TEST_APP proxy-ssl-port 443" + run /bin/bash -c "$PLUGIN_CORE_AVAILABLE_PATH/common/prop set ports $TEST_APP proxy-ssl-port 443" echo "output: $output" echo "status: $status" assert_success diff --git a/tests/unit/nginx-vhosts_5.bats b/tests/unit/nginx-vhosts_5.bats index 1c6ebc88f..9f8963950 100644 --- a/tests/unit/nginx-vhosts_5.bats +++ b/tests/unit/nginx-vhosts_5.bats @@ -62,7 +62,7 @@ teardown() { @test "(nginx-vhosts) proxy:build-config (global proxy-port)" { local GLOBAL_PORT=30999 - run /bin/bash -c "dokku ports:set --global proxy-port ${GLOBAL_PORT}" + run /bin/bash -c "$PLUGIN_CORE_AVAILABLE_PATH/common/prop set ports --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 ports:set --global proxy-port" + run /bin/bash -c "$PLUGIN_CORE_AVAILABLE_PATH/common/prop del ports --global proxy-port" echo "output: $output" echo "status: $status" assert_success From 915172c0c5ee65f77350bdb3f9faf091e0031b10 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 17:19:42 -0400 Subject: [PATCH 018/116] fix: move proxy-port properties to proxy plugin and fix CI failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move proxy-port and proxy-ssl-port properties from ports plugin to proxy plugin where they conceptually belong - Generalize proxy:set to handle any property (matching ps:set pattern) instead of only handling the type property - Fix apps:set --global flag parsing (pflag treats --global as a flag, not an argument — must be defined explicitly like ps:set does) - Add missing property-functions source to scheduler-docker-local shell scripts (scheduler-enter, scheduler-run, scheduler-deploy, check-deploy, bin/scheduler-deploy-process-container) - Update nginx-vhosts property references from ports to proxy plugin - Update tests to use proxy:set for proxy-port properties --- docs/configuration/environment-variables.md | 4 ++-- plugins/apps/src/subcommands/subcommands.go | 6 ++++++ plugins/nginx-vhosts/install | 4 ++-- plugins/nginx-vhosts/pre-disable-vhost | 4 ++-- plugins/nginx-vhosts/pre-enable-vhost | 4 ++-- plugins/ports/functions.go | 12 +++++------ plugins/ports/proxy.go | 13 ------------ plugins/ports/triggers.go | 20 +++++-------------- plugins/proxy/proxy.go | 10 +++++++--- plugins/proxy/src/subcommands/subcommands.go | 8 +++++--- plugins/proxy/subcommands.go | 20 ++----------------- plugins/proxy/triggers.go | 10 ++++++++++ .../bin/scheduler-deploy-process-container | 1 + plugins/scheduler-docker-local/check-deploy | 1 + .../scheduler-docker-local/scheduler-deploy | 1 + .../scheduler-docker-local/scheduler-enter | 1 + plugins/scheduler-docker-local/scheduler-run | 1 + tests/unit/apps_2.bats | 2 +- tests/unit/nginx-vhosts_5.bats | 4 ++-- 19 files changed, 57 insertions(+), 69 deletions(-) diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index cfe98ae22..a597ce464 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -122,8 +122,8 @@ The following environment variables have been migrated to plugin properties. Exi | `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 ports:set proxy-port ` | -| `DOKKU_PROXY_SSL_PORT` | `dokku ports:set proxy-ssl-port ` | +| `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 ` | diff --git a/plugins/apps/src/subcommands/subcommands.go b/plugins/apps/src/subcommands/subcommands.go index a61e46d45..802d0e1ed 100644 --- a/plugins/apps/src/subcommands/subcommands.go +++ b/plugins/apps/src/subcommands/subcommands.go @@ -74,10 +74,16 @@ func main() { } 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) diff --git a/plugins/nginx-vhosts/install b/plugins/nginx-vhosts/install index 636325e6d..94bb56442 100755 --- a/plugins/nginx-vhosts/install +++ b/plugins/nginx-vhosts/install @@ -7,8 +7,8 @@ source "$PLUGIN_AVAILABLE_PATH/config/functions" source "$PLUGIN_AVAILABLE_PATH/nginx-vhosts/internal-functions" fn-nginx-vhosts-migrate-env-vars() { - "$PLUGIN_CORE_AVAILABLE_PATH/common/prop" migrate-config-to-property ports proxy-port DOKKU_NGINX_PORT - "$PLUGIN_CORE_AVAILABLE_PATH/common/prop" migrate-config-to-property ports proxy-ssl-port DOKKU_NGINX_SSL_PORT + "$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/pre-disable-vhost b/plugins/nginx-vhosts/pre-disable-vhost index 027dedcd6..977524416 100755 --- a/plugins/nginx-vhosts/pre-disable-vhost +++ b/plugins/nginx-vhosts/pre-disable-vhost @@ -18,10 +18,10 @@ trigger-nginx-vhosts-pre-disable-vhost() { local PROXY_SSL_PORT=$(plugn trigger ports-get-property "$APP" "proxy-ssl-port") if [[ "$PROXY_PORT" == "80" ]]; then - fn-plugin-property-delete "ports" "$APP" "proxy-port" + fn-plugin-property-delete "proxy" "$APP" "proxy-port" fi if [[ "$PROXY_SSL_PORT" == "443" ]]; then - fn-plugin-property-delete "ports" "$APP" "proxy-ssl-port" + 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 11615f4be..224887462 100755 --- a/plugins/nginx-vhosts/pre-enable-vhost +++ b/plugins/nginx-vhosts/pre-enable-vhost @@ -18,10 +18,10 @@ trigger-nginx-vhosts-pre-enable-vhost() { local PROXY_SSL_PORT=$(plugn trigger ports-get-property "$APP" "proxy-ssl-port") if [[ "$PROXY_PORT" == "80" ]]; then - fn-plugin-property-delete "ports" "$APP" "proxy-port" + fn-plugin-property-delete "proxy" "$APP" "proxy-port" fi if [[ "$PROXY_SSL_PORT" == "443" ]]; then - fn-plugin-property-delete "ports" "$APP" "proxy-ssl-port" + 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/ports/functions.go b/plugins/ports/functions.go index 9698b2518..afeaf7120 100644 --- a/plugins/ports/functions.go +++ b/plugins/ports/functions.go @@ -161,7 +161,7 @@ func getDetectedPortMaps(appName string) []PortMap { // getGlobalProxyPort gets the global proxy port func getGlobalProxyPort() int { port := 0 - value := common.PropertyGet("ports", "--global", "proxy-port") + value := common.PropertyGet("proxy", "--global", "proxy-port") if intVar, err := strconv.Atoi(value); err == nil { port = intVar } @@ -172,7 +172,7 @@ func getGlobalProxyPort() int { // getGlobalProxySSLPort gets the global proxy ssl port func getGlobalProxySSLPort() int { port := 0 - value := common.PropertyGet("ports", "--global", "proxy-ssl-port") + value := common.PropertyGet("proxy", "--global", "proxy-ssl-port") if intVar, err := strconv.Atoi(value); err == nil { port = intVar } @@ -194,7 +194,7 @@ func getPortMaps(appName string) []PortMap { // getProxyPort gets the proxy port for an app func getProxyPort(appName string) int { port := 0 - value := common.PropertyGet("ports", appName, "proxy-port") + value := common.PropertyGet("proxy", appName, "proxy-port") if intVar, err := strconv.Atoi(value); err == nil { port = intVar } @@ -205,7 +205,7 @@ func getProxyPort(appName string) int { // getProxySSLPort gets the proxy ssl port for an app func getProxySSLPort(appName string) int { port := 0 - value := common.PropertyGet("ports", appName, "proxy-ssl-port") + value := common.PropertyGet("proxy", appName, "proxy-ssl-port") if intVar, err := strconv.Atoi(value); err == nil { port = intVar } @@ -432,12 +432,12 @@ func setPortMaps(appName string, portMaps []PortMap) error { // setProxyPort sets the proxy port for an app func setProxyPort(appName string, port int) error { - return common.PropertyWrite("ports", appName, "proxy-port", fmt.Sprint(port)) + 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.PropertyWrite("ports", appName, "proxy-ssl-port", fmt.Sprint(port)) + return common.PropertyWrite("proxy", appName, "proxy-ssl-port", fmt.Sprint(port)) } // transformPortMap normalizes a port map string for migration to list properties diff --git a/plugins/ports/proxy.go b/plugins/ports/proxy.go index b17550010..c8855b6e4 100644 --- a/plugins/ports/proxy.go +++ b/plugins/ports/proxy.go @@ -4,19 +4,6 @@ import ( "fmt" ) -var ( - // DefaultProperties is a map of all valid ports properties with corresponding default property values - DefaultProperties = map[string]string{ - "proxy-port": "", - "proxy-ssl-port": "", - } - - // GlobalProperties is a map of all valid global ports properties - GlobalProperties = map[string]bool{ - "proxy-port": true, - "proxy-ssl-port": true, - } -) // PortMap is a struct that contains a scheme:host-port:container-port mapping type PortMap struct { diff --git a/plugins/ports/triggers.go b/plugins/ports/triggers.go index 192e8cc64..8d6ce6cd7 100644 --- a/plugins/ports/triggers.go +++ b/plugins/ports/triggers.go @@ -21,16 +21,6 @@ func TriggerInstall() error { ListProperty: true, Transform: transformPortMap, }, - { - 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 } @@ -166,7 +156,7 @@ func TriggerPostAppRenameSetup(oldAppName string, newAppName string) error { // TriggerPostCertsRemove unsets port properties after SSL cert is removed func TriggerPostCertsRemove(appName string) error { - if err := common.PropertyDelete("ports", appName, "proxy-ssl-port"); err != nil { + if err := common.PropertyDelete("proxy", appName, "proxy-ssl-port"); err != nil { return err } @@ -175,15 +165,15 @@ func TriggerPostCertsRemove(appName string) error { // TriggerPostCertsUpdate sets port properties after SSL cert is added func TriggerPostCertsUpdate(appName string) error { - port := common.PropertyGet("ports", appName, "proxy-port") - sslPort := common.PropertyGet("ports", appName, "proxy-ssl-port") + port := common.PropertyGet("proxy", appName, "proxy-port") + sslPort := common.PropertyGet("proxy", appName, "proxy-ssl-port") portMaps := getPortMaps(appName) if port == "80" { - common.PropertyDelete("ports", appName, "proxy-port") + common.PropertyDelete("proxy", appName, "proxy-port") } if sslPort == "443" { - common.PropertyDelete("ports", appName, "proxy-ssl-port") + common.PropertyDelete("proxy", appName, "proxy-ssl-port") } var http80Ports []PortMap diff --git a/plugins/proxy/proxy.go b/plugins/proxy/proxy.go index 9f64815f7..aec2c924d 100644 --- a/plugins/proxy/proxy.go +++ b/plugins/proxy/proxy.go @@ -11,13 +11,17 @@ const RunInSerial = 0 var ( // DefaultProperties is a map of all valid proxy properties with corresponding default property values DefaultProperties = map[string]string{ - "disabled": "false", - "type": "", + "disabled": "false", + "proxy-port": "", + "proxy-ssl-port": "", + "type": "", } // GlobalProperties is a map of all valid global proxy properties GlobalProperties = map[string]bool{ - "type": true, + "proxy-port": true, + "proxy-ssl-port": true, + "type": true, } ) diff --git a/plugins/proxy/src/subcommands/subcommands.go b/plugins/proxy/src/subcommands/subcommands.go index ca06d2660..11498b8b0 100644 --- a/plugins/proxy/src/subcommands/subcommands.go +++ b/plugins/proxy/src/subcommands/subcommands.go @@ -59,12 +59,14 @@ func main() { 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/subcommands.go b/plugins/proxy/subcommands.go index b431a1284..e603665c1 100644 --- a/plugins/proxy/subcommands.go +++ b/plugins/proxy/subcommands.go @@ -2,7 +2,6 @@ package proxy import ( "errors" - "strings" "github.com/dokku/dokku/plugins/common" ) @@ -82,22 +81,7 @@ func CommandReport(appName string, format string, infoFlag string) error { } // 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 - } - } - - 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") - } - - common.CommandPropertySet("proxy", appName, "type", proxyType, DefaultProperties, GlobalProperties) +func CommandSet(appName string, property string, value string) error { + common.CommandPropertySet("proxy", appName, property, value, DefaultProperties, GlobalProperties) return nil } diff --git a/plugins/proxy/triggers.go b/plugins/proxy/triggers.go index 5a3eaeea8..935e63d77 100644 --- a/plugins/proxy/triggers.go +++ b/plugins/proxy/triggers.go @@ -28,6 +28,16 @@ func TriggerInstall() error { 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 } diff --git a/plugins/scheduler-docker-local/bin/scheduler-deploy-process-container b/plugins/scheduler-docker-local/bin/scheduler-deploy-process-container index 8028b9eaf..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() { diff --git a/plugins/scheduler-docker-local/check-deploy b/plugins/scheduler-docker-local/check-deploy index 6dd794aff..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" diff --git a/plugins/scheduler-docker-local/scheduler-deploy b/plugins/scheduler-docker-local/scheduler-deploy index 8daaa0532..7981c5a7f 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" diff --git a/plugins/scheduler-docker-local/scheduler-enter b/plugins/scheduler-docker-local/scheduler-enter index 61a3fa202..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 diff --git a/plugins/scheduler-docker-local/scheduler-run b/plugins/scheduler-docker-local/scheduler-run index 27d4236e9..658beff00 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" diff --git a/tests/unit/apps_2.bats b/tests/unit/apps_2.bats index 0926fe33c..5c481bde5 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 "$PLUGIN_CORE_AVAILABLE_PATH/common/prop set ports $TEST_APP 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 diff --git a/tests/unit/nginx-vhosts_5.bats b/tests/unit/nginx-vhosts_5.bats index 9f8963950..9af7c2ea0 100644 --- a/tests/unit/nginx-vhosts_5.bats +++ b/tests/unit/nginx-vhosts_5.bats @@ -62,7 +62,7 @@ teardown() { @test "(nginx-vhosts) proxy:build-config (global proxy-port)" { local GLOBAL_PORT=30999 - run /bin/bash -c "$PLUGIN_CORE_AVAILABLE_PATH/common/prop set ports --global 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 "$PLUGIN_CORE_AVAILABLE_PATH/common/prop del ports --global proxy-port" + run /bin/bash -c "dokku proxy:set --global proxy-port" echo "output: $output" echo "status: $status" assert_success From c7afc0523dca90f1c3df07e66f30591eca7ee257 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 18:07:10 -0400 Subject: [PATCH 019/116] fix: maintain backward compatibility for proxy:set command proxy:set continues to work as before by detecting when the first argument is not a known property name and treating it as the type value. Also preserves the port mapping detection error for inputs containing colons. --- plugins/proxy/subcommands.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/plugins/proxy/subcommands.go b/plugins/proxy/subcommands.go index e603665c1..997125a38 100644 --- a/plugins/proxy/subcommands.go +++ b/plugins/proxy/subcommands.go @@ -2,6 +2,7 @@ package proxy import ( "errors" + "strings" "github.com/dokku/dokku/plugins/common" ) @@ -80,8 +81,19 @@ func CommandReport(appName string, format string, infoFlag string) error { return ReportSingleApp(appName, format, infoFlag) } -// CommandSet sets a proxy for an app +// 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" + } + common.CommandPropertySet("proxy", appName, property, value, DefaultProperties, GlobalProperties) return nil } From 807ddf29a76f57767b1d0550da5f8539817f1013 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 26 Apr 2026 04:45:15 -0400 Subject: [PATCH 020/116] fix: update helm chart values for k3s scheduler - Mount `/vector-data-dir` from host (`/var/lib/vector` via `hostPath`, `DirectoryOrCreate`) so `kubernetes_logs` checkpoints survive pod restarts; resolves conflict with the chart's read-only `/var/lib` default volume - Enable Vector API on `:8686` and wire HTTP `/health` liveness and readiness probes; add `internal_metrics` source and `prometheus_exporter` sink on `:9090` so the `prom-exporter` service port is actually backed by an exporter - Set explicit `targetPort` on the `prom-exporter` port to avoid inference mismatches - Remove the `.message = .message` no-op - Add `node-role.kubernetes.io/control-plane` toleration alongside the legacy `master` key so the DaemonSet schedules on control-plane nodes regardless of cluster age --- .../templates/helm-config/vector.yaml | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/plugins/scheduler-k3s/templates/helm-config/vector.yaml b/plugins/scheduler-k3s/templates/helm-config/vector.yaml index e20ccf829..956b55ce5 100644 --- a/plugins/scheduler-k3s/templates/helm-config/vector.yaml +++ b/plugins/scheduler-k3s/templates/helm-config/vector.yaml @@ -5,30 +5,61 @@ 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 + +extraVolumes: + - name: vector-data-dir + hostPath: + path: /var/lib/vector + type: DirectoryOrCreate + +extraVolumeMounts: + - name: vector-data-dir + mountPath: /vector-data-dir + readOnly: false 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 .namespace = .kubernetes.pod_namespace @@ -40,3 +71,9 @@ customConfig: - kubernetes_container_logs encoding: codec: json + + prom_exporter: + type: prometheus_exporter + inputs: + - internal_metrics + address: 0.0.0.0:9090 From 4af68e75c501e9d6c277cd5a4958dcfd9b37539c Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 26 Apr 2026 04:46:05 -0400 Subject: [PATCH 021/116] chore: upgrade the vector helm chart to 0.52.0 --- plugins/scheduler-k3s/scheduler_k3s.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scheduler-k3s/scheduler_k3s.go b/plugins/scheduler-k3s/scheduler_k3s.go index c97b3d4c2..fa1077220 100644 --- a/plugins/scheduler-k3s/scheduler_k3s.go +++ b/plugins/scheduler-k3s/scheduler_k3s.go @@ -140,7 +140,7 @@ var HelmCharts = []HelmChart{ Namespace: "vector", ReleaseName: "vector", RepoURL: "https://helm.vector.dev", - Version: "0.50.0", + Version: "0.52.0", }, } From 973f6ffb2cc046525cca95ee19ee7b8fbfdbdf4f Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 26 Apr 2026 04:52:05 -0400 Subject: [PATCH 022/116] chore: upgrade to 4.15.1 --- plugins/scheduler-k3s/scheduler_k3s.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scheduler-k3s/scheduler_k3s.go b/plugins/scheduler-k3s/scheduler_k3s.go index 2d7cd5df8..867459920 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.14.4", + Version: "4.15.1", }, { ChartPath: "keda", From ed2fbf3d446717cc92b1948416be8cf81313f944 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 26 Apr 2026 08:00:12 -0400 Subject: [PATCH 023/116] chore: bump dependency versions and add daily updater workflow Bumps every entry in contrib/dependencies.json to its latest upstream release. Adds a scheduled GitHub Actions workflow that checks each dependency once a day and opens one PR per outdated entry. --- .github/workflows/dependency-updates.yml | 108 +++++++++++++++++++++++ contrib/dependencies.json | 66 +++++++------- 2 files changed, 141 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/dependency-updates.yml diff --git a/.github/workflows/dependency-updates.yml b/.github/workflows/dependency-updates.yml new file mode 100644 index 000000000..74f628513 --- /dev/null +++ b/.github/workflows/dependency-updates.yml @@ -0,0 +1,108 @@ +--- +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.GITHUB_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.GITHUB_TOKEN }} + branch: chore/bump-${{ matrix.name }}-${{ steps.latest.outputs.latest }} + base: ${{ github.ref_name }} + delete-branch: true + 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/contrib/dependencies.json b/contrib/dependencies.json index 7152bc043..040b4f01b 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.10.3", "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.10.3/netrc-linux-amd64", + "arm64": "https://github.com/dokku/netrc/releases/download/v0.10.3/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" } } ] From ef430e815b7d3b4a7955e876ebfb6efb443575d8 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 26 Apr 2026 08:06:47 -0400 Subject: [PATCH 024/116] chore: use Dokku Bot identity for dependency update PRs Switches the dependency-updates workflow to use HOMEBREW_GITHUB_API_TOKEN (the same PAT used to bump the homebrew formula on release) and sets the committer/author to Dokku Bot . --- .github/workflows/dependency-updates.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dependency-updates.yml b/.github/workflows/dependency-updates.yml index 74f628513..9573646e7 100644 --- a/.github/workflows/dependency-updates.yml +++ b/.github/workflows/dependency-updates.yml @@ -53,7 +53,7 @@ jobs: - name: Resolve latest release id: latest env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} REPO: ${{ matrix.repo }} CURRENT: ${{ matrix.version }} run: | @@ -95,10 +95,12 @@ jobs: if: steps.latest.outputs.outdated == 'true' uses: peter-evans/create-pull-request@v7 with: - token: ${{ secrets.GITHUB_TOKEN }} + 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: | From f5cbc51fec7e7ff33d8e7557e973d971eaa78841 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 26 Apr 2026 08:53:27 -0400 Subject: [PATCH 025/116] fix(tests): drop DOKKU_PROXY_PORT from config:show and config:export expectations now that it lives as a property rather than a config var --- tests/unit/config.bats | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/unit/config.bats b/tests/unit/config.bats index af758b96a..e9bfb1fce 100644 --- a/tests/unit/config.bats +++ b/tests/unit/config.bats @@ -506,7 +506,7 @@ teardown() { run /bin/bash -c "dokku --app $TEST_APP config:show" echo "output: $output" echo "status: $status" - assert_output "=====> $TEST_APP env vars"$'\nBKEY: true\nDOKKU_PROXY_PORT: 80\naKey: true\nbKey: true\nzKey: true' + assert_output "=====> $TEST_APP env vars"$'\nBKEY: true\naKey: true\nbKey: true\nzKey: true' } @test "(config) config:export" { @@ -518,20 +518,20 @@ teardown() { run /bin/bash -c "dokku config:export --format docker-args $TEST_APP" echo "output: $output" echo "status: $status" - assert_output "--env=BKEY='true' --env=DOKKU_PROXY_PORT='80' --env=aKey='true' --env=bKey='true' --env=zKey='true'" + assert_output "--env=BKEY='true' --env=aKey='true' --env=bKey='true' --env=zKey='true'" run /bin/bash -c "dokku config:export --format shell $TEST_APP" echo "output: $output" echo "status: $status" - assert_output "BKEY='true' DOKKU_PROXY_PORT='80' aKey='true' bKey='true' zKey='true' " + assert_output "BKEY='true' aKey='true' bKey='true' zKey='true' " run /bin/bash -c "dokku config:export --format json $TEST_APP" echo "output: $output" echo "status: $status" - assert_output '{"BKEY":"true","DOKKU_PROXY_PORT":"80","aKey":"true","bKey":"true","zKey":"true"}' + assert_output '{"BKEY":"true","aKey":"true","bKey":"true","zKey":"true"}' run /bin/bash -c "dokku config:export --format json-list $TEST_APP" echo "output: $output" echo "status: $status" - assert_output '[{"name":"BKEY","value":"true"},{"name":"DOKKU_PROXY_PORT","value":"80"},{"name":"aKey","value":"true"},{"name":"bKey","value":"true"},{"name":"zKey","value":"true"}]' + assert_output '[{"name":"BKEY","value":"true"},{"name":"aKey","value":"true"},{"name":"bKey","value":"true"},{"name":"zKey","value":"true"}]' } From cef1e7aff5a0053559d0ad8215253bbb631e250c Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 26 Apr 2026 05:10:47 -0400 Subject: [PATCH 026/116] chore: bump go version to 1.26.2 and run go mod tidy --- common.mk | 2 +- go.work | 2 +- plugins/app-json/go.mod | 2 +- plugins/apps/go.mod | 2 +- plugins/builder/go.mod | 2 +- plugins/buildpacks/go.mod | 4 ++-- plugins/buildpacks/go.sum | 15 +++++++++++++++ plugins/common/go.mod | 2 +- plugins/config/go.mod | 2 +- plugins/cron/go.mod | 2 +- plugins/docker-options/go.mod | 2 +- plugins/logs/go.mod | 2 +- plugins/network/go.mod | 2 +- plugins/nginx-vhosts/go.mod | 2 +- plugins/ports/go.mod | 4 +--- plugins/ports/go.sum | 2 -- plugins/proxy/go.mod | 4 +--- plugins/proxy/go.sum | 2 -- plugins/ps/go.mod | 4 +--- plugins/ps/go.sum | 2 -- plugins/registry/go.mod | 2 +- plugins/repo/go.mod | 2 +- plugins/resource/go.mod | 2 +- plugins/scheduler-docker-local/go.mod | 2 +- plugins/scheduler-k3s/go.mod | 2 +- plugins/scheduler/go.mod | 2 +- plugins/storage/go.mod | 2 +- tests/apps/gogrpc/go.mod | 2 +- tests/apps/zombies-buildpack/go.mod | 2 +- tests/apps/zombies-dockerfile-no-tini/go.mod | 2 +- tests/apps/zombies-dockerfile-tini/go.mod | 2 +- 31 files changed, 43 insertions(+), 40 deletions(-) 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/go.work b/go.work index 42c43feaf..814223fc1 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.25.5 +go 1.26.2 use ( ./plugins/app-json diff --git a/plugins/app-json/go.mod b/plugins/app-json/go.mod index 19649d0f3..a201928dc 100644 --- a/plugins/app-json/go.mod +++ b/plugins/app-json/go.mod @@ -1,6 +1,6 @@ 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 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/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/buildpacks/go.mod b/plugins/buildpacks/go.mod index 5afa00299..009d0db77 100644 --- a/plugins/buildpacks/go.mod +++ b/plugins/buildpacks/go.mod @@ -1,10 +1,11 @@ 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.39.1 github.com/spf13/pflag v1.0.10 ) @@ -19,7 +20,6 @@ require ( 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/onsi/gomega v1.39.1 // 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 diff --git a/plugins/buildpacks/go.sum b/plugins/buildpacks/go.sum index 957619840..10b277826 100644 --- a/plugins/buildpacks/go.sum +++ b/plugins/buildpacks/go.sum @@ -1,11 +1,19 @@ +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= @@ -20,6 +28,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/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/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8= @@ -44,6 +54,8 @@ 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= @@ -55,6 +67,9 @@ 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/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/go.mod b/plugins/common/go.mod index a419d124b..d7e100582 100644 --- a/plugins/common/go.mod +++ b/plugins/common/go.mod @@ -1,6 +1,6 @@ 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 diff --git a/plugins/config/go.mod b/plugins/config/go.mod index 6a88197d9..9053863d4 100644 --- a/plugins/config/go.mod +++ b/plugins/config/go.mod @@ -1,6 +1,6 @@ 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 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/docker-options/go.mod b/plugins/docker-options/go.mod index 5505a65ae..cc765fc5b 100644 --- a/plugins/docker-options/go.mod +++ b/plugins/docker-options/go.mod @@ -1,6 +1,6 @@ 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 diff --git a/plugins/logs/go.mod b/plugins/logs/go.mod index a3257e947..7c1145d99 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 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/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/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..c97fc9665 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= 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..c97fc9665 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= diff --git a/plugins/ps/go.mod b/plugins/ps/go.mod index 10721049b..6b80746be 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 diff --git a/plugins/ps/go.sum b/plugins/ps/go.sum index d16651988..61cae65f1 100644 --- a/plugins/ps/go.sum +++ b/plugins/ps/go.sum @@ -12,8 +12,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= 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/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/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/scheduler-docker-local/go.mod b/plugins/scheduler-docker-local/go.mod index 5a9cf3138..8db9ad568 100644 --- a/plugins/scheduler-docker-local/go.mod +++ b/plugins/scheduler-docker-local/go.mod @@ -1,6 +1,6 @@ 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 diff --git a/plugins/scheduler-k3s/go.mod b/plugins/scheduler-k3s/go.mod index 036b38a77..6dd3e265d 100644 --- a/plugins/scheduler-k3s/go.mod +++ b/plugins/scheduler-k3s/go.mod @@ -1,6 +1,6 @@ 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 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/storage/go.mod b/plugins/storage/go.mod index 8aa062bb0..63846b1ad 100644 --- a/plugins/storage/go.mod +++ b/plugins/storage/go.mod @@ -1,6 +1,6 @@ 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 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/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 From 76f3c3ec127a8a51a95f087c66a032b1afc1b109 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:54:26 +0000 Subject: [PATCH 027/116] chore(deps): bump k8s.io/apimachinery in /plugins/scheduler-k3s Bumps [k8s.io/apimachinery](https://github.com/kubernetes/apimachinery) from 0.35.4 to 0.36.0. - [Commits](https://github.com/kubernetes/apimachinery/compare/v0.35.4...v0.36.0) --- updated-dependencies: - dependency-name: k8s.io/apimachinery dependency-version: 0.36.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- plugins/scheduler-k3s/go.mod | 8 ++++---- plugins/scheduler-k3s/go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/plugins/scheduler-k3s/go.mod b/plugins/scheduler-k3s/go.mod index 6dd3e265d..8271ef412 100644 --- a/plugins/scheduler-k3s/go.mod +++ b/plugins/scheduler-k3s/go.mod @@ -28,7 +28,7 @@ require ( 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 @@ -116,7 +116,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 @@ -157,7 +156,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 +164,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 diff --git a/plugins/scheduler-k3s/go.sum b/plugins/scheduler-k3s/go.sum index 4c79608a2..d1e3cbeba 100644 --- a/plugins/scheduler-k3s/go.sum +++ b/plugins/scheduler-k3s/go.sum @@ -249,8 +249,6 @@ 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= @@ -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/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= From ef51f1f942286309bc661c5bd0b59e8bd6167971 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 05:11:24 +0000 Subject: [PATCH 028/116] chore(deps): bump k8s.io/kubernetes in /plugins/scheduler-k3s Bumps [k8s.io/kubernetes](https://github.com/kubernetes/kubernetes) from 1.35.4 to 1.36.0. - [Release notes](https://github.com/kubernetes/kubernetes/releases) - [Commits](https://github.com/kubernetes/kubernetes/compare/v1.35.4...v1.36.0) --- updated-dependencies: - dependency-name: k8s.io/kubernetes dependency-version: 1.36.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- plugins/scheduler-k3s/go.mod | 6 +++--- plugins/scheduler-k3s/go.sum | 40 ++++++++++++++++++------------------ 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/plugins/scheduler-k3s/go.mod b/plugins/scheduler-k3s/go.mod index 8271ef412..f049003f4 100644 --- a/plugins/scheduler-k3s/go.mod +++ b/plugins/scheduler-k3s/go.mod @@ -31,7 +31,7 @@ require ( 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 @@ -127,8 +127,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 diff --git a/plugins/scheduler-k3s/go.sum b/plugins/scheduler-k3s/go.sum index d1e3cbeba..6a1f2d265 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= @@ -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= @@ -250,8 +250,8 @@ github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a 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/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/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.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -281,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= @@ -358,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= @@ -384,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= @@ -463,8 +463,8 @@ k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hk 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= From 34c336c6685eed6de7d3f0e70ce1973b0acf66c6 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 7 Jan 2026 03:21:04 -0500 Subject: [PATCH 029/116] feat: add post-create support for env key in app.json Closes #4498 --- docs/appendices/file-formats/app-json.md | 97 ++++++++++ docs/configuration/environment-variables.md | 29 +++ docs/development/plugin-triggers.md | 15 ++ plugins/app-json/appjson.go | 42 ++++ plugins/app-json/functions.go | 201 ++++++++++++++++++++ plugins/app-json/triggers.go | 11 ++ plugins/builder-herokuish/builder-build | 6 +- plugins/config/Makefile | 2 +- plugins/config/functions | 11 ++ plugins/config/src/triggers/triggers.go | 5 + plugins/config/triggers.go | 5 + tests/apps/python/app-env-required.json | 8 + tests/apps/python/app-env.json | 27 +++ tests/unit/app-json-5.bats | 198 +++++++++++++++++++ 14 files changed, 653 insertions(+), 4 deletions(-) create mode 100644 tests/apps/python/app-env-required.json create mode 100644 tests/apps/python/app-env.json create mode 100644 tests/unit/app-json-5.bats diff --git a/docs/appendices/file-formats/app-json.md b/docs/appendices/file-formats/app-json.md index 6532c93b2..6a0cf767b 100644 --- a/docs/appendices/file-formats/app-json.md +++ b/docs/appendices/file-formats/app-json.md @@ -44,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/configuration/environment-variables.md b/docs/configuration/environment-variables.md index a597ce464..9c29dcc38 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -71,6 +71,35 @@ dokku config:export --format shell node-js-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. diff --git a/docs/development/plugin-triggers.md b/docs/development/plugin-triggers.md index 35e809137..89c50e510 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. diff --git a/plugins/app-json/appjson.go b/plugins/app-json/appjson.go index ddb045a11..2aff5c217 100644 --- a/plugins/app-json/appjson.go +++ b/plugins/app-json/appjson.go @@ -24,6 +24,45 @@ 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 @@ -32,6 +71,9 @@ type AppJSON struct { // 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"` 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/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/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/config/Makefile b/plugins/config/Makefile index 1f1be8b3c..d4cb33428 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/config-set triggers/config-unset triggers/post-app-clone-setup triggers/post-app-rename-setup BUILD = commands config_sub subcommands triggers PLUGIN_NAME = config 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/src/triggers/triggers.go b/plugins/config/src/triggers/triggers.go index 96e95d455..4b0783a0a 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) diff --git a/plugins/config/triggers.go b/plugins/config/triggers.go index b0c3a57f3..72bbc6719 100644 --- a/plugins/config/triggers.go +++ b/plugins/config/triggers.go @@ -45,6 +45,11 @@ 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) 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/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" +} From 3ba51725be00715492cec5f061896a2e553889e3 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 26 Apr 2026 04:32:10 -0400 Subject: [PATCH 030/116] fix(tests): adapt app.json fixtures and tini test for env processing Pre-set LOCAL_DOMAIN in the tini test so mastodon's required env var resolves, and replace unsupported `generator: "echo 5"` with `value: "5"` in the nodejs-express and dockerfile fixtures so WEB_CONCURRENCY no longer aborts deploys via the new app.json env processor. --- tests/apps/dockerfile/app.json | 2 +- tests/apps/nodejs-express/app.json | 2 +- tests/unit/app-json.bats | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/apps/dockerfile/app.json b/tests/apps/dockerfile/app.json index 39127109e..e1b65e4e0 100644 --- a/tests/apps/dockerfile/app.json +++ b/tests/apps/dockerfile/app.json @@ -11,7 +11,7 @@ }, "WEB_CONCURRENCY": { "description": "The number of processes to run.", - "generator": "echo 5" + "value": "5" } }, "healthchecks": { 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/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 From 65bdbefbea22ae3fd3787964aaec3dbd7d91fa08 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 26 Apr 2026 23:10:30 -0400 Subject: [PATCH 031/116] feat: add git:auth-status to check netrc match Adds `git:auth-status HOST [USERNAME] [PASSWORD]` which exits 0 when the configured `.netrc` entry matches the requested state and 1 otherwise, allowing external tooling to detect whether `git:auth` would change anything without reading `$DOKKU_ROOT/.netrc` directly. Both `git:auth` and `git:auth-status` now also accept the password via `STDIN`. --- docs/deployment/methods/git.md | 29 +++++++++++++ plugins/git/help-functions | 1 + plugins/git/internal-functions | 38 +++++++++++++++++ plugins/git/subcommands/auth-status | 6 +++ tests/unit/git_3.bats | 65 +++++++++++++++++++++++++++++ 5 files changed, 139 insertions(+) create mode 100755 plugins/git/subcommands/auth-status diff --git a/docs/deployment/methods/git.md b/docs/deployment/methods/git.md index b27371a07..8106a7445 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 @@ -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. 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..45579882b 100755 --- a/plugins/git/internal-functions +++ b/plugins/git/internal-functions @@ -76,6 +76,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 +93,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" 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/tests/unit/git_3.bats b/tests/unit/git_3.bats index 8e7119d41..f7563f8a5 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 "grep piped-username /home/dokku/.netrc" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "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]" { From 66c4b3b30eada90644a22a3e26582eca0d609d21 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 26 Apr 2026 23:30:22 -0400 Subject: [PATCH 032/116] feat: add --format json to git:report and nginx:report Mirrors the JSON output convention already used by scheduler:report, builder:report, network:report, and traefik:report. Both plugins now accept `--format json` to emit a single-line JSON object whose keys are the property names with the plugin prefix stripped. Combining `--format` with an info flag is rejected with an error message that matches the Go-based report helper. Closes #8499 --- docs/deployment/methods/git.md | 41 +++++++++++++++++++++++++- docs/networking/proxies/nginx.md | 30 ++++++++++++++++++- plugins/git/internal-functions | 40 ++++++++++++++++++++++--- plugins/nginx-vhosts/command-functions | 40 ++++++++++++++++++++++--- tests/unit/git_3.bats | 35 ++++++++++++++++++++++ tests/unit/nginx-vhosts_1.bats | 34 +++++++++++++++++++++ 6 files changed, 210 insertions(+), 10 deletions(-) diff --git a/docs/deployment/methods/git.md b/docs/deployment/methods/git.md index b27371a07..5093f9272 100644 --- a/docs/deployment/methods/git.md +++ b/docs/deployment/methods/git.md @@ -13,7 +13,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 ``` @@ -241,3 +241,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/networking/proxies/nginx.md b/docs/networking/proxies/nginx.md index 9e6c63d56..9dec59cfb 100644 --- a/docs/networking/proxies/nginx.md +++ b/docs/networking/proxies/nginx.md @@ -5,7 +5,7 @@ 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: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 @@ -128,6 +128,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: diff --git a/plugins/git/internal-functions b/plugins/git/internal-functions index 18c2f651b..ec8ac2b4c 100755 --- a/plugins/git/internal-functions +++ b/plugins/git/internal-functions @@ -278,7 +278,23 @@ 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" + local FORMAT="stdout" + local args=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --format) + FORMAT="$2" + shift 2 + ;; + *) + args+=("$1") + shift + ;; + esac + done + + declare APP="${args[0]:-}" INFO_FLAG="${args[1]:-}" if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then INFO_FLAG="$APP" @@ -291,15 +307,15 @@ cmd-git-report() { if [[ -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" "$FORMAT" | tee || true done else - cmd-git-report-single "$APP" "$INFO_FLAG" + cmd-git-report-single "$APP" "$INFO_FLAG" "$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="" @@ -315,6 +331,22 @@ cmd-git-report-single() { "--git-last-updated-at: $(fn-git-last-updated-at "$APP")" ) + if [[ "$FORMAT" != "stdout" ]] && [[ -n "$INFO_FLAG" ]]; then + dokku_log_fail "--format flag cannot be specified when specifying an info flag" + fi + + if [[ "$FORMAT" == "json" ]]; then + local json_output="{}" + for flag in "${flag_map[@]}"; do + local key value + key="$(echo "$flag" | cut -d':' -f1 | sed 's/^--git-//')" + 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" + return + fi + if [[ -z "$INFO_FLAG" ]]; then dokku_log_info2_quiet "${APP} git information" for flag in "${flag_map[@]}"; do diff --git a/plugins/nginx-vhosts/command-functions b/plugins/nginx-vhosts/command-functions index 84490ec8a..a2de9beaf 100755 --- a/plugins/nginx-vhosts/command-functions +++ b/plugins/nginx-vhosts/command-functions @@ -10,7 +10,23 @@ 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" + local FORMAT="stdout" + local args=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --format) + FORMAT="$2" + shift 2 + ;; + *) + args+=("$1") + shift + ;; + esac + done + + declare APP="${args[0]:-}" INFO_FLAG="${args[1]:-}" if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then INFO_FLAG="$APP" @@ -23,15 +39,15 @@ cmd-nginx-report() { if [[ -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" "$FORMAT" | tee || true done else - cmd-nginx-report-single "$APP" "$INFO_FLAG" + cmd-nginx-report-single "$APP" "$INFO_FLAG" "$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 @@ -130,6 +146,22 @@ cmd-nginx-report-single() { "--nginx-global-x-forwarded-ssl: $(fn-nginx-global-x-forwarded-ssl "$APP")" ) + if [[ "$FORMAT" != "stdout" ]] && [[ -n "$INFO_FLAG" ]]; then + dokku_log_fail "--format flag cannot be specified when specifying an info flag" + fi + + if [[ "$FORMAT" == "json" ]]; then + local json_output="{}" + for flag in "${flag_map[@]}"; do + local key value + key="$(echo "$flag" | cut -d':' -f1 | sed 's/^--nginx-//')" + 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" + return + fi + if [[ -z "$INFO_FLAG" ]]; then dokku_log_info2_quiet "${APP} nginx information" for flag in "${flag_map[@]}"; do diff --git a/tests/unit/git_3.bats b/tests/unit/git_3.bats index 8e7119d41..28bb789b0 100644 --- a/tests/unit/git_3.bats +++ b/tests/unit/git_3.bats @@ -731,3 +731,38 @@ 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" +} diff --git a/tests/unit/nginx-vhosts_1.bats b/tests/unit/nginx-vhosts_1.bats index 265a84202..cd038bef8 100644 --- a/tests/unit/nginx-vhosts_1.bats +++ b/tests/unit/nginx-vhosts_1.bats @@ -28,6 +28,40 @@ 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-vhosts) proxy:build-config (domains:disable/enable)" { run deploy_app echo "output: $output" From 769d029b0d1f327ba3140619b3e9f8bc01b5f8f2 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 00:08:05 -0400 Subject: [PATCH 033/116] fix: verify piped git:auth password via netrc get --- tests/unit/git_3.bats | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/git_3.bats b/tests/unit/git_3.bats index f7563f8a5..16e215e78 100644 --- a/tests/unit/git_3.bats +++ b/tests/unit/git_3.bats @@ -100,11 +100,11 @@ teardown() { assert_success assert_output_contains "Setting netrc auth entry for host github.com" - run /bin/bash -c "grep piped-username /home/dokku/.netrc" + run /bin/bash -c "netrc get github.com" echo "output: $output" echo "status: $status" assert_success - assert_output_contains "piped-password" + assert_output_contains "piped-username:piped-password" } @test "(git) git:auth-status" { From b8accfd463ac43e8c0f56c9a55c3ff2423a72513 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 14 Mar 2024 02:46:16 -0400 Subject: [PATCH 034/116] docs: remove references to ENV file --- docs/configuration/environment-variables.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index 9c29dcc38..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,7 +68,7 @@ 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 From 38c6b180af986794dc3b6c72e6b148288781d09d Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 14 Mar 2024 02:52:16 -0400 Subject: [PATCH 035/116] fix: reference env from config dir --- plugins/common/common_test.go | 4 ++-- plugins/config/config_test.go | 7 ++++--- plugins/config/environment.go | 4 ++-- tests/unit/config-oddities.bats | 10 +++++----- tests/unit/config.bats | 10 +++++----- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/plugins/common/common_test.go b/plugins/common/common_test.go index e6f0faee5..d6acadf66 100644 --- a/plugins/common/common_test.go +++ b/plugins/common/common_test.go @@ -10,11 +10,11 @@ import ( var ( testAppName = "test-app-1" - testAppDir = strings.Join([]string{"/home/dokku/", testAppName}, "") + testAppDir = strings.Join([]string{"/var/lib/dokku/config/config/", testAppName}, "") testEnvFile = strings.Join([]string{testAppDir, "/ENV"}, "") testEnvLine = "export testKey=TESTING" testAppName2 = "01-test-app-1" - testAppDir2 = strings.Join([]string{"/home/dokku/", testAppName2}, "") + testAppDir2 = strings.Join([]string{"/var/lib/dokku/config/config/", testAppName2}, "") testEnvFile2 = strings.Join([]string{testAppDir2, "/ENV"}, "") testEnvLine2 = "export testKey=TESTING" ) diff --git a/plugins/config/config_test.go b/plugins/config/config_test.go index 9eb0d0358..02df8d9bc 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" @@ -12,9 +13,9 @@ 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") + testAppDir = filepath.Join(dokkuLibRoot, "config", testAppName) + globalConfigFile = filepath.Join(dokkuLibRoot, "config", "--global", "ENV") ) func setupTests() (err error) { diff --git a/plugins/config/environment.go b/plugins/config/environment.go index 6e341dd62..97a7fe8c6 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_ROOT"), "config", "--global", "ENV") } 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 } From f2f6e0b473fee711b9acfa025421f9e365cd3a4b Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 14 Mar 2024 03:05:46 -0400 Subject: [PATCH 036/116] refactor: move the env files to the new path on upgrade Refs #1558 --- docs/appendices/0.37-migration-guide.md | 5 ++ plugins/common/properties.go | 4 ++ plugins/config/.gitignore | 1 + plugins/config/Makefile | 2 +- plugins/config/src/triggers/triggers.go | 8 +++ plugins/config/triggers.go | 73 +++++++++++++++++++++++++ 6 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 docs/appendices/0.37-migration-guide.md diff --git a/docs/appendices/0.37-migration-guide.md b/docs/appendices/0.37-migration-guide.md new file mode 100644 index 000000000..239e549e0 --- /dev/null +++ b/docs/appendices/0.37-migration-guide.md @@ -0,0 +1,5 @@ +# 0.37.0 Migration Guide + +## Changes + +- The path on disk to both the global `ENV` file and the `ENV` file for a given app has 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. diff --git a/plugins/common/properties.go b/plugins/common/properties.go index 7653e168b..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()) } 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 d4cb33428..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-set 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/src/triggers/triggers.go b/plugins/config/src/triggers/triggers.go index 4b0783a0a..dd5f1da77 100644 --- a/plugins/config/src/triggers/triggers.go +++ b/plugins/config/src/triggers/triggers.go @@ -51,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) @@ -59,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 72bbc6719..11e962a04 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 @@ -56,6 +60,65 @@ func TriggerConfigUnset(appName string, key string, restart bool) 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()) + } + + apps, err := common.UnfilteredDokkuApps() + if err != nil { + return nil + } + + // migrate all created-at values from app mod-time to property + for _, appName := range apps { + 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 + } + + if err := common.PropertySetupApp("config", appName); err != nil { + return fmt.Errorf("Unable to setup app environment: %s", err.Error()) + } + + // 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) @@ -95,3 +158,13 @@ func TriggerPostAppRenameSetup(oldAppName string, newAppName string) error { return nil } + +// TriggerPostCreate ensures apps have the correct config structure +func TriggerPostCreate(appName string) error { + return common.PropertySetupApp("config", appName) +} + +// TriggerPostDelete destroys the config data for a given app container +func TriggerPostDelete(appName string) error { + return common.PropertyDestroy("config", appName) +} From 81e6cdf5a5122b99e4a5541b0818f767e76bd6c0 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 15 Nov 2025 16:42:32 -0500 Subject: [PATCH 037/116] fix: ensure config properties for an app are setup prior to usage --- plugins/config/triggers.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/config/triggers.go b/plugins/config/triggers.go index 11e962a04..35c1cc920 100644 --- a/plugins/config/triggers.go +++ b/plugins/config/triggers.go @@ -71,8 +71,12 @@ func TriggerInstall() error { return nil } - // migrate all created-at values from app mod-time to property + // 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()) + } + oldEnvFile := filepath.Join(common.AppRoot(appName) + "ENV") isMigrated := common.PropertyGetDefault("config", appName, "env-migrated", "") // delete the old file on the next install @@ -91,10 +95,6 @@ func TriggerInstall() error { continue } - if err := common.PropertySetupApp("config", appName); err != nil { - return fmt.Errorf("Unable to setup app environment: %s", err.Error()) - } - // merge in the old env into the new env oldEnv, err := loadFromFile(appName, oldEnvFile) if err != nil { From 30e41239492f08d47338981b3e188cdec11691a7 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 15 Nov 2025 17:19:03 -0500 Subject: [PATCH 038/116] fix: migrate global env vars --- plugins/config/triggers.go | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/plugins/config/triggers.go b/plugins/config/triggers.go index 35c1cc920..8fd1b6b9e 100644 --- a/plugins/config/triggers.go +++ b/plugins/config/triggers.go @@ -60,6 +60,39 @@ func TriggerConfigUnset(appName string, key string, restart bool) error { return nil } +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.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 { @@ -71,6 +104,10 @@ func TriggerInstall() error { return nil } + if err := migrateGlobalEnv(); err != nil { + return fmt.Errorf("Unable to migrate global environment: %s", err.Error()) + } + // migrate all app ENV files to config path for _, appName := range apps { if err := common.PropertySetupApp("config", appName); err != nil { From bc4d3a76b9247b94f05e1b4f9f3e3784886df71b Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 16 Nov 2025 18:48:51 -0500 Subject: [PATCH 039/116] Update docs/appendices/0.37-migration-guide.md Co-authored-by: othercorey --- docs/appendices/0.37-migration-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/appendices/0.37-migration-guide.md b/docs/appendices/0.37-migration-guide.md index 239e549e0..a0b70b2ba 100644 --- a/docs/appendices/0.37-migration-guide.md +++ b/docs/appendices/0.37-migration-guide.md @@ -2,4 +2,4 @@ ## Changes -- The path on disk to both the global `ENV` file and the `ENV` file for a given app has 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. +- 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. From d846324fd5213d21cc4f485ea8616d2edba4062b Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 02:39:02 -0400 Subject: [PATCH 040/116] fix: correct global env path, migration join, and test app dirs - Use DOKKU_LIB_ROOT instead of DOKKU_ROOT in getGlobalFile() to match the intended new config path layout - Add missing comma in filepath.Join for old app ENV migration path - Revert common_test.go app dirs to DOKKU_ROOT since app detection still uses AppRoot() --- plugins/common/common_test.go | 4 ++-- plugins/config/environment.go | 2 +- plugins/config/triggers.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/common/common_test.go b/plugins/common/common_test.go index d6acadf66..e6f0faee5 100644 --- a/plugins/common/common_test.go +++ b/plugins/common/common_test.go @@ -10,11 +10,11 @@ import ( var ( testAppName = "test-app-1" - testAppDir = strings.Join([]string{"/var/lib/dokku/config/config/", testAppName}, "") + testAppDir = strings.Join([]string{"/home/dokku/", testAppName}, "") testEnvFile = strings.Join([]string{testAppDir, "/ENV"}, "") testEnvLine = "export testKey=TESTING" testAppName2 = "01-test-app-1" - testAppDir2 = strings.Join([]string{"/var/lib/dokku/config/config/", testAppName2}, "") + testAppDir2 = strings.Join([]string{"/home/dokku/", testAppName2}, "") testEnvFile2 = strings.Join([]string{testAppDir2, "/ENV"}, "") testEnvLine2 = "export testKey=TESTING" ) diff --git a/plugins/config/environment.go b/plugins/config/environment.go index 97a7fe8c6..3e9d5313d 100644 --- a/plugins/config/environment.go +++ b/plugins/config/environment.go @@ -396,5 +396,5 @@ func getAppFile(appName string) (string, error) { } func getGlobalFile() string { - return filepath.Join(common.MustGetEnv("DOKKU_ROOT"), "config", "--global", "ENV") + return filepath.Join(common.MustGetEnv("DOKKU_LIB_ROOT"), "config", "--global", "ENV") } diff --git a/plugins/config/triggers.go b/plugins/config/triggers.go index 8fd1b6b9e..23b19c55d 100644 --- a/plugins/config/triggers.go +++ b/plugins/config/triggers.go @@ -114,7 +114,7 @@ func TriggerInstall() error { return fmt.Errorf("Unable to setup app environment: %s", err.Error()) } - oldEnvFile := filepath.Join(common.AppRoot(appName) + "ENV") + 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" { From 17c549ee63bfd8164cd532a989f0bb95214a6dc3 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 03:26:42 -0400 Subject: [PATCH 041/116] fix: run global env migration before listing apps and set DOKKU_LIB_ROOT in tests - Move migrateGlobalEnv() before UnfilteredDokkuApps() so the global config directory is created even on fresh installs with no apps - Add DOKKU_LIB_ROOT to Go test env vars in tests.mk --- plugins/config/triggers.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/config/triggers.go b/plugins/config/triggers.go index 23b19c55d..d82a0d987 100644 --- a/plugins/config/triggers.go +++ b/plugins/config/triggers.go @@ -99,15 +99,15 @@ func TriggerInstall() error { 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 } - if err := migrateGlobalEnv(); err != nil { - return fmt.Errorf("Unable to migrate global environment: %s", err.Error()) - } - // migrate all app ENV files to config path for _, appName := range apps { if err := common.PropertySetupApp("config", appName); err != nil { From c3db713c273cc6cb771eca28e7303c415692ee3c Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 03:59:15 -0400 Subject: [PATCH 042/116] fix: ensure app ENV directory exists at DOKKU_LIB_ROOT/config/{app} The post-create trigger only created the property directory at DOKKU_LIB_ROOT/config/config/{app}/, but getAppFile() writes the ENV file to DOKKU_LIB_ROOT/config/{app}/ENV. Add setupAppConfigDir to create and permission the ENV directory in both post-create and install triggers, and clean it up on post-delete. --- plugins/config/triggers.go | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/plugins/config/triggers.go b/plugins/config/triggers.go index d82a0d987..02edcb160 100644 --- a/plugins/config/triggers.go +++ b/plugins/config/triggers.go @@ -60,6 +60,23 @@ func TriggerConfigUnset(appName string, key string, restart bool) error { 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()) @@ -114,6 +131,10 @@ func TriggerInstall() error { 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 @@ -198,10 +219,19 @@ func TriggerPostAppRenameSetup(oldAppName string, newAppName string) error { // TriggerPostCreate ensures apps have the correct config structure func TriggerPostCreate(appName string) error { - return common.PropertySetupApp("config", appName) + 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) } From aa45f748b1fa4714452c43cb3ec87e50acfa8ec1 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 05:02:19 -0400 Subject: [PATCH 043/116] fix: create DOKKU_ROOT app dir in Go tests and set global ENV permissions - config_test.go: create app dir at DOKKU_ROOT for VerifyAppName and ensure global config parent directory exists - migrateGlobalEnv: set dokku:dokku ownership on global ENV file after writing so deploy processes running as dokku can read it --- plugins/config/config_test.go | 5 +++++ plugins/config/triggers.go | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/plugins/config/config_test.go b/plugins/config/config_test.go index 02df8d9bc..e512fc42c 100644 --- a/plugins/config/config_test.go +++ b/plugins/config/config_test.go @@ -13,7 +13,9 @@ import ( var ( testAppName = "test-app-1" + dokkuRoot = common.MustGetEnv("DOKKU_ROOT") dokkuLibRoot = common.MustGetEnv("DOKKU_LIB_ROOT") + testAppRoot = filepath.Join(dokkuRoot, testAppName) testAppDir = filepath.Join(dokkuLibRoot, "config", testAppName) globalConfigFile = filepath.Join(dokkuLibRoot, "config", "--global", "ENV") ) @@ -27,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 @@ -41,6 +45,7 @@ func setupTestApp() (err error) { } func teardownTestApp() { + os.RemoveAll(testAppRoot) os.RemoveAll(testAppDir) } diff --git a/plugins/config/triggers.go b/plugins/config/triggers.go index 02edcb160..724aa348d 100644 --- a/plugins/config/triggers.go +++ b/plugins/config/triggers.go @@ -103,6 +103,13 @@ func migrateGlobalEnv() error { 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()) } From d24ab6ffdf988c9e0288ff7ddb039ff95131ad4f Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 06:01:34 -0400 Subject: [PATCH 044/116] fix: set DOKKU_LIB_ROOT in Go coverage test environment --- tests.mk | 1 + 1 file changed, 1 insertion(+) diff --git a/tests.mk b/tests.mk index d13240a98..6178cae64 100644 --- a/tests.mk +++ b/tests.mk @@ -155,6 +155,7 @@ 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 \ From d1554461abe11689f1f750aafd0ee2592d571b65 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 01:07:39 -0400 Subject: [PATCH 045/116] docs: move migration note to 0.38.x doc --- docs/appendices/0.37-migration-guide.md | 5 ----- docs/appendices/0.38.0-migration-guide.md | 3 +++ docs/getting-started/upgrading/index.md | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) delete mode 100644 docs/appendices/0.37-migration-guide.md diff --git a/docs/appendices/0.37-migration-guide.md b/docs/appendices/0.37-migration-guide.md deleted file mode 100644 index a0b70b2ba..000000000 --- a/docs/appendices/0.37-migration-guide.md +++ /dev/null @@ -1,5 +0,0 @@ -# 0.37.0 Migration Guide - -## Changes - -- 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. diff --git a/docs/appendices/0.38.0-migration-guide.md b/docs/appendices/0.38.0-migration-guide.md index e547325ec..e5e465ee3 100644 --- a/docs/appendices/0.38.0-migration-guide.md +++ b/docs/appendices/0.38.0-migration-guide.md @@ -4,6 +4,7 @@ - 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 }} @@ -13,3 +14,5 @@ {{ 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. 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 ``` - From c2a07c3f1c69ca1538232ecae2ee711fd9f74b86 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 01:47:50 -0400 Subject: [PATCH 046/116] fix: preserve all domains when renaming an app The `post-app-rename-setup` trigger redirected `sed` output to the new app's VHOST file inside a loop over global vhosts, so each iteration clobbered the previous one and only the last global subdomain was rewritten. When no global vhost was set, the loop never ran and the new app's VHOST file was never written, leaving the renamed app with no domains. Copy the old app's VHOST file once as a baseline, then apply `sed -i` in place for each global vhost so substitutions accumulate. Guard against a missing global VHOST file and skip blank lines. --- plugins/domains/post-app-rename-setup | 17 ++++++-- tests/unit/domains.bats | 56 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) 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/tests/unit/domains.bats b/tests/unit/domains.bats index 33a88fa08..577239b01 100644 --- a/tests/unit/domains.bats +++ b/tests/unit/domains.bats @@ -393,6 +393,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" From 4927ca092d427143b368f744421a2392df86b9a0 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 01:20:24 -0400 Subject: [PATCH 047/116] fix: retire orphaned containers when scaling down When scaling a process type to a smaller count, the indices above the new count had their `CONTAINER..` state files deleted but the underlying Docker containers were never registered for retirement, so they remained running indefinitely. The cleanup block now reads each orphaned state file and registers its container id with the existing `scheduler-register-retired` trigger before the file is removed. --- .../bin/scheduler-deploy-process | 9 +++- tests/unit/scheduler-docker-local.bats | 44 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) 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/tests/unit/scheduler-docker-local.bats b/tests/unit/scheduler-docker-local.bats index d14ea9ed4..4af6c279d 100644 --- a/tests/unit/scheduler-docker-local.bats +++ b/tests/unit/scheduler-docker-local.bats @@ -175,3 +175,47 @@ 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) 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 + + 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" +} From bc603d7b9c56c7aae40dda9c942a15f415077d26 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 03:29:06 -0400 Subject: [PATCH 048/116] test: drain dead-containers before scale-up assertion The intermediate container count check raced with the existing zero-downtime retire of the previous container, so docker ps still saw the renamed-but-pending container until wait-to-retire elapsed. --- tests/unit/scheduler-docker-local.bats | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/scheduler-docker-local.bats b/tests/unit/scheduler-docker-local.bats index 4af6c279d..fe9e9531d 100644 --- a/tests/unit/scheduler-docker-local.bats +++ b/tests/unit/scheduler-docker-local.bats @@ -197,6 +197,13 @@ teardown() { 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" From db1308a2ab381ecb577ae3c69b3825b4c83a570f Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Tue, 10 Mar 2026 15:36:43 -0400 Subject: [PATCH 049/116] feat: upgrade keda to 2.19.0 and keda-add-ons-http to 0.12.2 While there are breaking changes in the addon, none of these impact how Dokku exposes the addon. --- docs/deployment/schedulers/k3s.md | 3 ++- plugins/scheduler-k3s/scheduler_k3s.go | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/deployment/schedulers/k3s.md b/docs/deployment/schedulers/k3s.md index 975c51061..baaebf46a 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 { @@ -742,6 +742,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/plugins/scheduler-k3s/scheduler_k3s.go b/plugins/scheduler-k3s/scheduler_k3s.go index 63d9268cf..81dff53a7 100644 --- a/plugins/scheduler-k3s/scheduler_k3s.go +++ b/plugins/scheduler-k3s/scheduler_k3s.go @@ -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", From da13367ca83af8b9b87ea20f5c04b85dc714c9d4 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 07:57:38 -0400 Subject: [PATCH 050/116] feat: add version-aware helm chart upgrade callbacks Some helm chart upgrades require side-effects that a plain `helm upgrade` cannot perform, such as deleting deployments whose immutable selectors changed. Charts may now register pre and post upgrade hooks against a target version; applicable hooks fire in ascending semver order, each bracketed around an upgrade to its version, with a final upgrade to the chart's configured version when needed. The new `keda-add-ons-http` 0.12.2 hook deletes the chart-managed deployments before the upgrade so the new selectors take effect cleanly. --- docs/deployment/schedulers/k3s.md | 8 ++ plugins/scheduler-k3s/chart_hooks.go | 104 ++++++++++++++ plugins/scheduler-k3s/chart_hooks_test.go | 159 ++++++++++++++++++++++ plugins/scheduler-k3s/functions.go | 60 ++++++-- plugins/scheduler-k3s/k8s.go | 49 +++++++ 5 files changed, 370 insertions(+), 10 deletions(-) create mode 100644 plugins/scheduler-k3s/chart_hooks.go create mode 100644 plugins/scheduler-k3s/chart_hooks_test.go diff --git a/docs/deployment/schedulers/k3s.md b/docs/deployment/schedulers/k3s.md index baaebf46a..c28ac80df 100644 --- a/docs/deployment/schedulers/k3s.md +++ b/docs/deployment/schedulers/k3s.md @@ -683,6 +683,14 @@ Alternatively, a comma separated list of chart names can be specified to only fo dokku scheduler-k3s:ensure-charts --charts cert-manager ``` +### 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. 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/functions.go b/plugins/scheduler-k3s/functions.go index 4d56fcb2d..09490fb75 100644 --- a/plugins/scheduler-k3s/functions.go +++ b/plugins/scheduler-k3s/functions.go @@ -1679,17 +1679,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/k8s.go b/plugins/scheduler-k3s/k8s.go index 46cf92c87..2af20e8f3 100644 --- a/plugins/scheduler-k3s/k8s.go +++ b/plugins/scheduler-k3s/k8s.go @@ -296,6 +296,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 From d356ee684766e4bd54bc38b734a31b2b742817dd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:15:21 +0000 Subject: [PATCH 051/116] chore(deps): bump packaging from 26.1 to 26.2 in /docs/_build Bumps [packaging](https://github.com/pypa/packaging) from 26.1 to 26.2. - [Release notes](https://github.com/pypa/packaging/releases) - [Changelog](https://github.com/pypa/packaging/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pypa/packaging/compare/26.1...26.2) --- updated-dependencies: - dependency-name: packaging dependency-version: '26.2' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- docs/_build/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 4eb42f1b82d134fa195597c5b59360b04eddfd1d Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 14:11:37 -0400 Subject: [PATCH 052/116] refactor: migrate docker-options subcommands to go Ports the user-facing CLI subcommands and lifecycle triggers from bash to go, mirroring the layout used by the ports plugin. The on-disk format (`DOCKER_OPTIONS_BUILD/DEPLOY/RUN` files), the `docker-args-build/deploy/run` triggers, and every exported helper consumed by other plugins are unchanged. Reserves the `--process` and `--global` flags on add, remove, and clear so usage today exits with a not-yet-implemented error and the surface is locked in ahead of the process-scoping work tracked in #2441. --- plugins/docker-options/Makefile | 6 + plugins/docker-options/commands | 16 --- plugins/docker-options/functions | 112 ------------------ plugins/docker-options/go.mod | 5 +- plugins/docker-options/go.sum | 2 + plugins/docker-options/help-functions | 35 ------ plugins/docker-options/internal-functions | 75 ------------ plugins/docker-options/internal-functions.go | 45 +++++++ plugins/docker-options/post-app-clone-setup | 20 ---- plugins/docker-options/post-app-rename-setup | 20 ---- plugins/docker-options/post-delete | 14 --- plugins/docker-options/report | 6 - plugins/docker-options/report.go | 50 ++++++++ .../docker-options/src/commands/commands.go | 57 +++++++++ .../src/subcommands/subcommands.go | 109 +++++++++++++++++ .../docker-options/src/triggers/triggers.go | 41 +++++++ plugins/docker-options/subcommands.go | 105 ++++++++++++++++ plugins/docker-options/subcommands/add | 21 ---- plugins/docker-options/subcommands/clear | 28 ----- plugins/docker-options/subcommands/default | 6 - plugins/docker-options/subcommands/remove | 20 ---- plugins/docker-options/subcommands/report | 6 - plugins/docker-options/triggers.go | 65 ++++++++++ 23 files changed, 484 insertions(+), 380 deletions(-) create mode 100644 plugins/docker-options/Makefile delete mode 100755 plugins/docker-options/commands delete mode 100755 plugins/docker-options/functions delete mode 100755 plugins/docker-options/help-functions delete mode 100755 plugins/docker-options/internal-functions create mode 100644 plugins/docker-options/internal-functions.go delete mode 100755 plugins/docker-options/post-app-clone-setup delete mode 100755 plugins/docker-options/post-app-rename-setup delete mode 100755 plugins/docker-options/post-delete delete mode 100755 plugins/docker-options/report create mode 100644 plugins/docker-options/report.go create mode 100644 plugins/docker-options/src/commands/commands.go create mode 100644 plugins/docker-options/src/subcommands/subcommands.go create mode 100644 plugins/docker-options/src/triggers/triggers.go create mode 100644 plugins/docker-options/subcommands.go delete mode 100755 plugins/docker-options/subcommands/add delete mode 100755 plugins/docker-options/subcommands/clear delete mode 100755 plugins/docker-options/subcommands/default delete mode 100755 plugins/docker-options/subcommands/remove delete mode 100755 plugins/docker-options/subcommands/report create mode 100644 plugins/docker-options/triggers.go diff --git a/plugins/docker-options/Makefile b/plugins/docker-options/Makefile new file mode 100644 index 000000000..0e9019a9f --- /dev/null +++ b/plugins/docker-options/Makefile @@ -0,0 +1,6 @@ +SUBCOMMANDS = subcommands/add subcommands/clear subcommands/remove subcommands/report +TRIGGERS = 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/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/go.mod b/plugins/docker-options/go.mod index cc765fc5b..b76b55128 100644 --- a/plugins/docker-options/go.mod +++ b/plugins/docker-options/go.mod @@ -2,7 +2,10 @@ module github.com/dokku/dokku/plugins/docker-options 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 +) 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..c97fc9665 100644 --- a/plugins/docker-options/go.sum +++ b/plugins/docker-options/go.sum @@ -32,6 +32,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb 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= 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..70cb95513 --- /dev/null +++ b/plugins/docker-options/internal-functions.go @@ -0,0 +1,45 @@ +package dockeroptions + +import ( + "errors" + "fmt" + "strings" +) + +var availablePhases = []string{"build", "deploy", "run"} + +func parsePhases(phasesArg string) ([]string, error) { + if phasesArg == "" { + return nil, nil + } + + phases := strings.Split(phasesArg, ",") + for _, phase := range phases { + valid := false + for _, allowed := range availablePhases { + if phase == allowed { + valid = true + break + } + } + if !valid { + return nil, fmt.Errorf("Phase(s) must be one of [%s]", strings.Join(availablePhases, " ")) + } + } + + return phases, nil +} + +// ErrIfReservedFlagsUsed returns a not-yet-implemented error when --process or --global are passed. +// Both flags are reserved for the upcoming process-scoped docker options work (issue #2441). +func ErrIfReservedFlagsUsed(processes []string, global bool) error { + if len(processes) == 0 && !global { + return nil + } + + if len(processes) > 0 && global { + return errors.New("--process and --global flags are mutually exclusive") + } + + return errors.New("--process and --global flags are reserved for a future release and not yet implemented") +} 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..20c6117e0 --- /dev/null +++ b/plugins/docker-options/report.go @@ -0,0 +1,50 @@ +package dockeroptions + +import ( + "strings" + + "github.com/dokku/dokku/plugins/common" +) + +// ReportSingleApp displays the docker options report for a single app +func ReportSingleApp(appName string, format string, infoFlag string) error { + if err := common.VerifyAppName(appName); err != nil { + return err + } + + flags := map[string]common.ReportFunc{ + "--docker-options-build": reportBuildOptions, + "--docker-options-deploy": reportDeployOptions, + "--docker-options-run": reportRunOptions, + } + + 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 joinPhaseOptions(appName, "build") +} + +func reportDeployOptions(appName string) string { + return joinPhaseOptions(appName, "deploy") +} + +func reportRunOptions(appName string) string { + return joinPhaseOptions(appName, "run") +} + +func joinPhaseOptions(appName string, phase string) string { + options, err := GetDockerOptionsForPhase(appName, 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..d70e97263 --- /dev/null +++ b/plugins/docker-options/src/subcommands/subcommands.go @@ -0,0 +1,109 @@ +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 (reserved for future use)") + global := args.Bool("global", false, "explicitly mark as a global option (reserved for future use)") + args.Parse(os.Args[2:]) + + if err = dockeroptions.ErrIfReservedFlagsUsed(*processes, *global); err != nil { + break + } + + 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, 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 (reserved for future use)") + global := args.Bool("global", false, "explicitly mark as a global option (reserved for future use)") + args.Parse(os.Args[2:]) + + if err = dockeroptions.ErrIfReservedFlagsUsed(*processes, *global); err != nil { + break + } + + 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, 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 (reserved for future use)") + global := args.Bool("global", false, "explicitly mark as a global option (reserved for future use)") + args.Parse(os.Args[2:]) + + if err = dockeroptions.ErrIfReservedFlagsUsed(*processes, *global); err != nil { + break + } + + positional := args.Args() + appName := "" + phases := "" + if len(positional) > 0 { + appName = positional[0] + } + if len(positional) > 1 { + phases = positional[1] + } + err = dockeroptions.CommandClear(appName, phases) + case "report": + args := flag.NewFlagSet("docker-options:report", flag.ExitOnError) + format := args.String("format", "stdout", "format: [ stdout | json ]") + osArgs, infoFlag, flagErr := common.ParseReportArgs("docker-options", os.Args[2:]) + if flagErr == nil { + args.Parse(osArgs) + appName := args.Arg(0) + err = dockeroptions.CommandReport(appName, *format, 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..f65ff8554 --- /dev/null +++ b/plugins/docker-options/src/triggers/triggers.go @@ -0,0 +1,41 @@ +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 "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.go b/plugins/docker-options/subcommands.go new file mode 100644 index 000000000..f68606245 --- /dev/null +++ b/plugins/docker-options/subcommands.go @@ -0,0 +1,105 @@ +package dockeroptions + +import ( + "errors" + "fmt" + "os" + + "github.com/dokku/dokku/plugins/common" +) + +// CommandAdd adds a docker option to the specified phases for an app +func CommandAdd(appName string, phasesArg string, option string) error { + if err := common.VerifyAppName(appName); err != nil { + return err + } + + phases, err := parsePhases(phasesArg) + if err != nil { + return err + } + + if option == "" { + return errors.New("Please specify docker options to add to the phase") + } + + return AddDockerOptionToPhases(appName, phases, option) +} + +// CommandRemove removes a docker option from the specified phases for an app +func CommandRemove(appName string, phasesArg string, option string) error { + if err := common.VerifyAppName(appName); err != nil { + return err + } + + phases, err := parsePhases(phasesArg) + if err != nil { + return err + } + + if option == "" { + return errors.New("Please specify docker options to remove from the phase") + } + + return RemoveDockerOptionFromPhases(appName, phases, option) +} + +// CommandClear removes all docker options for an app, optionally limited to a list of phases +func CommandClear(appName string, phasesArg string) error { + if err := common.VerifyAppName(appName); err != nil { + return err + } + + if phasesArg == "" { + common.LogInfo1(fmt.Sprintf("Clearing docker-options for %s on all phases", appName)) + for _, phase := range availablePhases { + if err := removePhaseFile(appName, 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 := removePhaseFile(appName, phase); err != nil { + return err + } + } + + 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) +} + +func removePhaseFile(appName string, phase string) error { + if err := os.Remove(getPhaseFilePath(appName, phase)); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} 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..9e63a06fc --- /dev/null +++ b/plugins/docker-options/triggers.go @@ -0,0 +1,65 @@ +package dockeroptions + +import ( + "fmt" + "io" + "os" + + "github.com/dokku/dokku/plugins/common" +) + +// TriggerPostAppCloneSetup copies docker option files from the source app to the cloned app +func TriggerPostAppCloneSetup(oldAppName string, newAppName string) error { + for _, phase := range availablePhases { + if err := copyPhaseFile(oldAppName, newAppName, phase); err != nil { + return err + } + } + return nil +} + +// TriggerPostAppRenameSetup copies docker option files from the old app name to the new app name +func TriggerPostAppRenameSetup(oldAppName string, newAppName string) error { + for _, phase := range availablePhases { + if err := copyPhaseFile(oldAppName, newAppName, phase); err != nil { + return err + } + } + return nil +} + +// TriggerPostDelete deletes the docker option files for an app +func TriggerPostDelete(appName string) error { + for _, phase := range availablePhases { + if err := removePhaseFile(appName, phase); err != nil { + return err + } + } + return nil +} + +func copyPhaseFile(srcApp string, dstApp string, phase string) error { + srcPath := getPhaseFilePath(srcApp, phase) + if !common.FileExists(srcPath) { + return nil + } + + src, err := os.Open(srcPath) + if err != nil { + return fmt.Errorf("Unable to open docker options phase file %s.%s: %s", srcApp, phase, err.Error()) + } + defer src.Close() + + dstPath := getPhaseFilePath(dstApp, phase) + dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("Unable to create docker options phase file %s.%s: %s", dstApp, phase, err.Error()) + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + return fmt.Errorf("Unable to copy docker options phase file %s.%s to %s.%s: %s", srcApp, phase, dstApp, phase, err.Error()) + } + + return nil +} From 099c358a1e0a9954551f1e2ca43894a5acea9e1a Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 14:27:05 -0400 Subject: [PATCH 053/116] fix: set the defaultVolumeMounts instead of using extraVolumeMounts Without this, the daemonset will inject customConfig.data_dir as a default mount, which will collide with extraVolumeMounts. --- .../templates/helm-config/vector.yaml | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/plugins/scheduler-k3s/templates/helm-config/vector.yaml b/plugins/scheduler-k3s/templates/helm-config/vector.yaml index 956b55ce5..f56f95274 100644 --- a/plugins/scheduler-k3s/templates/helm-config/vector.yaml +++ b/plugins/scheduler-k3s/templates/helm-config/vector.yaml @@ -28,16 +28,43 @@ readinessProbe: path: /health port: api -extraVolumes: - - name: vector-data-dir +# Replace the chart's defaults so /vector-data-dir is backed by a host directory +# instead of the chart-managed emptyDir. Other defaults (var-log, var-lib, procfs, +# sysfs) are preserved as the chart ships them. +defaultVolumes: + - name: data hostPath: path: /var/lib/vector type: DirectoryOrCreate + - name: var-log + hostPath: + path: /var/log/ + - name: var-lib + hostPath: + path: /var/lib/ + - name: procfs + hostPath: + path: /proc + - name: sysfs + hostPath: + path: /sys -extraVolumeMounts: - - name: vector-data-dir +defaultVolumeMounts: + - name: data mountPath: /vector-data-dir readOnly: false + - name: var-log + mountPath: /var/log/ + readOnly: true + - name: var-lib + mountPath: /var/lib + readOnly: true + - name: procfs + mountPath: /host/proc + readOnly: true + - name: sysfs + mountPath: /host/sys + readOnly: true customConfig: data_dir: /vector-data-dir @@ -61,7 +88,7 @@ customConfig: - kubernetes_logs source: | .container = .kubernetes.container_name - .pod = .kubernetes.pod_name + .pod = .kubernetes.pod_name .namespace = .kubernetes.pod_namespace sinks: From a4f26a356afa74f375571abc57e5d87ebb4859ce Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 02:55:30 -0400 Subject: [PATCH 054/116] feat: add nginx:reload command Reloads the nginx server config after validating it with `nginx -t`. Useful for picking up changes to globally managed nginx configs without restarting the running process. --- plugins/nginx-vhosts/command-functions | 23 +++++++++++++++++++++++ plugins/nginx-vhosts/help-functions | 1 + plugins/nginx-vhosts/subcommands/reload | 6 ++++++ 3 files changed, 30 insertions(+) create mode 100755 plugins/nginx-vhosts/subcommands/reload diff --git a/plugins/nginx-vhosts/command-functions b/plugins/nginx-vhosts/command-functions index a2de9beaf..5efbd339c 100755 --- a/plugins/nginx-vhosts/command-functions +++ b/plugins/nginx-vhosts/command-functions @@ -235,6 +235,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/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/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 "$@" From d7c88ae298dad7b915ee5b38949996889e0220e0 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 02:55:40 -0400 Subject: [PATCH 055/116] feat: ship default catch-all site on fresh apt install Fresh apt installs now drop a catch-all server block at `/etc/nginx/conf.d/00-default-vhost.conf` that uses `ssl_reject_handshake on` and `return 444` to drop requests with unknown Host headers. Conflicting upstream nginx default vhosts are renamed to `*.dokku-disabled` rather than deleted, preserving any local edits. The new `dokku/install_default_site` debconf flag opts out of the install. Upgrades leave existing nginx config untouched. --- debian/config | 1 + debian/postinst | 35 ++++++ debian/templates | 5 + docs/appendices/0.38.0-migration-guide.md | 6 + docs/networking/proxies/nginx.md | 117 +++++++++++++----- .../nginx-vhosts/templates/default-site.conf | 15 +++ tests/unit/nginx-vhosts_14.bats | 99 +++++++++++++++ 7 files changed, 246 insertions(+), 32 deletions(-) create mode 100644 plugins/nginx-vhosts/templates/default-site.conf create mode 100644 tests/unit/nginx-vhosts_14.bats 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..7e67da421 100755 --- a/debian/postinst +++ b/debian/postinst @@ -135,6 +135,38 @@ setup-sshcommand() { fi } +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 + if [ -e "$path" ] && [ ! -e "${path}.dokku-disabled" ]; then + mv -n "$path" "${path}.dokku-disabled" + echo "Disabled $path (renamed to ${path}.dokku-disabled)" 1>&2 + fi + 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 +202,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/appendices/0.38.0-migration-guide.md b/docs/appendices/0.38.0-migration-guide.md index e5e465ee3..5ff39fd7e 100644 --- a/docs/appendices/0.38.0-migration-guide.md +++ b/docs/appendices/0.38.0-migration-guide.md @@ -16,3 +16,9 @@ ``` - 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). + +### 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/networking/proxies/nginx.md b/docs/networking/proxies/nginx.md index 9dec59cfb..2e746c0e7 100644 --- a/docs/networking/proxies/nginx.md +++ b/docs/networking/proxies/nginx.md @@ -5,6 +5,7 @@ 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: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 @@ -64,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] @@ -168,52 +182,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/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/tests/unit/nginx-vhosts_14.bats b/tests/unit/nginx-vhosts_14.bats new file mode 100644 index 000000000..46d424064 --- /dev/null +++ b/tests/unit/nginx-vhosts_14.bats @@ -0,0 +1,99 @@ +#!/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 + rm -f /etc/nginx/sites-enabled/default.bats-stub.dokku-disabled + [[ -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 mv /etc/nginx/sites-enabled/default.bats-stub /etc/nginx/sites-enabled/default.bats-stub.dokku-disabled + + run /bin/bash -c "sudo nginx -t" + echo "output: $output" + echo "status: $status" + assert_success +} From b2ea5406fee3b7b4362ab1ad3e185e1ebe80461b Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 04:02:18 -0400 Subject: [PATCH 056/116] fix: remove sites-enabled default instead of renaming Renaming `/etc/nginx/sites-enabled/default` to `.dokku-disabled` left a broken symlink that nginx's `include /etc/nginx/sites-enabled/*;` glob still picked up, breaking config validation. Files inside `sites-enabled/` are now removed; the real config in `sites-available/default` is still preserved via rename. --- debian/postinst | 35 +++++++++++++++++++++++++++++---- tests/unit/nginx-vhosts_14.bats | 3 +-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/debian/postinst b/debian/postinst index 7e67da421..06860f8d0 100755 --- a/debian/postinst +++ b/debian/postinst @@ -135,6 +135,36 @@ 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 @@ -153,10 +183,7 @@ setup-default-site() { fi for path in /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default /etc/nginx/conf.d/default.conf; do - if [ -e "$path" ] && [ ! -e "${path}.dokku-disabled" ]; then - mv -n "$path" "${path}.dokku-disabled" - echo "Disabled $path (renamed to ${path}.dokku-disabled)" 1>&2 - fi + disable-default-vhost-path "$path" done echo "Installing nginx default catch-all site at $default_vhost_target" diff --git a/tests/unit/nginx-vhosts_14.bats b/tests/unit/nginx-vhosts_14.bats index 46d424064..51046b609 100644 --- a/tests/unit/nginx-vhosts_14.bats +++ b/tests/unit/nginx-vhosts_14.bats @@ -25,7 +25,6 @@ teardown() { mv "${NGINX_DEFAULT_VHOST_PATH}.bats-bak" "$NGINX_DEFAULT_VHOST_PATH" fi rm -f /etc/nginx/sites-enabled/default.bats-stub - rm -f /etc/nginx/sites-enabled/default.bats-stub.dokku-disabled [[ -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 @@ -90,7 +89,7 @@ STOCK echo "status: $status" assert_failure - sudo mv /etc/nginx/sites-enabled/default.bats-stub /etc/nginx/sites-enabled/default.bats-stub.dokku-disabled + sudo rm -f /etc/nginx/sites-enabled/default.bats-stub run /bin/bash -c "sudo nginx -t" echo "output: $output" From af6267f3b51ee167bc9bf1f5308adb49569dad29 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 07:30:54 -0400 Subject: [PATCH 057/116] fix: restore default domains and reset proxy ports on vhost enable Re-enabling vhost (via `proxy:enable`, `domains:enable`, or `domains:add` with no global VHOST) was leaving the app pinned to the random high port and the empty VHOST file from the previous disabled state, so the new catch-all vhost intercepted requests for the app's domain. The `domains-enable` trigger now restores default vhosts via `domains_enable`, and `pre-enable-vhost` clears any stored proxy ports so they can be recomputed to 80/443. Also fixes the netrc lookup in the `git:auth` test to read dokku's netrc file directly. --- plugins/domains/domains-enable | 4 ++-- plugins/nginx-vhosts/pre-enable-vhost | 6 +++--- tests/unit/git_3.bats | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) 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/nginx-vhosts/pre-enable-vhost b/plugins/nginx-vhosts/pre-enable-vhost index 224887462..a64365d66 100755 --- a/plugins/nginx-vhosts/pre-enable-vhost +++ b/plugins/nginx-vhosts/pre-enable-vhost @@ -13,14 +13,14 @@ trigger-nginx-vhosts-pre-enable-vhost() { return fi - # only clear ports if they are port 80 or 443 + # 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") - if [[ "$PROXY_PORT" == "80" ]]; then + if [[ -n "$PROXY_PORT" ]]; then fn-plugin-property-delete "proxy" "$APP" "proxy-port" fi - if [[ "$PROXY_SSL_PORT" == "443" ]]; then + if [[ -n "$PROXY_SSL_PORT" ]]; then fn-plugin-property-delete "proxy" "$APP" "proxy-ssl-port" fi diff --git a/tests/unit/git_3.bats b/tests/unit/git_3.bats index aa4cce84c..21a7e06bb 100644 --- a/tests/unit/git_3.bats +++ b/tests/unit/git_3.bats @@ -100,7 +100,7 @@ teardown() { assert_success assert_output_contains "Setting netrc auth entry for host github.com" - run /bin/bash -c "netrc get github.com" + run /bin/bash -c "netrc get --netrc-file ${DOKKU_ROOT}/.netrc github.com" echo "output: $output" echo "status: $status" assert_success From eaaf0e41c2413c871af6d0ff338cf340f747a8a1 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 15:43:33 -0400 Subject: [PATCH 058/116] fix: clear auto-detected port maps when re-enabling vhost Re-enabling vhost on an app that was previously deployed with no global VHOST left the auto-assigned random high port behind in the ports plugin's `map-detected` property, so nginx kept listening on that port and the new catch-all default vhost rejected requests for the app's own domain on port 80. --- plugins/nginx-vhosts/pre-enable-vhost | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/nginx-vhosts/pre-enable-vhost b/plugins/nginx-vhosts/pre-enable-vhost index a64365d66..c3abb8439 100755 --- a/plugins/nginx-vhosts/pre-enable-vhost +++ b/plugins/nginx-vhosts/pre-enable-vhost @@ -24,6 +24,9 @@ trigger-nginx-vhosts-pre-enable-vhost() { fn-plugin-property-delete "proxy" "$APP" "proxy-ssl-port" 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") for PORT_MAP in $PORT_MAPS; do From b397cf1bd7c0af238cf19cc06c28d2ae347a23d1 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 15:58:35 -0400 Subject: [PATCH 059/116] test: pass deploy phase to docker-options:add in udp test The single-udp network test was calling `docker-options:add $TEST_APP -p 1194:1194` without a phase argument. The bash subcommand silently no-op'd because `-p` is not a recognized phase, so the test happened to pass even though it was not actually setting the option. The go port surfaces the validation error correctly, so the test now needs the proper `deploy` phase to keep working. --- tests/unit/network.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/network.bats b/tests/unit/network.bats index db2831c65..166ddd084 100644 --- a/tests/unit/network.bats +++ b/tests/unit/network.bats @@ -327,7 +327,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 From 87dd39e0b38871c5f2f195743643ad36ab7e2c20 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 15:58:40 -0400 Subject: [PATCH 060/116] refactor: collect docker-options file helpers in functions.go Moves `copyPhaseFile` and `removePhaseFile` next to the other phase-file helpers so the package keeps a single home for filesystem plumbing. --- plugins/docker-options/functions.go | 34 +++++++++++++++++++++++++++ plugins/docker-options/subcommands.go | 8 ------- plugins/docker-options/triggers.go | 34 --------------------------- 3 files changed, 34 insertions(+), 42 deletions(-) diff --git a/plugins/docker-options/functions.go b/plugins/docker-options/functions.go index f00fa5546..57b2d38d3 100644 --- a/plugins/docker-options/functions.go +++ b/plugins/docker-options/functions.go @@ -2,6 +2,7 @@ package dockeroptions import ( "fmt" + "io" "os" "path/filepath" "strings" @@ -13,6 +14,39 @@ func getPhaseFilePath(appName string, phase string) string { return filepath.Join(common.AppRoot(appName), "DOCKER_OPTIONS_"+strings.ToUpper(phase)) } +func copyPhaseFile(srcApp string, dstApp string, phase string) error { + srcPath := getPhaseFilePath(srcApp, phase) + if !common.FileExists(srcPath) { + return nil + } + + src, err := os.Open(srcPath) + if err != nil { + return fmt.Errorf("Unable to open docker options phase file %s.%s: %s", srcApp, phase, err.Error()) + } + defer src.Close() + + dstPath := getPhaseFilePath(dstApp, phase) + dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("Unable to create docker options phase file %s.%s: %s", dstApp, phase, err.Error()) + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + return fmt.Errorf("Unable to copy docker options phase file %s.%s to %s.%s: %s", srcApp, phase, dstApp, phase, err.Error()) + } + + return nil +} + +func removePhaseFile(appName string, phase string) error { + if err := os.Remove(getPhaseFilePath(appName, phase)); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + func touchPhaseFile(appName string, phase string) error { phaseFilePath := getPhaseFilePath(appName, phase) diff --git a/plugins/docker-options/subcommands.go b/plugins/docker-options/subcommands.go index f68606245..2f72d7077 100644 --- a/plugins/docker-options/subcommands.go +++ b/plugins/docker-options/subcommands.go @@ -3,7 +3,6 @@ package dockeroptions import ( "errors" "fmt" - "os" "github.com/dokku/dokku/plugins/common" ) @@ -96,10 +95,3 @@ func CommandReport(appName string, format string, infoFlag string) error { return ReportSingleApp(appName, format, infoFlag) } - -func removePhaseFile(appName string, phase string) error { - if err := os.Remove(getPhaseFilePath(appName, phase)); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} diff --git a/plugins/docker-options/triggers.go b/plugins/docker-options/triggers.go index 9e63a06fc..e594291f7 100644 --- a/plugins/docker-options/triggers.go +++ b/plugins/docker-options/triggers.go @@ -1,13 +1,5 @@ package dockeroptions -import ( - "fmt" - "io" - "os" - - "github.com/dokku/dokku/plugins/common" -) - // TriggerPostAppCloneSetup copies docker option files from the source app to the cloned app func TriggerPostAppCloneSetup(oldAppName string, newAppName string) error { for _, phase := range availablePhases { @@ -37,29 +29,3 @@ func TriggerPostDelete(appName string) error { } return nil } - -func copyPhaseFile(srcApp string, dstApp string, phase string) error { - srcPath := getPhaseFilePath(srcApp, phase) - if !common.FileExists(srcPath) { - return nil - } - - src, err := os.Open(srcPath) - if err != nil { - return fmt.Errorf("Unable to open docker options phase file %s.%s: %s", srcApp, phase, err.Error()) - } - defer src.Close() - - dstPath := getPhaseFilePath(dstApp, phase) - dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return fmt.Errorf("Unable to create docker options phase file %s.%s: %s", dstApp, phase, err.Error()) - } - defer dst.Close() - - if _, err := io.Copy(dst, src); err != nil { - return fmt.Errorf("Unable to copy docker options phase file %s.%s to %s.%s: %s", srcApp, phase, dstApp, phase, err.Error()) - } - - return nil -} From 5dcefe7ffa9664990ec6b0f99dbfd5e5993b5ff4 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 16:06:07 -0400 Subject: [PATCH 061/116] fix: use extraVolumeMount just for the vector state --- .../templates/helm-config/vector.yaml | 47 ++++--------------- 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/plugins/scheduler-k3s/templates/helm-config/vector.yaml b/plugins/scheduler-k3s/templates/helm-config/vector.yaml index f56f95274..6c841d7eb 100644 --- a/plugins/scheduler-k3s/templates/helm-config/vector.yaml +++ b/plugins/scheduler-k3s/templates/helm-config/vector.yaml @@ -28,46 +28,18 @@ readinessProbe: path: /health port: api -# Replace the chart's defaults so /vector-data-dir is backed by a host directory -# instead of the chart-managed emptyDir. Other defaults (var-log, var-lib, procfs, -# sysfs) are preserved as the chart ships them. -defaultVolumes: - - name: data +extraVolumes: + - name: vector-state hostPath: path: /var/lib/vector type: DirectoryOrCreate - - name: var-log - hostPath: - path: /var/log/ - - name: var-lib - hostPath: - path: /var/lib/ - - name: procfs - hostPath: - path: /proc - - name: sysfs - hostPath: - path: /sys -defaultVolumeMounts: - - name: data - mountPath: /vector-data-dir - readOnly: false - - name: var-log - mountPath: /var/log/ - readOnly: true - - name: var-lib - mountPath: /var/lib - readOnly: true - - name: procfs - mountPath: /host/proc - readOnly: true - - name: sysfs - mountPath: /host/sys - readOnly: true +extraVolumeMounts: + - name: vector-state + mountPath: /var/lib/vector-state customConfig: - data_dir: /vector-data-dir + data_dir: /var/lib/vector-state api: enabled: true @@ -87,9 +59,10 @@ customConfig: inputs: - kubernetes_logs source: | - .container = .kubernetes.container_name - .pod = .kubernetes.pod_name - .namespace = .kubernetes.pod_namespace + .container = del(.kubernetes.container_name) + .pod = del(.kubernetes.pod_name) + .namespace = del(.kubernetes.pod_namespace) + del(.kubernetes) sinks: default_global_sink: From 8e7d158ac2d0446fde857d0db754f28a82961671 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 16:06:45 -0400 Subject: [PATCH 062/116] chore: revert transforms changes --- plugins/scheduler-k3s/templates/helm-config/vector.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugins/scheduler-k3s/templates/helm-config/vector.yaml b/plugins/scheduler-k3s/templates/helm-config/vector.yaml index 6c841d7eb..805ab589e 100644 --- a/plugins/scheduler-k3s/templates/helm-config/vector.yaml +++ b/plugins/scheduler-k3s/templates/helm-config/vector.yaml @@ -59,10 +59,9 @@ customConfig: inputs: - kubernetes_logs source: | - .container = del(.kubernetes.container_name) - .pod = del(.kubernetes.pod_name) - .namespace = del(.kubernetes.pod_namespace) - del(.kubernetes) + .container = .kubernetes.container_name + .pod = .kubernetes.pod_name + .namespace = .kubernetes.pod_namespace sinks: default_global_sink: From c0f23528af626a606851510a59c3ea575907c16d Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 17:53:23 -0400 Subject: [PATCH 063/116] fix: just use an emptyDir Let vector decide how to handle the directory. --- .../scheduler-k3s/templates/helm-config/vector.yaml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/plugins/scheduler-k3s/templates/helm-config/vector.yaml b/plugins/scheduler-k3s/templates/helm-config/vector.yaml index 805ab589e..a6460eeec 100644 --- a/plugins/scheduler-k3s/templates/helm-config/vector.yaml +++ b/plugins/scheduler-k3s/templates/helm-config/vector.yaml @@ -28,18 +28,8 @@ readinessProbe: path: /health port: api -extraVolumes: - - name: vector-state - hostPath: - path: /var/lib/vector - type: DirectoryOrCreate - -extraVolumeMounts: - - name: vector-state - mountPath: /var/lib/vector-state - customConfig: - data_dir: /var/lib/vector-state + data_dir: /vector-data-dir api: enabled: true From 9c8d5f54fb05dea3b46d6656f40a81f9ed10bd65 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 27 Apr 2026 18:12:52 -0400 Subject: [PATCH 064/116] feat: scope docker-options to specific procfile processes Adds a `--process` flag (repeatable) to docker-options:add/remove/clear/list and the new docker-options:list subcommand for querying a single process+phase pair. Process scoping is supported only for the deploy phase since build runs once per app and run covers ad-hoc commands and cron tasks where no Procfile process type is available. Storage moves from `$DOKKU_ROOT/$APP/DOCKER_OPTIONS_*` files to property lists under `/var/lib/dokku/config/docker-options/$APP/{processType}.{phase}`, with `_default_` as the sentinel for app-wide options. The install trigger migrates pre-existing DOCKER_OPTIONS_* files into property lists once and renames them to `.migrated`; a global marker makes re-runs strictly no-op. The legacy docker-args-{build,deploy,run} bash triggers are reimplemented in Go alongside a new docker-args-process-deploy trigger that surfaces per-process options to the scheduler. The :report command exposes one dynamic flag per configured `process.deploy` pair (e.g. `--docker-options-deploy.web`) and supports `--format json`. There is no `--global` flag; omitting `--process` keeps the historical default behaviour, since `--global` elsewhere in dokku means "across all apps". Closes #2441. --- docs/advanced-usage/docker-options.md | 73 ++++- docs/development/plugin-triggers.md | 2 +- plugins/docker-options/.gitignore | 11 + plugins/docker-options/Makefile | 4 +- plugins/docker-options/docker-args-build | 1 - plugins/docker-options/docker-args-deploy | 86 ----- plugins/docker-options/docker-args-run | 1 - plugins/docker-options/dockeroptions.go | 246 ++++++++++----- plugins/docker-options/functions.go | 289 ++++++++++++++--- plugins/docker-options/internal-functions.go | 82 ++++- plugins/docker-options/report.go | 28 +- .../src/subcommands/subcommands.go | 36 +-- .../docker-options/src/triggers/triggers.go | 19 ++ plugins/docker-options/subcommands.go | 97 ------ plugins/docker-options/triggers.go | 146 +++++++-- tests/unit/docker-options-2.bats | 298 ++++++++++++++++++ 16 files changed, 1034 insertions(+), 385 deletions(-) create mode 100644 plugins/docker-options/.gitignore delete mode 120000 plugins/docker-options/docker-args-build delete mode 100755 plugins/docker-options/docker-args-deploy delete mode 120000 plugins/docker-options/docker-args-run delete mode 100644 plugins/docker-options/subcommands.go create mode 100644 tests/unit/docker-options-2.bats diff --git a/docs/advanced-usage/docker-options.md b/docs/advanced-usage/docker-options.md index 24b24a00c..4c3ecaa16 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. @@ -160,3 +161,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/development/plugin-triggers.md b/docs/development/plugin-triggers.md index 89c50e510..7fd0f7f29 100644 --- a/docs/development/plugin-triggers.md +++ b/docs/development/plugin-triggers.md @@ -809,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: 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 index 0e9019a9f..6a6807d81 100644 --- a/plugins/docker-options/Makefile +++ b/plugins/docker-options/Makefile @@ -1,5 +1,5 @@ -SUBCOMMANDS = subcommands/add subcommands/clear subcommands/remove subcommands/report -TRIGGERS = triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-delete triggers/report +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 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..b1ed96c2d 100644 --- a/plugins/docker-options/dockeroptions.go +++ b/plugins/docker-options/dockeroptions.go @@ -1,119 +1,152 @@ package dockeroptions import ( - "bufio" "fmt" - "os" "sort" "strings" "github.com/dokku/dokku/plugins/common" ) -// SetDockerOptionForPhases sets an option to specified phases +// 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_" + +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 { - for _, phase := range phases { - if err := touchPhaseFile(appName, phase); err != nil { - return err - } + return SetDockerOptionForProcessPhases(appName, []string{DefaultProcessType}, phases, name, value) +} - 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 +// 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 = append(newOptions, option) - } + 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 + 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 } -// AddDockerOptionToPhases adds docker option to specified phases +// AddDockerOptionToPhases adds an option to the default scope for the 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 + return AddDockerOptionToProcessPhases(appName, []string{DefaultProcessType}, phases, option) } -// 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 +// 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 + } - phaseFilePath := getPhaseFilePath(appName, phase) - file, err := os.Open(phaseFilePath) - 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())) - } - - 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 - } - - newOptions := []string{} - for _, opt := range options { - if opt != option { - newOptions = append(newOptions, opt) + options = append(options, option) + sort.Strings(options) + if err := writeDockerOptionsForProcessPhase(appName, processType, phase, options); err != nil { + return err } } + } + return nil +} - sort.Strings(newOptions) - if err = writeDockerOptionsForPhase(appName, phase, newOptions); err != nil { - return err +// 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 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 +// 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 +161,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 +176,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 +195,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/functions.go b/plugins/docker-options/functions.go index 57b2d38d3..b1a5b99aa 100644 --- a/plugins/docker-options/functions.go +++ b/plugins/docker-options/functions.go @@ -1,8 +1,8 @@ package dockeroptions import ( + "errors" "fmt" - "io" "os" "path/filepath" "strings" @@ -10,65 +10,258 @@ 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 copyPhaseFile(srcApp string, dstApp string, phase string) error { - srcPath := getPhaseFilePath(srcApp, phase) - if !common.FileExists(srcPath) { - return nil - } - - src, err := os.Open(srcPath) - if err != nil { - return fmt.Errorf("Unable to open docker options phase file %s.%s: %s", srcApp, phase, err.Error()) - } - defer src.Close() - - dstPath := getPhaseFilePath(dstApp, phase) - dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return fmt.Errorf("Unable to create docker options phase file %s.%s: %s", dstApp, phase, err.Error()) - } - defer dst.Close() - - if _, err := io.Copy(dst, src); err != nil { - return fmt.Errorf("Unable to copy docker options phase file %s.%s to %s.%s: %s", srcApp, phase, dstApp, phase, err.Error()) - } - - return nil -} - -func removePhaseFile(appName string, phase string) error { - if err := os.Remove(getPhaseFilePath(appName, phase)); err != nil && !os.IsNotExist(err) { +// 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. +func CommandAdd(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 + } + + if option == "" { + return errors.New("Please specify docker options to add to the phase") + } + + if err := ValidateProcessFlag(processes, phases); err != nil { + return err + } + + for _, processType := range processes { + WarnIfProcessNotInProcfile(appName, processType) + } + + if len(processes) == 0 { + return AddDockerOptionToPhases(appName, phases, option) + } + + return AddDockerOptionToProcessPhases(appName, processes, phases, option) +} + +// CommandRemove removes a docker option from the specified phases for an app. +// Process-flag handling matches CommandAdd. +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 + } + + if option == "" { + return errors.New("Please specify docker options to remove from the phase") + } + + if err := ValidateProcessFlag(processes, phases); err != nil { + return err + } + + if len(processes) == 0 { + return RemoveDockerOptionFromPhases(appName, phases, option) + } + + return RemoveDockerOptionFromProcessPhases(appName, processes, phases, option) +} + +// 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 touchPhaseFile(appName string, phase string) error { - phaseFilePath := getPhaseFilePath(appName, phase) - - _, err := os.Stat(phaseFilePath) - if !os.IsNotExist(err) { +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 } - 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 + } + + 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 + } } - 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), - }) +// 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/internal-functions.go b/plugins/docker-options/internal-functions.go index 70cb95513..480d110a9 100644 --- a/plugins/docker-options/internal-functions.go +++ b/plugins/docker-options/internal-functions.go @@ -3,11 +3,31 @@ 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 @@ -15,14 +35,7 @@ func parsePhases(phasesArg string) ([]string, error) { phases := strings.Split(phasesArg, ",") for _, phase := range phases { - valid := false - for _, allowed := range availablePhases { - if phase == allowed { - valid = true - break - } - } - if !valid { + if !isValidPhase(phase) { return nil, fmt.Errorf("Phase(s) must be one of [%s]", strings.Join(availablePhases, " ")) } } @@ -30,16 +43,55 @@ func parsePhases(phasesArg string) ([]string, error) { return phases, nil } -// ErrIfReservedFlagsUsed returns a not-yet-implemented error when --process or --global are passed. -// Both flags are reserved for the upcoming process-scoped docker options work (issue #2441). -func ErrIfReservedFlagsUsed(processes []string, global bool) error { - if len(processes) == 0 && !global { +// 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 } - if len(processes) > 0 && global { - return errors.New("--process and --global flags are mutually exclusive") + 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) + } } - return errors.New("--process and --global flags are reserved for a future release and not yet implemented") + 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/report.go b/plugins/docker-options/report.go index 20c6117e0..8ea166cf3 100644 --- a/plugins/docker-options/report.go +++ b/plugins/docker-options/report.go @@ -1,12 +1,16 @@ package dockeroptions import ( + "fmt" "strings" "github.com/dokku/dokku/plugins/common" ) -// ReportSingleApp displays the docker options report for a single app +// 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 err := common.VerifyAppName(appName); err != nil { return err @@ -18,6 +22,18 @@ func ReportSingleApp(appName string, format string, infoFlag string) error { "--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) @@ -30,19 +46,19 @@ func ReportSingleApp(appName string, format string, infoFlag string) error { } func reportBuildOptions(appName string) string { - return joinPhaseOptions(appName, "build") + return joinProcessPhaseOptions(appName, DefaultProcessType, "build") } func reportDeployOptions(appName string) string { - return joinPhaseOptions(appName, "deploy") + return joinProcessPhaseOptions(appName, DefaultProcessType, "deploy") } func reportRunOptions(appName string) string { - return joinPhaseOptions(appName, "run") + return joinProcessPhaseOptions(appName, DefaultProcessType, "run") } -func joinPhaseOptions(appName string, phase string) string { - options, err := GetDockerOptionsForPhase(appName, phase) +func joinProcessPhaseOptions(appName, processType, phase string) string { + options, err := GetDockerOptionsForProcessPhase(appName, processType, phase) if err != nil || len(options) == 0 { return "" } diff --git a/plugins/docker-options/src/subcommands/subcommands.go b/plugins/docker-options/src/subcommands/subcommands.go index d70e97263..fae642832 100644 --- a/plugins/docker-options/src/subcommands/subcommands.go +++ b/plugins/docker-options/src/subcommands/subcommands.go @@ -20,14 +20,9 @@ func main() { 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 (reserved for future use)") - global := args.Bool("global", false, "explicitly mark as a global option (reserved for future use)") + processes := args.StringSlice("process", []string{}, "process types to scope this option to (deploy phase only)") args.Parse(os.Args[2:]) - if err = dockeroptions.ErrIfReservedFlagsUsed(*processes, *global); err != nil { - break - } - positional := args.Args() appName := "" phases := "" @@ -41,18 +36,13 @@ func main() { if len(positional) > 2 { optionParts = positional[2:] } - err = dockeroptions.CommandAdd(appName, phases, strings.Join(optionParts, " ")) + 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 (reserved for future use)") - global := args.Bool("global", false, "explicitly mark as a global option (reserved for future use)") + processes := args.StringSlice("process", []string{}, "process types to scope this option to (deploy phase only)") args.Parse(os.Args[2:]) - if err = dockeroptions.ErrIfReservedFlagsUsed(*processes, *global); err != nil { - break - } - positional := args.Args() appName := "" phases := "" @@ -66,18 +56,13 @@ func main() { if len(positional) > 2 { optionParts = positional[2:] } - err = dockeroptions.CommandRemove(appName, phases, strings.Join(optionParts, " ")) + 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 (reserved for future use)") - global := args.Bool("global", false, "explicitly mark as a global option (reserved for future use)") + processes := args.StringSlice("process", []string{}, "process types to scope this option to (deploy phase only)") args.Parse(os.Args[2:]) - if err = dockeroptions.ErrIfReservedFlagsUsed(*processes, *global); err != nil { - break - } - positional := args.Args() appName := "" phases := "" @@ -87,7 +72,16 @@ func main() { if len(positional) > 1 { phases = positional[1] } - err = dockeroptions.CommandClear(appName, phases) + err = dockeroptions.CommandClear(appName, *processes, phases) + case "list": + args := flag.NewFlagSet("docker-options:list", flag.ExitOnError) + args.SetInterspersed(false) + 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 ]") diff --git a/plugins/docker-options/src/triggers/triggers.go b/plugins/docker-options/src/triggers/triggers.go index f65ff8554..59361a245 100644 --- a/plugins/docker-options/src/triggers/triggers.go +++ b/plugins/docker-options/src/triggers/triggers.go @@ -17,6 +17,25 @@ func main() { 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) diff --git a/plugins/docker-options/subcommands.go b/plugins/docker-options/subcommands.go deleted file mode 100644 index 2f72d7077..000000000 --- a/plugins/docker-options/subcommands.go +++ /dev/null @@ -1,97 +0,0 @@ -package dockeroptions - -import ( - "errors" - "fmt" - - "github.com/dokku/dokku/plugins/common" -) - -// CommandAdd adds a docker option to the specified phases for an app -func CommandAdd(appName string, phasesArg string, option string) error { - if err := common.VerifyAppName(appName); err != nil { - return err - } - - phases, err := parsePhases(phasesArg) - if err != nil { - return err - } - - if option == "" { - return errors.New("Please specify docker options to add to the phase") - } - - return AddDockerOptionToPhases(appName, phases, option) -} - -// CommandRemove removes a docker option from the specified phases for an app -func CommandRemove(appName string, phasesArg string, option string) error { - if err := common.VerifyAppName(appName); err != nil { - return err - } - - phases, err := parsePhases(phasesArg) - if err != nil { - return err - } - - if option == "" { - return errors.New("Please specify docker options to remove from the phase") - } - - return RemoveDockerOptionFromPhases(appName, phases, option) -} - -// CommandClear removes all docker options for an app, optionally limited to a list of phases -func CommandClear(appName string, phasesArg string) error { - if err := common.VerifyAppName(appName); err != nil { - return err - } - - if phasesArg == "" { - common.LogInfo1(fmt.Sprintf("Clearing docker-options for %s on all phases", appName)) - for _, phase := range availablePhases { - if err := removePhaseFile(appName, 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 := removePhaseFile(appName, phase); err != nil { - return err - } - } - - 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) -} diff --git a/plugins/docker-options/triggers.go b/plugins/docker-options/triggers.go index e594291f7..de9292454 100644 --- a/plugins/docker-options/triggers.go +++ b/plugins/docker-options/triggers.go @@ -1,31 +1,141 @@ package dockeroptions -// TriggerPostAppCloneSetup copies docker option files from the source app to the cloned app +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 { - for _, phase := range availablePhases { - if err := copyPhaseFile(oldAppName, newAppName, phase); err != nil { - return err - } - } - return nil + return common.PropertyClone("docker-options", oldAppName, newAppName) } -// TriggerPostAppRenameSetup copies docker option files from the old app name to the new app name +// TriggerPostAppRenameSetup moves docker option properties from the old app +// name to the new one. func TriggerPostAppRenameSetup(oldAppName string, newAppName string) error { - for _, phase := range availablePhases { - if err := copyPhaseFile(oldAppName, newAppName, phase); err != nil { - return err - } + 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 } -// TriggerPostDelete deletes the docker option files for an app -func TriggerPostDelete(appName string) error { - for _, phase := range availablePhases { - if err := removePhaseFile(appName, phase); err != nil { - return err - } +// 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/tests/unit/docker-options-2.bats b/tests/unit/docker-options-2.bats new file mode 100644 index 000000000..dda98bd92 --- /dev/null +++ b/tests/unit/docker-options-2.bats @@ -0,0 +1,298 @@ +#!/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 "" +} + +@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 "-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) install migration is idempotent" { + echo '-v /tmp/legacy:/legacy' >"$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" + + rm -f "/var/lib/dokku/config/docker-options/--global/migrated-from-files" + rm -rf "/var/lib/dokku/config/docker-options/$TEST_APP" + + run /bin/bash -c "dokku plugin:trigger install" + 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 "-v /tmp/legacy:/legacy" + + [[ -f "$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY.migrated" ]] + [[ ! -f "$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" ]] + + echo '-v /tmp/sneaky:/sneaky' >"$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" + run /bin/bash -c "dokku plugin:trigger install" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:list $TEST_APP --phase deploy" + echo "output: $output" + assert_output "-v /tmp/legacy:/legacy" + + [[ -f "$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" ]] + run /bin/bash -c "cat $DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" + assert_output_contains "/tmp/sneaky" +} + +@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 "-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 "-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" < Date: Mon, 27 Apr 2026 18:18:39 -0400 Subject: [PATCH 065/116] feat: send SIGTERM to old containers immediately on deploy The docker-local scheduler now sends `SIGTERM` to old containers immediately after a successful deploy via `docker container kill --signal=SIGTERM`, 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 existing `wait-to-retire` grace period and `stop-timeout-seconds` hard-stop continue to apply unchanged as the authoritative cleanup path. --- docs/appendices/0.38.0-migration-guide.md | 1 + docs/deployment/zero-downtime-deploys.md | 4 +- .../scheduler-docker-local/scheduler-deploy | 13 +++++++ tests/unit/scheduler-docker-local.bats | 38 +++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/docs/appendices/0.38.0-migration-guide.md b/docs/appendices/0.38.0-migration-guide.md index 5ff39fd7e..4c809cf6e 100644 --- a/docs/appendices/0.38.0-migration-guide.md +++ b/docs/appendices/0.38.0-migration-guide.md @@ -18,6 +18,7 @@ - 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. ### TLS handshake behavior change diff --git a/docs/deployment/zero-downtime-deploys.md b/docs/deployment/zero-downtime-deploys.md index 5ef21b4ee..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 diff --git a/plugins/scheduler-docker-local/scheduler-deploy b/plugins/scheduler-docker-local/scheduler-deploy index 7981c5a7f..16c3370be 100755 --- a/plugins/scheduler-docker-local/scheduler-deploy +++ b/plugins/scheduler-docker-local/scheduler-deploy @@ -84,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/tests/unit/scheduler-docker-local.bats b/tests/unit/scheduler-docker-local.bats index fe9e9531d..1260c127e 100644 --- a/tests/unit/scheduler-docker-local.bats +++ b/tests/unit/scheduler-docker-local.bats @@ -176,6 +176,44 @@ teardown() { 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" From 32c997e4a72c268602869918750e7302bf23053a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:54:14 +0000 Subject: [PATCH 066/116] chore(deps): bump packaging from 26.1 to 26.2 in /docs/_build Bumps [packaging](https://github.com/pypa/packaging) from 26.1 to 26.2. - [Release notes](https://github.com/pypa/packaging/releases) - [Changelog](https://github.com/pypa/packaging/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pypa/packaging/compare/26.1...26.2) --- updated-dependencies: - dependency-name: packaging dependency-version: '26.2' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- docs/_build/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 97b86707e7c6c53d87ad6b9a104da13b60266ac7 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 00:35:26 -0400 Subject: [PATCH 067/116] fix: docker-options:list flag parsing and ci test fixes The :list subcommand had `SetInterspersed(false)` which forced flags to come before positional arguments; in practice users invoke it as `docker-options:list --process X --phase Y`. Drop the non-interspersed setting so flags can appear after the app name. Two existing buildpacks/dockerfile tests appended directly to the legacy `DOCKER_OPTIONS_DEPLOY` file - that path is no longer the source of truth, so switch them to `docker-options:add`. The new migration test invoked `dokku plugin:trigger install` which fans out to every plugin and trips on unrelated permission errors; call the docker-options install binary directly instead. Tighten the report and JSON assertions to match the actual output format (`.` becomes a space in the display key, and JSON values may concatenate options). --- .../src/subcommands/subcommands.go | 1 - tests/unit/docker-options-2.bats | 19 +++++++++---------- tests/unit/docker-options.bats | 12 ++---------- 3 files changed, 11 insertions(+), 21 deletions(-) diff --git a/plugins/docker-options/src/subcommands/subcommands.go b/plugins/docker-options/src/subcommands/subcommands.go index fae642832..43f33bd69 100644 --- a/plugins/docker-options/src/subcommands/subcommands.go +++ b/plugins/docker-options/src/subcommands/subcommands.go @@ -75,7 +75,6 @@ func main() { err = dockeroptions.CommandClear(appName, *processes, phases) case "list": args := flag.NewFlagSet("docker-options:list", flag.ExitOnError) - args.SetInterspersed(false) 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:]) diff --git a/tests/unit/docker-options-2.bats b/tests/unit/docker-options-2.bats index dda98bd92..9f5ec9b08 100644 --- a/tests/unit/docker-options-2.bats +++ b/tests/unit/docker-options-2.bats @@ -166,7 +166,7 @@ teardown() { 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 "Docker options deploy web" assert_output_contains "-p 8080:5000" } @@ -184,18 +184,17 @@ teardown() { echo "status: $status" assert_success assert_output_contains '"docker-options-deploy.web"' - assert_output_contains '"-p 8080:5000"' + assert_output_contains "-p 8080:5000" assert_output_contains '"docker-options-deploy"' - assert_output_contains '"-v /logs:/logs"' + assert_output_contains "-v /logs:/logs" } @test "(docker-options) install migration is idempotent" { - echo '-v /tmp/legacy:/legacy' >"$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" + sudo rm -f "/var/lib/dokku/config/docker-options/--global/migrated-from-files" + sudo rm -rf "/var/lib/dokku/config/docker-options/$TEST_APP" + sudo bash -c "echo '-v /tmp/legacy:/legacy' >\"$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY\"" - rm -f "/var/lib/dokku/config/docker-options/--global/migrated-from-files" - rm -rf "/var/lib/dokku/config/docker-options/$TEST_APP" - - run /bin/bash -c "dokku plugin:trigger install" + run /bin/bash -c "sudo /var/lib/dokku/plugins/enabled/docker-options/install" echo "output: $output" echo "status: $status" assert_success @@ -207,8 +206,8 @@ teardown() { [[ -f "$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY.migrated" ]] [[ ! -f "$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" ]] - echo '-v /tmp/sneaky:/sneaky' >"$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" - run /bin/bash -c "dokku plugin:trigger install" + sudo bash -c "echo '-v /tmp/sneaky:/sneaky' >\"$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY\"" + run /bin/bash -c "sudo /var/lib/dokku/plugins/enabled/docker-options/install" echo "output: $output" assert_success diff --git a/tests/unit/docker-options.bats b/tests/unit/docker-options.bats index 8ea8cf61f..3d802f690 100644 --- a/tests/unit/docker-options.bats +++ b/tests/unit/docker-options.bats @@ -264,11 +264,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 @@ -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 From f728fc8cd7cdfae40c2b6c2e338a5b6da64dc4ad Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 01:10:34 -0400 Subject: [PATCH 068/116] fix: only emit keda fallback when a non-cpu/memory trigger exists Keda 2.17+ rejects ScaledObjects whose spec.fallback is set unless at least one trigger is not a cpu or memory scaler, so unconditionally emitting fallback broke deploys for apps autoscaled on cpu or memory alone. The chart now skips the fallback block when every configured trigger is cpu or memory and keeps the existing behavior otherwise. --- docs/deployment/schedulers/k3s.md | 2 + .../templates/chart/keda-scaled-object.yaml | 8 ++ tests/unit/scheduler-k3s-4.bats | 117 ++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/docs/deployment/schedulers/k3s.md b/docs/deployment/schedulers/k3s.md index c28ac80df..33e55126b 100644 --- a/docs/deployment/schedulers/k3s.md +++ b/docs/deployment/schedulers/k3s.md @@ -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: 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/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 +} From d1213c14a8058a19ba41fbb654d84181cd624310 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 02:33:23 -0400 Subject: [PATCH 069/116] test: relax default-scope assertions and pass env to install The ps plugin auto-adds --restart=on-failure:10 to the default deploy scope on app create, so docker-options:list --phase deploy is never empty for a freshly-created app. Switch the default-scope assertions from exact-match to contains/not-contains so the auto-injected restart line stops causing false failures. The migration test invoked the install binary directly via sudo, but DOKKU_LIB_ROOT is normally exported by the dokku launcher script and isn't set in that subprocess; pass it (and DOKKU_ROOT) explicitly. --- tests/unit/docker-options-2.bats | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/unit/docker-options-2.bats b/tests/unit/docker-options-2.bats index 9f5ec9b08..85edbeeb3 100644 --- a/tests/unit/docker-options-2.bats +++ b/tests/unit/docker-options-2.bats @@ -28,7 +28,7 @@ teardown() { echo "output: $output" echo "status: $status" assert_success - assert_output "" + assert_output_contains "-p 8080:5000" 0 } @test "(docker-options:add) multiple --process flags add to each process" { @@ -54,7 +54,7 @@ teardown() { run /bin/bash -c "dokku docker-options:list $TEST_APP --phase deploy" echo "output: $output" - assert_output "-v /tmp/shared:/shared" + assert_output_contains "-v /tmp/shared:/shared" run /bin/bash -c "dokku docker-options:report $TEST_APP --docker-options-deploy" echo "output: $output" @@ -194,26 +194,26 @@ teardown() { sudo rm -rf "/var/lib/dokku/config/docker-options/$TEST_APP" sudo bash -c "echo '-v /tmp/legacy:/legacy' >\"$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY\"" - run /bin/bash -c "sudo /var/lib/dokku/plugins/enabled/docker-options/install" + run /bin/bash -c "sudo DOKKU_LIB_ROOT=/var/lib/dokku DOKKU_ROOT=$DOKKU_ROOT /var/lib/dokku/plugins/enabled/docker-options/install" 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 "-v /tmp/legacy:/legacy" + assert_output_contains "-v /tmp/legacy:/legacy" [[ -f "$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY.migrated" ]] [[ ! -f "$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" ]] sudo bash -c "echo '-v /tmp/sneaky:/sneaky' >\"$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY\"" - run /bin/bash -c "sudo /var/lib/dokku/plugins/enabled/docker-options/install" + run /bin/bash -c "sudo DOKKU_LIB_ROOT=/var/lib/dokku DOKKU_ROOT=$DOKKU_ROOT /var/lib/dokku/plugins/enabled/docker-options/install" echo "output: $output" assert_success run /bin/bash -c "dokku docker-options:list $TEST_APP --phase deploy" echo "output: $output" - assert_output "-v /tmp/legacy:/legacy" + assert_output_contains "-v /tmp/legacy:/legacy" [[ -f "$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" ]] run /bin/bash -c "cat $DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" @@ -238,7 +238,7 @@ teardown() { run /bin/bash -c "dokku docker-options:list $CLONE_APP --phase deploy" echo "output: $output" - assert_output "-v /logs:/logs" + assert_output_contains "-v /logs:/logs" dokku --force apps:destroy "$CLONE_APP" || true } @@ -259,7 +259,7 @@ teardown() { assert_output "-p 8080:5000" run /bin/bash -c "dokku docker-options:list $RENAMED_APP --phase deploy" - assert_output "-v /logs:/logs" + assert_output_contains "-v /logs:/logs" [[ ! -d "/var/lib/dokku/config/docker-options/$TEST_APP" ]] From 9a795fcf91136c902b3acb4cc94db9fbaebe2e31 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 03:14:17 -0400 Subject: [PATCH 070/116] test: cover migration via go unit test The bats test for migration idempotency invoked the install binary directly via sudo, which kept hitting fresh missing env vars on each iteration (DOKKU_LIB_ROOT, then PLUGIN_PATH, etc). The dokku launcher script exports a chain of vars that the install path reads via common.MustGetEnv, and mirroring that chain in a bats test is fragile. Migration is pure file IO plus a property marker - perfect for a Go unit test that isolates itself with t.TempDir and t.Setenv. The new test covers parsing comments and blank lines, the marker-based no-op on re-run, and the rule that a manually re-created legacy file is left untouched after the marker is set. --- plugins/docker-options/migration_test.go | 148 +++++++++++++++++++++++ tests/unit/docker-options-2.bats | 31 ----- 2 files changed, 148 insertions(+), 31 deletions(-) create mode 100644 plugins/docker-options/migration_test.go 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/tests/unit/docker-options-2.bats b/tests/unit/docker-options-2.bats index 85edbeeb3..f7f7f289f 100644 --- a/tests/unit/docker-options-2.bats +++ b/tests/unit/docker-options-2.bats @@ -189,37 +189,6 @@ teardown() { assert_output_contains "-v /logs:/logs" } -@test "(docker-options) install migration is idempotent" { - sudo rm -f "/var/lib/dokku/config/docker-options/--global/migrated-from-files" - sudo rm -rf "/var/lib/dokku/config/docker-options/$TEST_APP" - sudo bash -c "echo '-v /tmp/legacy:/legacy' >\"$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY\"" - - run /bin/bash -c "sudo DOKKU_LIB_ROOT=/var/lib/dokku DOKKU_ROOT=$DOKKU_ROOT /var/lib/dokku/plugins/enabled/docker-options/install" - 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/legacy:/legacy" - - [[ -f "$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY.migrated" ]] - [[ ! -f "$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" ]] - - sudo bash -c "echo '-v /tmp/sneaky:/sneaky' >\"$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY\"" - run /bin/bash -c "sudo DOKKU_LIB_ROOT=/var/lib/dokku DOKKU_ROOT=$DOKKU_ROOT /var/lib/dokku/plugins/enabled/docker-options/install" - echo "output: $output" - assert_success - - run /bin/bash -c "dokku docker-options:list $TEST_APP --phase deploy" - echo "output: $output" - assert_output_contains "-v /tmp/legacy:/legacy" - - [[ -f "$DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" ]] - run /bin/bash -c "cat $DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" - assert_output_contains "/tmp/sneaky" -} - @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'" From 132f7248413777623b7969d1f32dbb22c70ecea3 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 03:42:10 -0400 Subject: [PATCH 071/116] feat: support resource limits on the build container Adds a `docker-args-process-build` trigger to the resource plugin so limits set via `dokku resource:limit --process-type build APP` are applied during the build phase. Only `build.limit.*` properties are read - defaults do not inherit, since builds typically need more memory than runtime and a leaked tiny default would cause confusing OOM failures. Reservations are never applied at build time. Allowed flags are filtered per builder: herokuish gets cpu, memory, memory-swap, and nvidia-gpu; dockerfile gets memory and memory-swap; pack, nixpacks, railpack, lambda, and null emit nothing because their underlying CLIs do not accept docker run resource flags. The dockerfile builder whitelists the new memory flags and corrects pre-existing typos where `--ssh` mapped to `--platform` and `--ulimit` mapped to `--tag`. --- docs/advanced-usage/resource-management.md | 36 +++++- plugins/builder-dockerfile/builder-build | 39 +++++- plugins/resource/.gitignore | 1 + plugins/resource/Makefile | 2 +- plugins/resource/src/triggers/triggers.go | 4 + plugins/resource/triggers.go | 78 ++++++++++++ tests/unit/resource_3.bats | 131 +++++++++++++++++++++ 7 files changed, 285 insertions(+), 6 deletions(-) create mode 100644 tests/unit/resource_3.bats 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/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/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/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/tests/unit/resource_3.bats b/tests/unit/resource_3.bats new file mode 100644 index 000000000..9c50888aa --- /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 ': | plugn trigger docker-args-process-build $TEST_APP herokuish' + 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 ': | plugn trigger docker-args-process-build $TEST_APP dockerfile' + 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 ': | plugn trigger docker-args-process-build $TEST_APP pack' + echo "output: $output" + echo "status: $status" + assert_success + assert_output_not_contains "--memory" + + run /bin/bash -c ': | plugn trigger docker-args-process-build $TEST_APP lambda' + 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 ': | plugn trigger docker-args-process-build $TEST_APP herokuish' + 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 ': | plugn trigger docker-args-process-build $TEST_APP herokuish' + 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 "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 +} From 1812cfbdebc7474e47a16eb57fb865538ec4bb05 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 05:00:53 -0400 Subject: [PATCH 072/116] test: export PLUGIN_PATH when invoking plugn trigger in subshells --- tests/unit/resource_3.bats | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/unit/resource_3.bats b/tests/unit/resource_3.bats index 9c50888aa..cb2cb48bc 100644 --- a/tests/unit/resource_3.bats +++ b/tests/unit/resource_3.bats @@ -23,7 +23,7 @@ teardown() { echo "status: $status" assert_output "256m" - run /bin/bash -c ': | plugn trigger docker-args-process-build $TEST_APP herokuish' + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" echo "output: $output" echo "status: $status" assert_success @@ -39,7 +39,7 @@ teardown() { echo "status: $status" assert_success - run /bin/bash -c ': | plugn trigger docker-args-process-build $TEST_APP dockerfile' + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH plugn trigger docker-args-process-build $TEST_APP dockerfile < /dev/null" echo "output: $output" echo "status: $status" assert_success @@ -55,13 +55,13 @@ teardown() { echo "status: $status" assert_success - run /bin/bash -c ': | plugn trigger docker-args-process-build $TEST_APP pack' + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH 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 ': | plugn trigger docker-args-process-build $TEST_APP lambda' + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH plugn trigger docker-args-process-build $TEST_APP lambda < /dev/null" echo "output: $output" echo "status: $status" assert_success @@ -74,7 +74,7 @@ teardown() { echo "status: $status" assert_success - run /bin/bash -c ': | plugn trigger docker-args-process-build $TEST_APP herokuish' + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" echo "output: $output" echo "status: $status" assert_success @@ -87,7 +87,7 @@ teardown() { echo "status: $status" assert_success - run /bin/bash -c ': | plugn trigger docker-args-process-build $TEST_APP herokuish' + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" echo "output: $output" echo "status: $status" assert_success @@ -101,7 +101,7 @@ teardown() { echo "status: $status" assert_success - run /bin/bash -c "DOKKU_OMIT_RESOURCE_ARGS=1 plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" + run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH DOKKU_OMIT_RESOURCE_ARGS=1 plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" echo "output: $output" echo "status: $status" assert_success From 63b809f64c9a4a6a5a0e37f8cd880179054b526b Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 04:22:56 -0400 Subject: [PATCH 073/116] feat: pre-validate custom nginx.conf.sigil during core-post-extract Renders the user-supplied nginx.conf.sigil via sigil into a tmp file and runs `nginx -t` against a wrapped copy as soon as the template is extracted from the source tree, so syntactically invalid templates abort the deploy before the build phase runs. Skipped when `proxy-type` is not `nginx`, when `disable-custom-config=true`, or when no custom template was extracted. Closes #7827. --- .../file-formats/nginx-conf-sigil.md | 6 + docs/networking/proxies/nginx.md | 4 +- plugins/nginx-vhosts/core-post-extract | 3 + plugins/nginx-vhosts/functions | 364 ++++++++++++------ tests/unit/nginx-vhosts_15.bats | 60 +++ tests/unit/nginx-vhosts_7.bats | 2 + 6 files changed, 315 insertions(+), 124 deletions(-) create mode 100644 tests/unit/nginx-vhosts_15.bats diff --git a/docs/appendices/file-formats/nginx-conf-sigil.md b/docs/appendices/file-formats/nginx-conf-sigil.md index 8f97fa58f..eeabfce3f 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 (using empty `DOKKU_APP_WEB_LISTENERS` for first deploys), 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. + +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/networking/proxies/nginx.md b/docs/networking/proxies/nginx.md index 2e746c0e7..473f7a408 100644 --- a/docs/networking/proxies/nginx.md +++ b/docs/networking/proxies/nginx.md @@ -119,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 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 fac8a85ee..01d0de72d 100755 --- a/plugins/nginx-vhosts/functions +++ b/plugins/nginx-vhosts/functions @@ -193,6 +193,241 @@ 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" + 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") + 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" >/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 +436,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 +461,6 @@ nginx_build_config() { # setup nginx listen ports plugn trigger ports-configure "$APP" - 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 while read -r PORT_MAP; do @@ -239,21 +471,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,57 +488,8 @@ 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")" - - 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")" - if [[ -z "$DOKKU_APP_LISTENERS" ]] && [[ -z "$PASSED_LISTEN_IP_PORT" ]]; then dokku_log_warn_quiet "No web listeners specified for $APP" fi @@ -343,66 +519,10 @@ nginx_build_config() { 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" + NGINX_TEMPLATE="$CUSTOM_NGINX_TEMPLATE" + NGINX_TEMPLATE_SOURCE="app-supplied" fi - 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 - 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 @@ -414,12 +534,10 @@ nginx_build_config() { 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 - else - SIGIL_PARAMS+=(DOKKU_APP_WEB_LISTENERS="") fi 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" 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_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" } From ec7841a07bc6338925e3940a1a64142997d703d6 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 05:12:05 -0400 Subject: [PATCH 074/116] fix: inject placeholder listener for pre-validation upstream blocks When pre-validating a custom nginx.conf.sigil before the build phase, no app listeners exist yet on first deploys. Templates that emit `proxy_pass http://app-port` while gating the matching upstream block on `DOKKU_APP_WEB_LISTENERS` render an undefined upstream, causing `nginx -t` to fail with "host not found in upstream". Pre-validation now passes a `127.0.0.1:5000` placeholder for `DOKKU_APP_WEB_LISTENERS` so the upstream block emits a static server entry and the template can be validated for syntax without depending on live listeners. --- docs/appendices/file-formats/nginx-conf-sigil.md | 2 +- plugins/nginx-vhosts/functions | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/appendices/file-formats/nginx-conf-sigil.md b/docs/appendices/file-formats/nginx-conf-sigil.md index eeabfce3f..3cb67eecf 100644 --- a/docs/appendices/file-formats/nginx-conf-sigil.md +++ b/docs/appendices/file-formats/nginx-conf-sigil.md @@ -4,6 +4,6 @@ The `nginx.conf.sigil` file is used to configure the nginx server for an applica ## 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 (using empty `DOKKU_APP_WEB_LISTENERS` for first deploys), 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. +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/plugins/nginx-vhosts/functions b/plugins/nginx-vhosts/functions index 01d0de72d..f15556cbf 100755 --- a/plugins/nginx-vhosts/functions +++ b/plugins/nginx-vhosts/functions @@ -197,6 +197,7 @@ 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 @@ -361,6 +362,11 @@ fn-nginx-vhosts-render-template() { 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 @@ -412,7 +418,7 @@ fn-nginx-vhosts-pre-validate-custom-template() { dokku_log_info1 "Pre-validating custom nginx.conf.sigil" - if ! fn-nginx-vhosts-render-template "$APP" "$PID_TEMPLATE" "$TMP_RENDERED" >/dev/null; then + 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" From d88e8f137f5632d997124a00ac47309ec4e54cfa Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 05:36:04 -0400 Subject: [PATCH 075/116] fix: skip retiring images still in use by app containers When ps:rebuild runs against an image-based deploy via git:from-image, the resulting image often shares the same SHA as the previous deployment, so retiring the old container's image would target the live image of the new container. The retirement is now skipped when another running container of the same app still references the image, and the cron retire loop self-heals previously stuck entries the next time it encounters them. --- docs/appendices/0.38.0-migration-guide.md | 1 + .../scheduler-docker-local/internal-functions | 38 ++++++++++++++++ .../scheduler-register-retired | 9 +++- tests/unit/scheduler-docker-local.bats | 44 +++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/docs/appendices/0.38.0-migration-guide.md b/docs/appendices/0.38.0-migration-guide.md index 4c809cf6e..5debbd56f 100644 --- a/docs/appendices/0.38.0-migration-guide.md +++ b/docs/appendices/0.38.0-migration-guide.md @@ -19,6 +19,7 @@ - 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. ### TLS handshake behavior change diff --git a/plugins/scheduler-docker-local/internal-functions b/plugins/scheduler-docker-local/internal-functions index d0cfdb061..dd34eecbe 100755 --- a/plugins/scheduler-docker-local/internal-functions +++ b/plugins/scheduler-docker-local/internal-functions @@ -243,6 +243,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 +278,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-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/tests/unit/scheduler-docker-local.bats b/tests/unit/scheduler-docker-local.bats index 1260c127e..d51d86ae9 100644 --- a/tests/unit/scheduler-docker-local.bats +++ b/tests/unit/scheduler-docker-local.bats @@ -264,3 +264,47 @@ teardown() { 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 "test -f /var/lib/dokku/data/scheduler-docker-local/dead-images && grep -c \"$running_image\" /var/lib/dokku/data/scheduler-docker-local/dead-images || echo 0" + echo "output: $output" + echo "status: $status" + assert_output "0" + + run /bin/bash -c "docker container inspect $TEST_APP.web.1 --format '{{.State.Status}}'" + echo "output: $output" + echo "status: $status" + assert_output "running" +} From effa9d37cf26148161fb1ccfa508ab5d931c856e Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 04:39:02 -0400 Subject: [PATCH 076/116] fix: add launcher entrypoint for CNB images on dokku run and cron:run The scheduler-run script classified CNB-based images as `herokuish` because `is_image_herokuish_based` returns true for them, which caused the `docker-args-process-run` trigger for builder-pack to skip injecting `--entrypoint launcher`. Without that flag the container fell back to the image entrypoint (`/cnb/process/web`) and dropped the user-supplied arguments. Mirror the deploy-side detection so CNB images set `IMAGE_SOURCE_TYPE=pack`, allowing the launcher entrypoint to be added. --- plugins/scheduler-docker-local/scheduler-run | 5 ++- tests/apps/python/app-cnb-cron-procfile.json | 21 ++++++++++ tests/unit/builder-pack.bats | 44 ++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 tests/apps/python/app-cnb-cron-procfile.json diff --git a/plugins/scheduler-docker-local/scheduler-run b/plugins/scheduler-docker-local/scheduler-run index 658beff00..90327e49e 100755 --- a/plugins/scheduler-docker-local/scheduler-run +++ b/plugins/scheduler-docker-local/scheduler-run @@ -73,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" @@ -96,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 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/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" +} From 3a7d74cdaaa72d376d8db9f059278918d1342db8 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 05:48:43 -0400 Subject: [PATCH 077/116] fix: ensure leading space when injecting --entrypoint launcher The trigger output started with `--entrypoint` and relied on the caller to provide a trailing space. `scheduler-deploy-process-container` does that, but `scheduler-run` does not when `DOKKU_TRACE` is unset, so the flag concatenated onto the previous arg as `--env=KEY--entrypoint`, leaving `launcher` as the image positional argument and causing `docker container create` to fail with `Unable to find image 'launcher:latest'`. Match the leading-space convention used by the other docker-args triggers (`config/docker-args-run`, `builder-herokuish/docker-args-run`). --- plugins/builder-pack/docker-args-process-deploy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From f4a1559edc1362eab6e34d483e541ab12dc67acf Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 06:09:07 -0400 Subject: [PATCH 078/116] chore: bump dokku/netrc to v0.11.0 The `git:auth` test in `tests/unit/git_3.bats` verifies the written entry with `netrc get --netrc-file ${DOKKU_ROOT}/.netrc github.com`, but the `--netrc-file` flag was added in netrc v0.11.0 and CI was still pinned to v0.10.3, so the assertion ran against root's `~/.netrc` and exited 1. --- contrib/dependencies.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contrib/dependencies.json b/contrib/dependencies.json index 040b4f01b..12a04aeab 100644 --- a/contrib/dependencies.json +++ b/contrib/dependencies.json @@ -26,10 +26,10 @@ }, { "name": "netrc", - "version": "0.10.3", + "version": "0.11.0", "urls": { - "amd64": "https://github.com/dokku/netrc/releases/download/v0.10.3/netrc-linux-amd64", - "arm64": "https://github.com/dokku/netrc/releases/download/v0.10.3/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" } }, { From 7791a26e98184e72dafa646d75681d35fad6a8be Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 08:11:47 -0400 Subject: [PATCH 079/116] test: pass plugin and dokku root paths to plugn invocations --- tests/unit/resource_3.bats | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/unit/resource_3.bats b/tests/unit/resource_3.bats index cb2cb48bc..687c1a512 100644 --- a/tests/unit/resource_3.bats +++ b/tests/unit/resource_3.bats @@ -23,7 +23,7 @@ teardown() { echo "status: $status" assert_output "256m" - run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" + 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 @@ -39,7 +39,7 @@ teardown() { echo "status: $status" assert_success - run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH plugn trigger docker-args-process-build $TEST_APP dockerfile < /dev/null" + 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 @@ -55,13 +55,13 @@ teardown() { echo "status: $status" assert_success - run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH plugn trigger docker-args-process-build $TEST_APP pack < /dev/null" + 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 plugn trigger docker-args-process-build $TEST_APP lambda < /dev/null" + 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 @@ -74,7 +74,7 @@ teardown() { echo "status: $status" assert_success - run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" + 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 @@ -87,7 +87,7 @@ teardown() { echo "status: $status" assert_success - run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" + 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 @@ -101,7 +101,7 @@ teardown() { echo "status: $status" assert_success - run /bin/bash -c "PLUGIN_PATH=$PLUGIN_PATH DOKKU_OMIT_RESOURCE_ARGS=1 plugn trigger docker-args-process-build $TEST_APP herokuish < /dev/null" + 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 From ab804c678afc90dd8ec7e19b67c50c398f6d4324 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 08:19:54 -0400 Subject: [PATCH 080/116] test: assert dead-images grep result instead of count The previous assertion piped grep -c through `|| echo 0`, which printed an extra `0` when the file existed without matches because grep -c emits the count of `0` and exits 1, triggering the fallback. --- tests/unit/scheduler-docker-local.bats | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/scheduler-docker-local.bats b/tests/unit/scheduler-docker-local.bats index d51d86ae9..88bafef00 100644 --- a/tests/unit/scheduler-docker-local.bats +++ b/tests/unit/scheduler-docker-local.bats @@ -298,10 +298,10 @@ teardown() { assert_success assert_output_contains "has running containers, skipping rm" 0 - run /bin/bash -c "test -f /var/lib/dokku/data/scheduler-docker-local/dead-images && grep -c \"$running_image\" /var/lib/dokku/data/scheduler-docker-local/dead-images || echo 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 "0" + assert_output "" run /bin/bash -c "docker container inspect $TEST_APP.web.1 --format '{{.State.Status}}'" echo "output: $output" From 3267cb2ac8244025e481fd8384718c44578913c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:24:21 +0000 Subject: [PATCH 081/116] chore(deps): bump github.com/traefik/traefik/v2 Bumps [github.com/traefik/traefik/v2](https://github.com/traefik/traefik) from 2.11.43 to 2.11.44. - [Release notes](https://github.com/traefik/traefik/releases) - [Changelog](https://github.com/traefik/traefik/blob/v2.11.44/CHANGELOG.md) - [Commits](https://github.com/traefik/traefik/compare/v2.11.43...v2.11.44) --- updated-dependencies: - dependency-name: github.com/traefik/traefik/v2 dependency-version: 2.11.44 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- plugins/scheduler-k3s/go.mod | 4 ++-- plugins/scheduler-k3s/go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/scheduler-k3s/go.mod b/plugins/scheduler-k3s/go.mod index 142754e2a..f776e3a6d 100644 --- a/plugins/scheduler-k3s/go.mod +++ b/plugins/scheduler-k3s/go.mod @@ -23,7 +23,7 @@ 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 @@ -68,7 +68,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 diff --git a/plugins/scheduler-k3s/go.sum b/plugins/scheduler-k3s/go.sum index 4c79608a2..bc02e8ec6 100644 --- a/plugins/scheduler-k3s/go.sum +++ b/plugins/scheduler-k3s/go.sum @@ -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= @@ -336,8 +336,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= From 82829813618a023a79affbab5249d86fbf5d2b87 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 04:47:15 -0400 Subject: [PATCH 082/116] feat: accept --global on :report subcommands Every `:report` subcommand now recognizes `--global` as a scope selector that limits the report to globally-configured properties, including in JSON form via `--global --format json`. Previously this combination was rejected because `--global` was treated as an info flag, conflicting with `--format`. The shared `common.ParseReportArgs` helper now returns a `ReportArgs` struct exposing the parsed scope; each Go and bash report selects a global-only flag map when scope is global, and skips per-app verification. --- docs/appendices/0.38.0-migration-guide.md | 1 + plugins/app-json/report.go | 21 +++- .../app-json/src/subcommands/subcommands.go | 9 +- plugins/apps/report.go | 23 ++-- plugins/apps/src/subcommands/subcommands.go | 9 +- plugins/builder-dockerfile/internal-functions | 50 ++++++-- plugins/builder-herokuish/internal-functions | 50 ++++++-- plugins/builder-lambda/internal-functions | 50 ++++++-- plugins/builder-nixpacks/internal-functions | 50 ++++++-- plugins/builder-pack/internal-functions | 50 ++++++-- plugins/builder-railpack/internal-functions | 50 ++++++-- plugins/builder/report.go | 30 +++-- .../builder/src/subcommands/subcommands.go | 9 +- plugins/buildpacks/report.go | 23 ++-- .../buildpacks/src/subcommands/subcommands.go | 9 +- plugins/caddy-vhosts/command-functions | 60 ++++++--- plugins/certs/internal-functions | 56 ++++++--- plugins/checks/internal-functions | 54 ++++++--- plugins/common/common.go | 20 ++- plugins/common/common_test.go | 78 ++++++++++++ plugins/common/functions | 59 +++++++++ plugins/cron/report.go | 37 ++++-- plugins/cron/src/subcommands/subcommands.go | 9 +- plugins/docker-options/report.go | 19 ++- .../src/subcommands/subcommands.go | 9 +- plugins/domains/internal-functions | 32 +++-- plugins/git/internal-functions | 77 ++++++------ plugins/haproxy-vhosts/command-functions | 35 ++++-- plugins/logs/report.go | 36 ++++-- plugins/logs/src/subcommands/subcommands.go | 9 +- plugins/network/report.go | 53 +++++--- .../network/src/subcommands/subcommands.go | 9 +- plugins/nginx-vhosts/command-functions | 56 ++++----- plugins/openresty-vhosts/command-functions | 114 +++++++++++------- plugins/ports/report.go | 17 ++- plugins/ports/src/subcommands/subcommands.go | 9 +- plugins/proxy/report.go | 23 ++-- plugins/proxy/src/subcommands/subcommands.go | 9 +- plugins/ps/report.go | 48 +++++--- plugins/ps/src/subcommands/subcommands.go | 9 +- plugins/registry/report.go | 39 +++--- .../registry/src/subcommands/subcommands.go | 9 +- plugins/resource/resource.go | 6 +- .../resource/src/subcommands/subcommands.go | 9 +- .../scheduler-docker-local/internal-functions | 35 ++++-- plugins/scheduler-k3s/report.go | 81 ++++++++----- .../src/subcommands/subcommands.go | 9 +- plugins/scheduler/report.go | 21 +++- .../scheduler/src/subcommands/subcommands.go | 9 +- plugins/storage/report.go | 19 ++- .../storage/src/subcommands/subcommands.go | 9 +- plugins/traefik-vhosts/command-functions | 49 ++++---- tests/unit/builder-dockerfile.bats | 25 ++++ tests/unit/builder.bats | 34 ++++++ tests/unit/certs.bats | 14 +++ tests/unit/checks.bats | 35 ++++++ tests/unit/cron.bats | 22 ++++ tests/unit/domains.bats | 19 +++ tests/unit/git_3.bats | 28 +++++ tests/unit/network.bats | 35 ++++++ tests/unit/nginx-vhosts_1.bats | 19 +++ 61 files changed, 1379 insertions(+), 518 deletions(-) diff --git a/docs/appendices/0.38.0-migration-guide.md b/docs/appendices/0.38.0-migration-guide.md index 5debbd56f..7a7eb618e 100644 --- a/docs/appendices/0.38.0-migration-guide.md +++ b/docs/appendices/0.38.0-migration-guide.md @@ -20,6 +20,7 @@ - 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. ### TLS handshake behavior change 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/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/subcommands/subcommands.go b/plugins/apps/src/subcommands/subcommands.go index 802d0e1ed..29c0fe4c9 100644 --- a/plugins/apps/src/subcommands/subcommands.go +++ b/plugins/apps/src/subcommands/subcommands.go @@ -66,11 +66,14 @@ 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) 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/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/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/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/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/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/internal-functions b/plugins/checks/internal-functions index 6d55ba45b..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#*: }")" diff --git a/plugins/common/common.go b/plugins/common/common.go index 21847aa7e..a3ec11acd 100644 --- a/plugins/common/common.go +++ b/plugins/common/common.go @@ -713,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 { @@ -728,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 { @@ -736,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/functions b/plugins/common/functions index 59ea62fc6..5bcec750a 100755 --- a/plugins/common/functions +++ b/plugins/common/functions @@ -958,3 +958,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/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/report.go b/plugins/docker-options/report.go index 8ea166cf3..656ff92a3 100644 --- a/plugins/docker-options/report.go +++ b/plugins/docker-options/report.go @@ -12,14 +12,21 @@ import ( // 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 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{ - "--docker-options-build": reportBuildOptions, - "--docker-options-deploy": reportDeployOptions, - "--docker-options-run": reportRunOptions, + 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) diff --git a/plugins/docker-options/src/subcommands/subcommands.go b/plugins/docker-options/src/subcommands/subcommands.go index 43f33bd69..7eedc7241 100644 --- a/plugins/docker-options/src/subcommands/subcommands.go +++ b/plugins/docker-options/src/subcommands/subcommands.go @@ -84,11 +84,14 @@ func main() { case "report": args := flag.NewFlagSet("docker-options:report", flag.ExitOnError) format := args.String("format", "stdout", "format: [ stdout | json ]") - osArgs, infoFlag, flagErr := common.ParseReportArgs("docker-options", os.Args[2:]) + reportArgs, flagErr := common.ParseReportArgs("docker-options", os.Args[2:]) if flagErr == nil { - args.Parse(osArgs) + args.Parse(reportArgs.OSArgs) appName := args.Arg(0) - err = dockeroptions.CommandReport(appName, *format, infoFlag) + if reportArgs.IsGlobal { + appName = "--global" + } + err = dockeroptions.CommandReport(appName, *format, reportArgs.InfoFlag) } else { err = flagErr } 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/git/internal-functions b/plugins/git/internal-functions index 42b4e71f5..4ef9e1272 100755 --- a/plugins/git/internal-functions +++ b/plugins/git/internal-functions @@ -316,25 +316,14 @@ cmd-git-report() { declare desc="displays a git report for one or more apps" declare cmd="git:report" [[ "$1" == "$cmd" ]] && shift 1 - local FORMAT="stdout" - local args=() + fn-report-parse-args "$@" + set -- "${REPORT_ARGS[@]}" - while [[ $# -gt 0 ]]; do - case "$1" in - --format) - FORMAT="$2" - shift 2 - ;; - *) - args+=("$1") - shift - ;; - esac - done + declare APP="${1:-}" INFO_FLAG="${2:-}" - declare APP="${args[0]:-}" INFO_FLAG="${args[1]:-}" - - if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then + if [[ "$REPORT_IS_GLOBAL" == "true" ]]; then + APP="--global" + elif [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then INFO_FLAG="$APP" APP="" fi @@ -343,12 +332,14 @@ 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" "$FORMAT" | tee || true + cmd-git-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true done else - cmd-git-report-single "$APP" "$INFO_FLAG" "$FORMAT" + cmd-git-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" fi } @@ -358,35 +349,37 @@ cmd-git-report-single() { 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")" - ) - - if [[ "$FORMAT" != "stdout" ]] && [[ -n "$INFO_FLAG" ]]; then - dokku_log_fail "--format flag cannot be specified when specifying an info flag" + 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 - local json_output="{}" - for flag in "${flag_map[@]}"; do - local key value - key="$(echo "$flag" | cut -d':' -f1 | sed 's/^--git-//')" - 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-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/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/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/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 5efbd339c..b75eb711d 100755 --- a/plugins/nginx-vhosts/command-functions +++ b/plugins/nginx-vhosts/command-functions @@ -10,25 +10,14 @@ cmd-nginx-report() { declare desc="displays a nginx report for one or more apps" declare cmd="nginx:report" [[ "$1" == "$cmd" ]] && shift 1 - local FORMAT="stdout" - local args=() + fn-report-parse-args "$@" + set -- "${REPORT_ARGS[@]}" - while [[ $# -gt 0 ]]; do - case "$1" in - --format) - FORMAT="$2" - shift 2 - ;; - *) - args+=("$1") - shift - ;; - esac - done + declare APP="${1:-}" INFO_FLAG="${2:-}" - declare APP="${args[0]:-}" INFO_FLAG="${args[1]:-}" - - if [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then + if [[ "$REPORT_IS_GLOBAL" == "true" ]]; then + APP="--global" + elif [[ -n "$APP" ]] && [[ "$APP" == --* ]]; then INFO_FLAG="$APP" APP="" fi @@ -37,12 +26,14 @@ 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" "$FORMAT" | tee || true + cmd-nginx-report-single "$app" "$INFO_FLAG" "$REPORT_FORMAT" | tee || true done else - cmd-nginx-report-single "$APP" "$INFO_FLAG" "$FORMAT" + cmd-nginx-report-single "$APP" "$INFO_FLAG" "$REPORT_FORMAT" fi } @@ -51,7 +42,9 @@ cmd-nginx-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=( "--nginx-access-log-format: $(fn-nginx-access-log-format "$APP")" "--nginx-computed-access-log-format: $(fn-nginx-computed-access-log-format "$APP")" @@ -146,24 +139,23 @@ cmd-nginx-report-single() { "--nginx-global-x-forwarded-ssl: $(fn-nginx-global-x-forwarded-ssl "$APP")" ) - if [[ "$FORMAT" != "stdout" ]] && [[ -n "$INFO_FLAG" ]]; then - dokku_log_fail "--format flag cannot be specified when specifying an info flag" + if [[ "$APP" == "--global" ]]; then + fn-report-filter-global flag_map 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/^--nginx-//')" - 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-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#*: }")" 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/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/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 11498b8b0..07b1a5655 100644 --- a/plugins/proxy/src/subcommands/subcommands.go +++ b/plugins/proxy/src/subcommands/subcommands.go @@ -48,11 +48,14 @@ 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) diff --git a/plugins/ps/report.go b/plugins/ps/report.go index a95dca567..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{} 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/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/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/scheduler-docker-local/internal-functions b/plugins/scheduler-docker-local/internal-functions index dd34eecbe..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#*: }")" 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/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/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/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/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/subcommands/subcommands.go b/plugins/storage/src/subcommands/subcommands.go index 87f00c831..3e051c341 100644 --- a/plugins/storage/src/subcommands/subcommands.go +++ b/plugins/storage/src/subcommands/subcommands.go @@ -40,11 +40,14 @@ func main() { 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) 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/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.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/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 138b389c1..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" diff --git a/tests/unit/cron.bats b/tests/unit/cron.bats index 5eb2bc34a..c9f36e471 100644 --- a/tests/unit/cron.bats +++ b/tests/unit/cron.bats @@ -102,6 +102,28 @@ teardown() { assert_output_contains "python3 task.py daily" } +@test "(cron:report) --global --format json" { + run /bin/bash -c "dokku cron:set --global mailto admin@example.com" + echo "output: $output" + echo "status: $status" + assert_success + + 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" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku cron:set --global mailto" + 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/domains.bats b/tests/unit/domains.bats index 577239b01..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" diff --git a/tests/unit/git_3.bats b/tests/unit/git_3.bats index 21a7e06bb..a924b99ea 100644 --- a/tests/unit/git_3.bats +++ b/tests/unit/git_3.bats @@ -831,3 +831,31 @@ teardown() { 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 "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 166ddd084..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" diff --git a/tests/unit/nginx-vhosts_1.bats b/tests/unit/nginx-vhosts_1.bats index cd038bef8..6d11422dd 100644 --- a/tests/unit/nginx-vhosts_1.bats +++ b/tests/unit/nginx-vhosts_1.bats @@ -62,6 +62,25 @@ teardown() { 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" From b28e4e552bdda45ced03a5da30d422b2ebe21b41 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 07:08:26 -0400 Subject: [PATCH 083/116] test: align git:report --global assertion with rendered key --- tests/unit/git_3.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/git_3.bats b/tests/unit/git_3.bats index a924b99ea..064d8e9a1 100644 --- a/tests/unit/git_3.bats +++ b/tests/unit/git_3.bats @@ -838,7 +838,7 @@ teardown() { echo "status: $status" assert_success assert_output_contains "global git information" - assert_output_contains "Global deploy branch" + assert_output_contains "Git global deploy branch" } @test "(git:report) --global --format json" { From 0f78f81d715662a0e889f2cd8f9c0795a824097d Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 09:51:53 -0400 Subject: [PATCH 084/116] test: drop cron:set in cron:report --global test The set step relied on scheduler-cron-write accepting `--global` as the appName, which the scheduler-k3s plugin trigger does not. --- tests/unit/cron.bats | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/unit/cron.bats b/tests/unit/cron.bats index c9f36e471..c579d4496 100644 --- a/tests/unit/cron.bats +++ b/tests/unit/cron.bats @@ -103,22 +103,18 @@ teardown() { } @test "(cron:report) --global --format json" { - run /bin/bash -c "dokku cron:set --global mailto admin@example.com" - echo "output: $output" - echo "status: $status" - assert_success - 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" + 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:set --global mailto" + run /bin/bash -c "dokku cron:report --global" echo "output: $output" echo "status: $status" assert_success From 2f1268172b680d0e8aee274926d0024de7790439 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:14:23 +0000 Subject: [PATCH 085/116] chore(deps): bump github.com/onsi/gomega in /plugins/common Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.39.1 to 1.40.0. - [Release notes](https://github.com/onsi/gomega/releases) - [Changelog](https://github.com/onsi/gomega/blob/master/CHANGELOG.md) - [Commits](https://github.com/onsi/gomega/compare/v1.39.1...v1.40.0) --- updated-dependencies: - dependency-name: github.com/onsi/gomega dependency-version: 1.40.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- plugins/common/go.mod | 3 ++- plugins/common/go.sum | 28 +++++++++++----------------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/plugins/common/go.mod b/plugins/common/go.mod index d7e100582..440cb2d79 100644 --- a/plugins/common/go.mod +++ b/plugins/common/go.mod @@ -7,7 +7,7 @@ require ( 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= From 579481e76b2b1081f87bc09a14f0095e0ea41240 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 12:16:58 -0400 Subject: [PATCH 086/116] chore: bump go modules --- plugins/app-json/go.mod | 2 +- plugins/app-json/go.sum | 4 ++-- plugins/apps/go.sum | 4 ++-- plugins/builder/go.sum | 4 ++-- plugins/buildpacks/go.mod | 2 +- plugins/buildpacks/go.sum | 27 ++++++++++----------------- plugins/config/go.mod | 2 +- plugins/config/go.sum | 27 ++++++++++----------------- plugins/cron/go.sum | 4 ++-- plugins/docker-options/go.sum | 4 ++-- plugins/logs/go.sum | 4 ++-- plugins/network/go.sum | 4 ++-- plugins/nginx-vhosts/go.sum | 4 ++-- plugins/ports/go.sum | 4 ++-- plugins/proxy/go.sum | 4 ++-- plugins/ps/go.sum | 4 ++-- plugins/registry/go.sum | 4 ++-- plugins/repo/go.sum | 4 ++-- plugins/resource/go.sum | 4 ++-- plugins/scheduler-docker-local/go.sum | 4 ++-- plugins/scheduler-k3s/go.sum | 4 ++-- plugins/scheduler/go.sum | 4 ++-- plugins/storage/go.mod | 2 +- plugins/storage/go.sum | 27 ++++++++++----------------- 24 files changed, 68 insertions(+), 89 deletions(-) diff --git a/plugins/app-json/go.mod b/plugins/app-json/go.mod index a201928dc..86f70509d 100644 --- a/plugins/app-json/go.mod +++ b/plugins/app-json/go.mod @@ -5,6 +5,7 @@ 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/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/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/buildpacks/go.mod b/plugins/buildpacks/go.mod index 009d0db77..2a72d8ec6 100644 --- a/plugins/buildpacks/go.mod +++ b/plugins/buildpacks/go.mod @@ -5,7 +5,7 @@ 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.39.1 + github.com/onsi/gomega v1.40.0 github.com/spf13/pflag v1.0.10 ) diff --git a/plugins/buildpacks/go.sum b/plugins/buildpacks/go.sum index 10b277826..2477a51c0 100644 --- a/plugins/buildpacks/go.sum +++ b/plugins/buildpacks/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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU 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/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= @@ -54,8 +50,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= @@ -67,10 +61,9 @@ 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= k8s.io/utils v0.0.0-20240102154912-e7106e64919e h1:eQ/4ljkx21sObifjzXwlPKpdGLrCfRziVtos3ofG/sQ= diff --git a/plugins/config/go.mod b/plugins/config/go.mod index 9053863d4..0eaeea7b2 100644 --- a/plugins/config/go.mod +++ b/plugins/config/go.mod @@ -5,7 +5,7 @@ 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/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/docker-options/go.sum b/plugins/docker-options/go.sum index c97fc9665..e973ec8a5 100644 --- a/plugins/docker-options/go.sum +++ b/plugins/docker-options/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/logs/go.sum b/plugins/logs/go.sum index ae82beeb5..36fdc8811 100644 --- a/plugins/logs/go.sum +++ b/plugins/logs/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/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/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/ports/go.sum b/plugins/ports/go.sum index c97fc9665..e973ec8a5 100644 --- a/plugins/ports/go.sum +++ b/plugins/ports/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/proxy/go.sum b/plugins/proxy/go.sum index c97fc9665..e973ec8a5 100644 --- a/plugins/proxy/go.sum +++ b/plugins/proxy/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/ps/go.sum b/plugins/ps/go.sum index 61cae65f1..0cd75d307 100644 --- a/plugins/ps/go.sum +++ b/plugins/ps/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/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/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/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/scheduler-docker-local/go.sum b/plugins/scheduler-docker-local/go.sum index 3de658ee0..fd1216a63 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= diff --git a/plugins/scheduler-k3s/go.sum b/plugins/scheduler-k3s/go.sum index bb7f051f4..6721d3746 100644 --- a/plugins/scheduler-k3s/go.sum +++ b/plugins/scheduler-k3s/go.sum @@ -252,8 +252,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= 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.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/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= 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/storage/go.mod b/plugins/storage/go.mod index 63846b1ad..56e6555b4 100644 --- a/plugins/storage/go.mod +++ b/plugins/storage/go.mod @@ -5,7 +5,7 @@ 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 ) diff --git a/plugins/storage/go.sum b/plugins/storage/go.sum index f356a46b2..ed40f8ecc 100644 --- a/plugins/storage/go.sum +++ b/plugins/storage/go.sum @@ -1,35 +1,29 @@ -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= 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 +34,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 +46,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 +57,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= From ef9bdc037990ea3bb6efa265d5c638bd08b749ac Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 09:46:29 -0400 Subject: [PATCH 087/116] fix: split env config and image pull secret into separate helm releases Bundling these Secrets in the app helm chart caused two bugs in the scheduler-k3s plugin: a chart rollback could delete Secrets that older ReplicaSets still referenced by exact timestamped name (`env-{app}.{ts}` and `ims-{app}.{ts}`), hard-crashing pods until manual intervention; and the strategic-merge `patchMergeKey` on `imagePullSecrets` let stale entries leak into the live Deployment until the list pointed at many nonexistent Secrets. Each Secret now lives in its own helm release with a stable name (`config-{app}` and `pull-secret-{app}`), installed before the app chart on every deploy. The deployment trigger also prunes any leaked `imagePullSecrets` entries from the live Deployment so the next deploy lands on a clean list, and the rename and destroy paths uninstall the new releases (and the previously-leaked TLS release on rename) under the old app name. --- docs/appendices/0.38.0-migration-guide.md | 1 + plugins/scheduler-k3s/config_secret.go | 175 ++++++++++++++++++ plugins/scheduler-k3s/config_secret_test.go | 65 +++++++ plugins/scheduler-k3s/functions.go | 57 ++++++ plugins/scheduler-k3s/functions_test.go | 93 ++++++++++ plugins/scheduler-k3s/k8s.go | 59 ++++++ plugins/scheduler-k3s/pull_secret.go | 170 +++++++++++++++++ plugins/scheduler-k3s/pull_secret_test.go | 61 ++++++ plugins/scheduler-k3s/template.go | 4 +- .../templates/chart/cron-job.yaml | 2 +- .../templates/chart/deployment.yaml | 2 +- .../templates/chart/image-pull-secret.yaml | 20 -- .../scheduler-k3s/templates/chart/secret.yaml | 19 -- .../templates/config-secret.yaml | 14 ++ .../templates/pull-secret.yaml | 13 ++ plugins/scheduler-k3s/triggers.go | 64 +++++-- tests/unit/scheduler-k3s-secrets-deploy.bats | 161 ++++++++++++++++ tests/unit/scheduler-k3s-secrets.bats | 162 ++++++++++++++++ 18 files changed, 1086 insertions(+), 56 deletions(-) create mode 100644 plugins/scheduler-k3s/config_secret.go create mode 100644 plugins/scheduler-k3s/config_secret_test.go create mode 100644 plugins/scheduler-k3s/functions_test.go create mode 100644 plugins/scheduler-k3s/pull_secret.go create mode 100644 plugins/scheduler-k3s/pull_secret_test.go delete mode 100644 plugins/scheduler-k3s/templates/chart/image-pull-secret.yaml delete mode 100644 plugins/scheduler-k3s/templates/chart/secret.yaml create mode 100644 plugins/scheduler-k3s/templates/config-secret-chart/templates/config-secret.yaml create mode 100644 plugins/scheduler-k3s/templates/pull-secret-chart/templates/pull-secret.yaml create mode 100644 tests/unit/scheduler-k3s-secrets-deploy.bats create mode 100644 tests/unit/scheduler-k3s-secrets.bats diff --git a/docs/appendices/0.38.0-migration-guide.md b/docs/appendices/0.38.0-migration-guide.md index 7a7eb618e..dbf297da8 100644 --- a/docs/appendices/0.38.0-migration-guide.md +++ b/docs/appendices/0.38.0-migration-guide.md @@ -21,6 +21,7 @@ - The `docker-local` scheduler now sends `SIGTERM` to old containers immediately after a successful deploy, rather than waiting `wait-to-retire` seconds before signaling. This matches Heroku's graceful-shutdown contract and lets applications begin draining in-flight work as soon as proxy traffic switches. The `wait-to-retire` grace period and `stop-timeout-seconds` hard-stop continue to apply as before. See the [zero downtime deploys documentation](/docs/deployment/zero-downtime-deploys.md#wait-to-retire) for more details. - The `docker-local` scheduler no longer queues an image for retirement when another running container of the same app still uses it. This fixes the case where a `ps:rebuild` against an image-based deploy (`git:from-image`) produced an identical-SHA image and the `dokku-retire` cron timer would log `Image ... has running containers, skipping rm` on every run. Stuck entries from prior versions are pruned automatically on the next `ps:retire` run. - All `:report` subcommands now accept the `--global` flag, which scopes the report to globally-configured properties. The flag composes with `--format json`, so a JSON report of global properties can be obtained via, for example, `dokku scheduler:report --global --format json`. Previously, combining `--global` with `--format json` was rejected with an "info flag" error, and `--global` on its own was treated as an unknown flag. +- 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. ### TLS handshake behavior change diff --git a/plugins/scheduler-k3s/config_secret.go b/plugins/scheduler-k3s/config_secret.go new file mode 100644 index 000000000..b91f9e4f3 --- /dev/null +++ b/plugins/scheduler-k3s/config_secret.go @@ -0,0 +1,175 @@ +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 { + AppName string `yaml:"app_name"` + 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) +} + +// CreateOrUpdateConfigSecret creates or updates the config env secret helm chart for an app +func CreateOrUpdateConfigSecret(ctx context.Context, appName string, env map[string]string) error { + 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 env { + encodedSecrets[key] = base64.StdEncoding.EncodeToString([]byte(value)) + } + + values := &ConfigSecretValues{ + Global: ConfigSecretGlobalValues{ + AppName: appName, + 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 09490fb75..faaddffca 100644 --- a/plugins/scheduler-k3s/functions.go +++ b/plugins/scheduler-k3s/functions.go @@ -808,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) } 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/k8s.go b/plugins/scheduler-k3s/k8s.go index 2af20e8f3..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" @@ -977,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..af56d8017 --- /dev/null +++ b/plugins/scheduler-k3s/pull_secret.go @@ -0,0 +1,170 @@ +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 { + AppName string `yaml:"app_name"` + 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) +} + +// CreateOrUpdateImagePullSecret creates or updates the image pull secret helm chart for an app +func CreateOrUpdateImagePullSecret(ctx context.Context, appName string, dockerConfigJSON []byte) error { + 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{ + AppName: appName, + Namespace: namespace, + PullSecretBase64: base64.StdEncoding.EncodeToString(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/template.go b/plugins/scheduler-k3s/template.go index b850df321..f58fccc4d 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"` } @@ -425,7 +423,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..fec53e1f2 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 diff --git a/plugins/scheduler-k3s/templates/chart/deployment.yaml b/plugins/scheduler-k3s/templates/chart/deployment.yaml index da68f0f26..2eb5946c0 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 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/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..f93667638 --- /dev/null +++ b/plugins/scheduler-k3s/templates/config-secret-chart/templates/config-secret.yaml @@ -0,0 +1,14 @@ +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 }} + annotations: + dokku.com/managed: "true" +{{- with .Values.global.secrets }} +data: + {{- toYaml . | nindent 2 }} +{{- end }} 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..25da91319 --- /dev/null +++ b/plugins/scheduler-k3s/templates/pull-secret-chart/templates/pull-secret.yaml @@ -0,0 +1,13 @@ +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 }} + annotations: + dokku.com/managed: "true" +data: + .dockerconfigjson: {{ .Values.global.pull_secret_base64 | quote }} diff --git a/plugins/scheduler-k3s/triggers.go b/plugins/scheduler-k3s/triggers.go index f95ccc2c9..92a521d53 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{}, @@ -851,10 +865,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 +935,28 @@ func TriggerSchedulerDeploy(scheduler string, appName string, imageTag string) e } } + if err := CreateOrUpdateConfigSecret(ctx, appName, env.Map()); err != nil { + return fmt.Errorf("Error syncing config secret: %w", err) + } + + if dokkuManagedPullSecret { + if err := CreateOrUpdateImagePullSecret(ctx, appName, dokkuPullSecretBytes); 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 +1434,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 +1755,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/tests/unit/scheduler-k3s-secrets-deploy.bats b/tests/unit/scheduler-k3s-secrets-deploy.bats new file mode 100644 index 000000000..36b693197 --- /dev/null +++ b/tests/unit/scheduler-k3s-secrets-deploy.bats @@ -0,0 +1,161 @@ +#!/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 deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" + 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 +} From 7ba453e588a704f094b05132dac2fdedc393f429 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 12:00:21 -0400 Subject: [PATCH 088/116] fix: thread secret annotations and labels through new helm charts The new config and pull secret helm releases need to honor user-set annotations and labels for `--resource-type secret` so existing scheduler-k3s annotation tests keep passing. The dedicated charts now render `.Values.global.annotations` and `.Values.global.labels` onto their Secret manifest, and the deploy trigger plumbs `SecretAnnotations` and `SecretLabels` from the global annotation/label config. The rollback regression bats test now uses `dokku ps:rebuild` for its second deploy because git push of an unchanged ref is rejected by the dokku remote. --- plugins/scheduler-k3s/config_secret.go | 29 ++++++++++++++----- plugins/scheduler-k3s/pull_secret.go | 23 +++++++++++---- .../templates/config-secret.yaml | 6 ++++ .../templates/pull-secret.yaml | 6 ++++ plugins/scheduler-k3s/triggers.go | 14 +++++++-- tests/unit/scheduler-k3s-secrets-deploy.bats | 7 ++++- 6 files changed, 69 insertions(+), 16 deletions(-) diff --git a/plugins/scheduler-k3s/config_secret.go b/plugins/scheduler-k3s/config_secret.go index b91f9e4f3..c36c6ba31 100644 --- a/plugins/scheduler-k3s/config_secret.go +++ b/plugins/scheduler-k3s/config_secret.go @@ -17,9 +17,11 @@ type ConfigSecretValues struct { // ConfigSecretGlobalValues contains the global values for the config secret chart type ConfigSecretGlobalValues struct { - AppName string `yaml:"app_name"` - Namespace string `yaml:"namespace"` - Secrets map[string]string `yaml:"secrets,omitempty"` + 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 @@ -32,8 +34,17 @@ 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, appName string, env map[string]string) error { +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 @@ -94,15 +105,17 @@ func CreateOrUpdateConfigSecret(ctx context.Context, appName string, env map[str } encodedSecrets := map[string]string{} - for key, value := range env { + for key, value := range input.Env { encodedSecrets[key] = base64.StdEncoding.EncodeToString([]byte(value)) } values := &ConfigSecretValues{ Global: ConfigSecretGlobalValues{ - AppName: appName, - Namespace: namespace, - Secrets: encodedSecrets, + Annotations: input.Annotations, + AppName: appName, + Labels: input.Labels, + Namespace: namespace, + Secrets: encodedSecrets, }, } diff --git a/plugins/scheduler-k3s/pull_secret.go b/plugins/scheduler-k3s/pull_secret.go index af56d8017..47ccb02df 100644 --- a/plugins/scheduler-k3s/pull_secret.go +++ b/plugins/scheduler-k3s/pull_secret.go @@ -17,9 +17,11 @@ type ImagePullSecretValues struct { // ImagePullSecretGlobalValues contains the global values for the image pull secret chart type ImagePullSecretGlobalValues struct { - AppName string `yaml:"app_name"` - Namespace string `yaml:"namespace"` - PullSecretBase64 string `yaml:"pull_secret_base64"` + 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 @@ -32,8 +34,17 @@ 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, appName string, dockerConfigJSON []byte) error { +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 @@ -95,9 +106,11 @@ func CreateOrUpdateImagePullSecret(ctx context.Context, appName string, dockerCo values := &ImagePullSecretValues{ Global: ImagePullSecretGlobalValues{ + Annotations: input.Annotations, AppName: appName, + Labels: input.Labels, Namespace: namespace, - PullSecretBase64: base64.StdEncoding.EncodeToString(dockerConfigJSON), + PullSecretBase64: base64.StdEncoding.EncodeToString(input.DockerConfigJSON), }, } 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 index f93667638..cbb2ed52f 100644 --- a/plugins/scheduler-k3s/templates/config-secret-chart/templates/config-secret.yaml +++ b/plugins/scheduler-k3s/templates/config-secret-chart/templates/config-secret.yaml @@ -6,8 +6,14 @@ metadata: 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 }} 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 index 25da91319..778c491b3 100644 --- a/plugins/scheduler-k3s/templates/pull-secret-chart/templates/pull-secret.yaml +++ b/plugins/scheduler-k3s/templates/pull-secret-chart/templates/pull-secret.yaml @@ -7,7 +7,13 @@ metadata: 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/triggers.go b/plugins/scheduler-k3s/triggers.go index 92a521d53..bc5d406a9 100644 --- a/plugins/scheduler-k3s/triggers.go +++ b/plugins/scheduler-k3s/triggers.go @@ -935,12 +935,22 @@ func TriggerSchedulerDeploy(scheduler string, appName string, imageTag string) e } } - if err := CreateOrUpdateConfigSecret(ctx, appName, env.Map()); err != nil { + 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, appName, dokkuPullSecretBytes); err != nil { + 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 { diff --git a/tests/unit/scheduler-k3s-secrets-deploy.bats b/tests/unit/scheduler-k3s-secrets-deploy.bats index 36b693197..58295bb95 100644 --- a/tests/unit/scheduler-k3s-secrets-deploy.bats +++ b/tests/unit/scheduler-k3s-secrets-deploy.bats @@ -44,7 +44,12 @@ teardown() { echo "status: $status" assert_success - run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" + 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 From 55d7487d66a396a0ac580e163b48ffe13b7ae764 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 09:02:27 -0400 Subject: [PATCH 089/116] fix: split multi-flag input in docker-options Multi-flag inputs (e.g. `--build-arg X=Y --link a --link b`) used to be stored as a single line, which bypassed the per-line filter that drops `--link` and similar flags for dockerfile-based builders. Each `--flag [value]` group is now stored as its own entry, and a `--process` typed after the app name is lifted into the subcommand flag instead of being stored as a docker option. --- docs/advanced-usage/docker-options.md | 17 +- plugins/docker-options/dockeroptions.go | 112 +++++++++++++ plugins/docker-options/dockeroptions_test.go | 156 +++++++++++++++++++ plugins/docker-options/functions.go | 71 +++++++-- plugins/docker-options/go.mod | 1 + plugins/docker-options/go.sum | 10 ++ tests/unit/docker-options.bats | 72 +++++++++ 7 files changed, 424 insertions(+), 15 deletions(-) create mode 100644 plugins/docker-options/dockeroptions_test.go diff --git a/docs/advanced-usage/docker-options.md b/docs/advanced-usage/docker-options.md index 4c3ecaa16..0d70721f2 100644 --- a/docs/advanced-usage/docker-options.md +++ b/docs/advanced-usage/docker-options.md @@ -58,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 @@ -79,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 diff --git a/plugins/docker-options/dockeroptions.go b/plugins/docker-options/dockeroptions.go index b1ed96c2d..8d48d293e 100644 --- a/plugins/docker-options/dockeroptions.go +++ b/plugins/docker-options/dockeroptions.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/dokku/dokku/plugins/common" + "mvdan.cc/sh/v3/shell" ) // DefaultProcessType is the sentinel process-type key used for options that @@ -13,6 +14,117 @@ import ( // Procfile process type). const DefaultProcessType = "_default_" +// 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 + } + + fields, err := shell.Fields(input, func(string) string { return "" }) + if err != nil { + return nil, nil, fmt.Errorf("Unable to parse docker option: %s", err.Error()) + } + + 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 + } + + 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 + } + + 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 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.go b/plugins/docker-options/functions.go index b1a5b99aa..27535f977 100644 --- a/plugins/docker-options/functions.go +++ b/plugins/docker-options/functions.go @@ -15,6 +15,12 @@ import ( // 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 @@ -25,10 +31,17 @@ func CommandAdd(appName string, processes []string, phasesArg string, option str return err } - if option == "" { + 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 } @@ -37,15 +50,24 @@ func CommandAdd(appName string, processes []string, phasesArg string, option str WarnIfProcessNotInProcfile(appName, processType) } - if len(processes) == 0 { - return AddDockerOptionToPhases(appName, phases, option) + 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 + } } - return AddDockerOptionToProcessPhases(appName, processes, phases, option) + return nil } // CommandRemove removes a docker option from the specified phases for an app. -// Process-flag handling matches CommandAdd. +// 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 @@ -56,19 +78,50 @@ func CommandRemove(appName string, processes []string, phasesArg string, option return err } - if option == "" { + 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 } - if len(processes) == 0 { - return RemoveDockerOptionFromPhases(appName, phases, option) + 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 RemoveDockerOptionFromProcessPhases(appName, processes, phases, option) + 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 diff --git a/plugins/docker-options/go.mod b/plugins/docker-options/go.mod index b76b55128..07380bc87 100644 --- a/plugins/docker-options/go.mod +++ b/plugins/docker-options/go.mod @@ -5,6 +5,7 @@ go 1.26.2 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 ( diff --git a/plugins/docker-options/go.sum b/plugins/docker-options/go.sum index e973ec8a5..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,6 +14,10 @@ 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= @@ -30,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= @@ -53,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/tests/unit/docker-options.bats b/tests/unit/docker-options.bats index 3d802f690..e473070cc 100644 --- a/tests/unit/docker-options.bats +++ b/tests/unit/docker-options.bats @@ -362,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 +} From 507df612e8d14b32aabffb24a6ae8a9420d1fb56 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 10:58:08 -0400 Subject: [PATCH 090/116] fix: tidy go.sum for plugins importing docker-options --- plugins/logs/go.mod | 1 + plugins/logs/go.sum | 10 ++++++++++ plugins/ps/go.mod | 1 + plugins/ps/go.sum | 10 ++++++++++ plugins/storage/go.mod | 1 + plugins/storage/go.sum | 8 ++++++-- 6 files changed, 29 insertions(+), 2 deletions(-) diff --git a/plugins/logs/go.mod b/plugins/logs/go.mod index 7c1145d99..beefb8026 100644 --- a/plugins/logs/go.mod +++ b/plugins/logs/go.mod @@ -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 36fdc8811..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,6 +16,10 @@ 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= @@ -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/ps/go.mod b/plugins/ps/go.mod index 6b80746be..29490ea46 100644 --- a/plugins/ps/go.mod +++ b/plugins/ps/go.mod @@ -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/ps/go.sum b/plugins/ps/go.sum index 0cd75d307..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= @@ -14,6 +16,10 @@ 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= @@ -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/storage/go.mod b/plugins/storage/go.mod index 56e6555b4..9713c5570 100644 --- a/plugins/storage/go.mod +++ b/plugins/storage/go.mod @@ -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 ed40f8ecc..f81958888 100644 --- a/plugins/storage/go.sum +++ b/plugins/storage/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= @@ -34,8 +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/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= @@ -62,3 +64,5 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33 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= From 0c08aefc540920c764616c93b6d749af31c3c070 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Tue, 24 Sep 2019 12:14:48 -0400 Subject: [PATCH 091/116] feat: implement build tracking The DOKKU_PID now never gets overwritten except in the case that DOKKU is executed by the sudo user. If the command ends up executing a deploy, then the pid of the `dokku` owned process - which may have been executed via sudo - will be written to the file lock, allowing future commands to interact with the original process. Additionally, the new builds plugin can be used to handle killing a build. --- docs/deployment/builds-management.md | 30 +++++++++++++++++++++++ plugins/builds/commands | 16 +++++++++++++ plugins/builds/internal-functions | 31 ++++++++++++++++++++++++ plugins/builds/plugin.toml | 4 ++++ plugins/builds/subcommands/cancel | 36 ++++++++++++++++++++++++++++ plugins/builds/subcommands/default | 6 +++++ plugins/builds/subcommands/list | 13 ++++++++++ plugins/builds/subcommands/output | 27 +++++++++++++++++++++ plugins/builds/subcommands/report | 15 ++++++++++++ 9 files changed, 178 insertions(+) create mode 100644 docs/deployment/builds-management.md create mode 100755 plugins/builds/commands create mode 100755 plugins/builds/internal-functions create mode 100644 plugins/builds/plugin.toml create mode 100755 plugins/builds/subcommands/cancel create mode 100755 plugins/builds/subcommands/default create mode 100755 plugins/builds/subcommands/list create mode 100755 plugins/builds/subcommands/output create mode 100755 plugins/builds/subcommands/report diff --git a/docs/deployment/builds-management.md b/docs/deployment/builds-management.md new file mode 100644 index 000000000..cf3e779dd --- /dev/null +++ b/docs/deployment/builds-management.md @@ -0,0 +1,30 @@ +# Build Management + +> New as of 0.19.0 + +``` +builds:cancel # Cancel a running build for an app +builds:list # List all running builds +builds:output # Shows build output +builds:report [] [] # Displays a build report for one or more apps +``` + +## Usage + +### Listing running deploys + +### Viewing the status of a deploy + +### Viewing build output for a deploy + +### Canceling a running deploy + +It can be useful to kill a deploy if that deploy does not appear to be progressing, is impacting other apps through system resource utilization, or if a successful deploy will result in app errors. To do so, the `builds:cancel` command can be used: + +```shell +dokku builds:cancel node-js-app +``` + +This command will send a `QUIT` signal to the Process Group ID of the process handling the deploy, and should terminate all processes within that process tree. Finally, it will unlock the deploy so that a new deploy may be immediately invoked. + +> Warning: This may also result in invalid app state depending upon when the app deploy was killed. diff --git a/plugins/builds/commands b/plugins/builds/commands new file mode 100755 index 000000000..bbb121222 --- /dev/null +++ b/plugins/builds/commands @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +[[ " help builds:help " == *" $1 "* ]] || exit "$DOKKU_NOT_IMPLEMENTED_EXIT" +source "$PLUGIN_AVAILABLE_PATH/builds/internal-functions" +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x + +case "$1" in + help | builds:help) + cmd-builds-help "$@" + ;; + + *) + exit "$DOKKU_NOT_IMPLEMENTED_EXIT" + ;; + +esac diff --git a/plugins/builds/internal-functions b/plugins/builds/internal-functions new file mode 100755 index 000000000..1b56fad60 --- /dev/null +++ b/plugins/builds/internal-functions @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x + +fn-builds-help-content() { + declare desc="return logs plugin help content" + cat <, Cancel a running build for an app + builds:list , List all running builds + builds:output , Shows build output + builds:report [] [], Displays a build report for one or more apps +help_content +} + +cmd-builds-help() { + if [[ $1 == "builds:help" ]]; then + echo -e 'Usage: dokku builds[:COMMAND]' + echo '' + echo 'Manage running builds' + echo '' + echo 'Additional commands:' + fn-builds-help-content | sort | column -c2 -t -s, + elif [[ $(ps -o command= $PPID) == *"--all"* ]]; then + fn-builds-help-content + else + cat < Date: Thu, 14 Mar 2024 06:09:15 -0400 Subject: [PATCH 092/116] fix: correct issue with cancel --- plugins/builds/plugin.toml | 2 +- plugins/builds/subcommands/cancel | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/plugins/builds/plugin.toml b/plugins/builds/plugin.toml index 3dae3d778..7dadc67c8 100644 --- a/plugins/builds/plugin.toml +++ b/plugins/builds/plugin.toml @@ -1,4 +1,4 @@ [plugin] description = "dokku core builds plugin" -version = "0.33.6" +version = "0.33.9" [plugin.config] diff --git a/plugins/builds/subcommands/cancel b/plugins/builds/subcommands/cancel index a45d85f99..818905a4c 100755 --- a/plugins/builds/subcommands/cancel +++ b/plugins/builds/subcommands/cancel @@ -8,12 +8,13 @@ cmd-builds-cancel() { declare cmd="builds:cancel" [[ "$1" == "$cmd" ]] && shift 1 declare APP="$1" + local APP_DEPLOY_LOCK_FILE PROCESS_ID PROCESS_GROUP_ID verify_app_name "$APP" local APP_DEPLOY_LOCK_FILE PROCESS_ID PROCESS_GROUP_ID - APP_DEPLOY_LOCK_FILE="$DOKKU_ROOT/$APP/.deploy.lock" + APP_DEPLOY_LOCK_FILE="$DOKKU_LIB_ROOT/data/apps/$APP/.deploy.lock" if [[ ! -f "$APP_DEPLOY_LOCK_FILE" ]]; then - dokku_log_info1 "No matching app deploy found" + dokku_log_info1 "App not currently deploying" return fi @@ -25,12 +26,12 @@ cmd-builds-cancel() { PROCESS_GROUP_ID="$(ps -o pgid= "$PROCESS_ID" || true)" if [[ -z "$PROCESS_ID" ]]; then - dokku_log_info1 "No matching app deploy found" + dokku_log_info1 "Unable to find process group id for app deploy" return fi dokku_log_info1 "Killing app deploy" - kill -quit -- "-${PROCESS_GROUP_ID}" && rm -f "$DOKKU_ROOT/$APP/.deploy.lock" + kill -quit -- "-${PROCESS_GROUP_ID}" && rm -f "$APP_DEPLOY_LOCK_FILE" } -ps_kill_deploy_cmd "$@" +cmd-builds-cancel "$@" \ No newline at end of file From 7d00e2c422d8d4b1ba2becd233b763fa5d0f4792 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 14 Mar 2024 08:00:26 -0400 Subject: [PATCH 093/116] feat: implement builds:output This still needs tightening to ensure we don't trigger this for normal git plugin commands - as well as anything that triggers a deploy - but works in a pinch. --- dokku | 4 ++++ plugins/builds/subcommands/cancel | 2 +- plugins/builds/subcommands/output | 33 +++++++++++++++++++++---------- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/dokku b/dokku index 60caaa6e2..b6912a8e5 100755 --- a/dokku +++ b/dokku @@ -188,6 +188,10 @@ execute_dokku_cmd() { set -- "$PLUGIN_CMD" "$@" fi + if [[ $PLUGIN_NAME =~ git-* ]] || [[ $PLUGIN_NAME =~ git:* ]]; then + exec &> >(tee >(tee | logger -i -t "dokku-${DOKKU_PID}")) + fi + if [[ -x $PLUGIN_ENABLED_PATH/$PLUGIN_NAME/subcommands/default ]]; then "$PLUGIN_ENABLED_PATH/$PLUGIN_NAME/subcommands/default" "$@" implemented=1 diff --git a/plugins/builds/subcommands/cancel b/plugins/builds/subcommands/cancel index 818905a4c..8472feae6 100755 --- a/plugins/builds/subcommands/cancel +++ b/plugins/builds/subcommands/cancel @@ -34,4 +34,4 @@ cmd-builds-cancel() { kill -quit -- "-${PROCESS_GROUP_ID}" && rm -f "$APP_DEPLOY_LOCK_FILE" } -cmd-builds-cancel "$@" \ No newline at end of file +cmd-builds-cancel "$@" diff --git a/plugins/builds/subcommands/output b/plugins/builds/subcommands/output index 4ddb23ecc..ab1453059 100755 --- a/plugins/builds/subcommands/output +++ b/plugins/builds/subcommands/output @@ -7,20 +7,33 @@ cmd-builds-output() { declare desc="shows build output" declare cmd="builds:output" [[ "$1" == "$cmd" ]] && shift 1 - declare APP="$1" + declare APP="$1" DEPLOY_ID="$2" verify_app_name "$APP" - local APP_DEPLOY_LOCK_FILE PROCESS_ID - APP_DEPLOY_LOCK_FILE="$DOKKU_ROOT/$APP/.deploy.lock" - if [[ ! -f "$APP_DEPLOY_LOCK_FILE" ]]; then - dokku_log_info1 "No matching app deploy found" - return + if [[ -z "$DEPLOY_ID" ]] || [[ "$DEPLOY_ID" == "current" ]]; then + local APP_DEPLOY_LOCK_FILE PROCESS_ID + APP_DEPLOY_LOCK_FILE="$DOKKU_ROOT/$APP/.deploy.lock" + if [[ ! -f "$APP_DEPLOY_LOCK_FILE" ]]; then + dokku_log_info1 "App not currently deploying" + return + fi + + DEPLOY_ID="$(cat "$APP_DEPLOY_LOCK_FILE")" + if [[ -z "$DEPLOY_ID" ]]; then + dokku_log_info1 "No matching app deploy found" + return + fi fi - PROCESS_ID="$(cat "$APP_DEPLOY_LOCK_FILE")" - if [[ -z "$PROCESS_ID" ]]; then - dokku_log_info1 "No matching app deploy found" - return + if [[ -z "$DEPLOY_ID" ]]; then + dokku_log_fail "No deploy id specified" + return 1 + fi + + if [[ -x /usr/bin/systemctl ]] || [[ -x /usr/local/bin/systemctl ]]; then + journalctl -n1000 -f -b -a -o cat "SYSLOG_IDENTIFIER=dokku-${DEPLOY_ID}" + else + grep "dokku-${DEPLOY_ID}" /var/log/syslog fi } From aa72794711c0f7414e3904e96e175ca301eda9ec Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 14 Mar 2024 10:34:08 -0400 Subject: [PATCH 094/116] feat: redirect output in every case as appropriate --- dokku | 3 ++- plugins/common/functions | 5 +++++ plugins/git/internal-functions | 18 ++++++++++++++++++ plugins/git/receive-app | 5 +++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/dokku b/dokku index b6912a8e5..067cc550e 100755 --- a/dokku +++ b/dokku @@ -188,7 +188,8 @@ execute_dokku_cmd() { set -- "$PLUGIN_CMD" "$@" fi - if [[ $PLUGIN_NAME =~ git-* ]] || [[ $PLUGIN_NAME =~ git:* ]]; then + if [[ $PLUGIN_NAME =~ git-* ]] || [[ "$PLUGIN_NAME" == "ps:rebuild" ]] || [[ "$PLUGIN_NAME" == "ps:restart" ]] || [[ "$PLUGIN_NAME" == "deploy" ]]; then + export DOKKU_REDIRECT_OUTPUT=true exec &> >(tee >(tee | logger -i -t "dokku-${DOKKU_PID}")) fi diff --git a/plugins/common/functions b/plugins/common/functions index 5bcec750a..27102fc27 100755 --- a/plugins/common/functions +++ b/plugins/common/functions @@ -626,6 +626,11 @@ release_and_deploy() { declare desc="main function for releasing and deploying an app" source "$PLUGIN_AVAILABLE_PATH/config/functions" + if [[ "$DOKKU_REDIRECT_OUTPUT" != "true" ]]; then + export DOKKU_REDIRECT_OUTPUT=true + exec &> >(tee >(tee | logger -i -t "dokku-${DOKKU_PID}")) + fi + local APP="$1" local IMAGE_TAG="${2:-latest}" local IMAGE=$(get_app_image_name "$APP" "$IMAGE_TAG") diff --git a/plugins/git/internal-functions b/plugins/git/internal-functions index 4ef9e1272..30634839b 100755 --- a/plugins/git/internal-functions +++ b/plugins/git/internal-functions @@ -52,6 +52,10 @@ cmd-git-from-archive() { dokku_log_fail "Invalid archive type specified, valid archive types include: tar, tar.gz, zip" fi + if [[ "$DOKKU_REDIRECT_OUTPUT" != "true" ]]; then + export DOKKU_REDIRECT_OUTPUT=true + exec &> >(tee >(tee | logger -i -t "dokku-${DOKKU_PID}")) + fi plugn trigger git-from-archive "$APP" "$ARCHIVE_URL" "$ARCHIVE_TYPE" "$USER_NAME" "$USER_EMAIL" plugn trigger deploy-source-set "$APP" "$ARCHIVE_TYPE" "$ARCHIVE_URL" } @@ -162,6 +166,10 @@ 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" + if [[ "$DOKKU_REDIRECT_OUTPUT" != "true" ]]; then + export DOKKU_REDIRECT_OUTPUT=true + exec &> >(tee >(tee | logger -i -t "dokku-${DOKKU_PID}")) + fi cat | docker load if ! verify_image "$DOCKER_IMAGE"; then @@ -215,6 +223,11 @@ cmd-git-from-image() { export DOKKU_APPS_FORCE_DELETE=1 fi + if [[ "$DOKKU_REDIRECT_OUTPUT" != "true" ]]; then + export DOKKU_REDIRECT_OUTPUT=true + exec &> >(tee >(tee | logger -i -t "dokku-${DOKKU_PID}")) + fi + if ! plugn trigger git-from-image "$APP" "$DOCKER_IMAGE" "$BUILD_DIR" "$USER_NAME" "$USER_EMAIL"; then return 1 fi @@ -263,6 +276,11 @@ 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)" + if [[ "$DOKKU_REDIRECT_OUTPUT" != "true" ]]; then + export DOKKU_REDIRECT_OUTPUT=true + exec &> >(tee >(tee | logger -i -t "dokku-${DOKKU_PID}")) + fi + 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" diff --git a/plugins/git/receive-app b/plugins/git/receive-app index ef7f59af1..78821f835 100755 --- a/plugins/git/receive-app +++ b/plugins/git/receive-app @@ -7,6 +7,11 @@ trigger-git-receive-app() { declare desc="builds the app from the local git repository" declare trigger="receive-app" + if [[ "$DOKKU_REDIRECT_OUTPUT" != "true" ]]; then + export DOKKU_REDIRECT_OUTPUT=true + exec &> >(tee >(tee | logger -i -t "dokku-${DOKKU_PID}")) + fi + git_receive_app "$@" return $? } From 4e87d1f44741aa431438707a43abf8c1aa1bffef Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 14:36:47 -0400 Subject: [PATCH 095/116] feat: add storage entry and attachment types Introduces the Entry and Attachment types that the storage plugin will use as the source of truth for named storage volumes, replacing the colon-delimited mount strings stored under docker-options. Entries persist as JSON under config/storage/entries and validate against scheduler-specific rules; attachments persist via the property list and capture how an app uses an entry. The legacy migration name synthesizer is also added so existing colon-form mounts can converge on a deterministic legacy- entry name. Existing storage commands are unchanged in this commit. --- go.work | 1 + plugins/storage/attachment.go | 221 +++++++++++++++++++++++ plugins/storage/attachment_test.go | 33 ++++ plugins/storage/entry.go | 281 +++++++++++++++++++++++++++++ plugins/storage/entry_test.go | 196 ++++++++++++++++++++ 5 files changed, 732 insertions(+) create mode 100644 plugins/storage/attachment.go create mode 100644 plugins/storage/attachment_test.go create mode 100644 plugins/storage/entry.go create mode 100644 plugins/storage/entry_test.go diff --git a/go.work b/go.work index 814223fc1..bf66069d0 100644 --- a/go.work +++ b/go.work @@ -21,4 +21,5 @@ use ( ./plugins/scheduler ./plugins/scheduler-docker-local ./plugins/scheduler-k3s + ./plugins/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..d6be3e31b --- /dev/null +++ b/plugins/storage/attachment_test.go @@ -0,0 +1,33 @@ +package storage + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +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()) +} diff --git a/plugins/storage/entry.go b/plugins/storage/entry.go new file mode 100644 index 000000000..68ef5d79f --- /dev/null +++ b/plugins/storage/entry.go @@ -0,0 +1,281 @@ +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])?$`) + + // 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"` +} + +// EntriesDirectory returns the directory holding per-entry JSON files. +func EntriesDirectory() string { + root := common.GetenvWithDefault("DOKKU_LIB_ROOT", "/var/lib/dokku") + return filepath.Join(root, "config", "storage", "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) { + return fmt.Errorf("storage entry %q host_path must be absolute, 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..683032008 --- /dev/null +++ b/plugins/storage/entry_test.go @@ -0,0 +1,196 @@ +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()) + + relativePath := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "relative/path"} + Expect(relativePath.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, "config", "storage", "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()) +} From 8a46d891815751a1735f353dce537989fd19c983 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 14:39:59 -0400 Subject: [PATCH 096/116] feat: add storage:create, storage:destroy, storage:info, storage:list-entries Wires the new entry-management commands on top of the Entry type. storage:create accepts both docker-local entries (with idempotent host-directory creation) and k3s entries (which delegate PVC provisioning to a forthcoming scheduler-side storage-create trigger). storage:destroy refuses to remove an entry that any app still mounts. storage:ensure-directory now emits a deprecation warning. storage:mount and storage:unmount keep the existing colon-form behavior so this commit is non-breaking; the named-entry mount form follows in a later commit. --- plugins/storage/Makefile | 2 +- plugins/storage/commands_entries.go | 302 ++++++++++++++++++ plugins/storage/src/commands/commands.go | 6 +- .../storage/src/subcommands/subcommands.go | 68 ++++ plugins/storage/subcommands.go | 6 +- 5 files changed, 381 insertions(+), 3 deletions(-) create mode 100644 plugins/storage/commands_entries.go diff --git a/plugins/storage/Makefile b/plugins/storage/Makefile index 754299f43..c719dde7d 100644 --- a/plugins/storage/Makefile +++ b/plugins/storage/Makefile @@ -1,5 +1,5 @@ GOARCH ?= amd64 -SUBCOMMANDS = subcommands/default subcommands/ensure-directory subcommands/list subcommands/mount subcommands/report subcommands/unmount +SUBCOMMANDS = subcommands/default subcommands/create subcommands/destroy subcommands/ensure-directory subcommands/info subcommands/list subcommands/list-entries subcommands/mount subcommands/report subcommands/unmount TRIGGERS = triggers/install triggers/storage-list BUILD = commands subcommands triggers PLUGIN_NAME = storage diff --git a/plugins/storage/commands_entries.go b/plugins/storage/commands_entries.go new file mode 100644 index 000000000..4f5c6ff5f --- /dev/null +++ b/plugins/storage/commands_entries.go @@ -0,0 +1,302 @@ +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 +} + +// 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/src/commands/commands.go b/plugins/storage/src/commands/commands.go index 8ad0b2de8..0c5ed04a1 100644 --- a/plugins/storage/src/commands/commands.go +++ b/plugins/storage/src/commands/commands.go @@ -18,8 +18,12 @@ 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: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:mount , Create a new bind mount storage:report [] [], Displays a storage report for one or more apps storage:unmount , Remove an existing bind mount` diff --git a/plugins/storage/src/subcommands/subcommands.go b/plugins/storage/src/subcommands/subcommands.go index 3e051c341..86ce98ecf 100644 --- a/plugins/storage/src/subcommands/subcommands.go +++ b/plugins/storage/src/subcommands/subcommands.go @@ -19,18 +19,71 @@ 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 "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) args.Parse(os.Args[2:]) @@ -63,3 +116,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/subcommands.go b/plugins/storage/subcommands.go index 4ff579a65..b84575b84 100644 --- a/plugins/storage/subcommands.go +++ b/plugins/storage/subcommands.go @@ -19,8 +19,12 @@ 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: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:mount , Create a new bind mount storage:report [] [], Displays a storage report for one or more apps storage:unmount , Remove an existing bind mount` From e1b6bdb1d41bcee2d6416d015c515783f7e660dc Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 14:43:51 -0400 Subject: [PATCH 097/116] feat: storage attachments, docker-args triggers, install-time migration storage:mount and storage:unmount now accept the named-entry form (--container-dir, --phase, --process-type, --volume-subpath, --volume-readonly, --volume-chown), persisting attachments via the property list. The legacy host:container colon form is preserved for back-compat. Storage now emits its own docker-args-deploy and docker-args-run triggers so docker-local apps continue to receive bind mounts even after their data moves out of docker-options. The install trigger walks every app once per upgrade and converts legacy -v lines into legacy- entries plus attachments, draining the originals from docker-options last so a partial run leaves the old behavior intact. Lifecycle triggers post-delete, post-app-clone-setup, and post-app-rename-setup keep the attachment store in sync with app state. --- plugins/storage/Makefile | 2 +- plugins/storage/migrate.go | 190 ++++++++++++++++++ .../storage/src/subcommands/subcommands.go | 28 ++- plugins/storage/src/triggers/triggers.go | 21 ++ plugins/storage/subcommands.go | 108 ++++++++-- plugins/storage/triggers.go | 134 +++++++++++- 6 files changed, 453 insertions(+), 30 deletions(-) create mode 100644 plugins/storage/migrate.go diff --git a/plugins/storage/Makefile b/plugins/storage/Makefile index c719dde7d..7a6b61e35 100644 --- a/plugins/storage/Makefile +++ b/plugins/storage/Makefile @@ -1,6 +1,6 @@ GOARCH ?= amd64 SUBCOMMANDS = subcommands/default subcommands/create subcommands/destroy subcommands/ensure-directory subcommands/info subcommands/list subcommands/list-entries subcommands/mount subcommands/report subcommands/unmount -TRIGGERS = triggers/install triggers/storage-list +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/migrate.go b/plugins/storage/migrate.go new file mode 100644 index 000000000..088ad4d83 --- /dev/null +++ b/plugins/storage/migrate.go @@ -0,0 +1,190 @@ +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. +func migrationFlagDir() string { + root := common.GetenvWithDefault("DOKKU_LIB_ROOT", "/var/lib/dokku") + return filepath.Join(root, "config", "storage", ".migrated") +} + +func migrationFlagFile(appName string) string { + return filepath.Join(migrationFlagDir(), appName) +} + +// 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/src/subcommands/subcommands.go b/plugins/storage/src/subcommands/subcommands.go index 86ce98ecf..52d9a46bb 100644 --- a/plugins/storage/src/subcommands/subcommands.go +++ b/plugins/storage/src/subcommands/subcommands.go @@ -86,10 +86,23 @@ func main() { 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 ]") @@ -104,10 +117,13 @@ func main() { } 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) } 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/subcommands.go b/plugins/storage/subcommands.go index b84575b84..9b3eb5824 100644 --- a/plugins/storage/subcommands.go +++ b/plugins/storage/subcommands.go @@ -125,38 +125,102 @@ 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 { - return err - } - - if err := VerifyPaths(mountPath); err != nil { - return err - } - - if CheckIfPathExists(appName, mountPath, MountPhases) { - return errors.New("Mount path already exists.") - } - - return dockeroptions.AddDockerOptionToPhases(appName, MountPhases, fmt.Sprintf("-v %s", mountPath)) +// 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 } -// CommandUnmount removes an existing bind mount from an app -func CommandUnmount(appName string, mountPath string) error { - if err := common.VerifyAppName(appName); err != nil { +// 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 } - if err := VerifyPaths(mountPath); err != nil { + // Legacy colon form: keep writing into docker-options so existing + // users see no behavior change. + if strings.Contains(input.NameOrPath, ":") { + if err := VerifyPaths(input.NameOrPath); err != nil { + return err + } + if CheckIfPathExists(input.AppName, input.NameOrPath, MountPhases) { + return errors.New("Mount path already exists.") + } + return dockeroptions.AddDockerOptionToPhases(input.AppName, MountPhases, fmt.Sprintf("-v %s", 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 } - if !CheckIfPathExists(appName, mountPath, MountPhases) { - return errors.New("Mount path does not exist.") + phases := input.Phases + if len(phases) == 0 { + phases = []string{PhaseDeploy, PhaseRun} } - return dockeroptions.RemoveDockerOptionFromPhases(appName, MountPhases, fmt.Sprintf("-v %s", mountPath)) + 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, ":") { + if err := VerifyPaths(input.NameOrPath); err != nil { + return err + } + if !CheckIfPathExists(input.AppName, input.NameOrPath, MountPhases) { + return errors.New("Mount path does not exist.") + } + return dockeroptions.RemoveDockerOptionFromPhases(input.AppName, MountPhases, fmt.Sprintf("-v %s", input.NameOrPath)) + } + + return RemoveAttachment(input.AppName, input.NameOrPath, input.ContainerDir) } // CommandList lists all bind mounts for an app diff --git a/plugins/storage/triggers.go b/plugins/storage/triggers.go index ac2c4d4e0..389783e42 100644 --- a/plugins/storage/triggers.go +++ b/plugins/storage/triggers.go @@ -6,11 +6,13 @@ 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 { storageDir := GetStorageDirectory() @@ -25,6 +27,14 @@ func TriggerInstall() error { return fmt.Errorf("Unable to set storage directory permissions: %s", err.Error()) } + if err := EnsureEntriesDirectory(); err != nil { + return err + } + + if err := MigrateLegacyMounts(); err != nil { + return fmt.Errorf("storage migration failed: %w", err) + } + distro := detectDistro() if distro == "" { return nil @@ -44,6 +54,44 @@ func TriggerInstall() error { return nil } +// TriggerPostDelete clears any attachment list for an app that's being +// destroyed. Entries are global and survive app deletion. +func TriggerPostDelete(appName string) error { + if appName == "" { + return nil + } + return common.PropertyListWrite(PluginName, appName, AttachmentsProperty, []string{}) +} + +// 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 + } + 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 clears the old. +func TriggerPostAppRenameSetup(oldName string, newName string) error { + if oldName == "" || newName == "" { + return nil + } + attachments, err := LoadAttachments(oldName) + if err != nil { + return err + } + if err := SaveAttachments(newName, attachments); err != nil { + return err + } + return common.PropertyListWrite(PluginName, oldName, AttachmentsProperty, []string{}) +} + // detectDistro returns the Linux distribution name func detectDistro() string { if runtime.GOOS != "linux" { @@ -91,3 +139,87 @@ func TriggerStorageList(appName string, phase string, format string) error { 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 +} From cfb7da8ec0ce581487a51c2dbd62ef2552df9b56 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 14:49:15 -0400 Subject: [PATCH 098/116] feat: scheduler-k3s renders pvc-backed volumes from storage attachments Adds a per-entry storage helm chart that owns the PVC and (when host_path is set) the PV, and three triggers for the storage plugin to drive: storage-create installs/upgrades the chart with storage-class existence validation, storage-destroy uninstalls it, storage-status reports the PVC phase. App deployment and cron-job charts now consume storage-app-mounts and reference each PVC by name; the // todo: implement volumes blocks are gone. ProcessVolume gained sub_path, read_only, and persistent_claim fields and the deployment/cron-job templates render them. --- plugins/scheduler-k3s/Makefile | 2 +- plugins/scheduler-k3s/go.mod | 3 + .../scheduler-k3s/src/triggers/triggers.go | 10 + plugins/scheduler-k3s/storage.go | 313 ++++++++++++++++++ plugins/scheduler-k3s/template.go | 13 +- .../templates/chart/cron-job.yaml | 32 ++ .../templates/chart/deployment.yaml | 10 + .../templates/persistent-volume-claim.yaml | 29 ++ .../templates/persistent-volume.yaml | 29 ++ plugins/scheduler-k3s/triggers.go | 12 +- 10 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 plugins/scheduler-k3s/storage.go create mode 100644 plugins/scheduler-k3s/templates/storage-chart/templates/persistent-volume-claim.yaml create mode 100644 plugins/scheduler-k3s/templates/storage-chart/templates/persistent-volume.yaml diff --git a/plugins/scheduler-k3s/Makefile b/plugins/scheduler-k3s/Makefile index fce3bb816..69b114ba4 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 BUILD = commands subcommands triggers PLUGIN_NAME = scheduler-k3s diff --git a/plugins/scheduler-k3s/go.mod b/plugins/scheduler-k3s/go.mod index 9f57b850b..6e9b0eb34 100644 --- a/plugins/scheduler-k3s/go.mod +++ b/plugins/scheduler-k3s/go.mod @@ -13,6 +13,7 @@ require ( 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 @@ -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/src/triggers/triggers.go b/plugins/scheduler-k3s/src/triggers/triggers.go index d78606e72..e9720f7d2 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" @@ -158,6 +159,15 @@ 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) 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/template.go b/plugins/scheduler-k3s/template.go index f58fccc4d..9d2b2e63a 100644 --- a/plugins/scheduler-k3s/template.go +++ b/plugins/scheduler-k3s/template.go @@ -117,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 { @@ -127,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"` diff --git a/plugins/scheduler-k3s/templates/chart/cron-job.yaml b/plugins/scheduler-k3s/templates/chart/cron-job.yaml index fec53e1f2..e8cb4675a 100644 --- a/plugins/scheduler-k3s/templates/chart/cron-job.yaml +++ b/plugins/scheduler-k3s/templates/chart/cron-job.yaml @@ -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 2eb5946c0..bee80b421 100644 --- a/plugins/scheduler-k3s/templates/chart/deployment.yaml +++ b/plugins/scheduler-k3s/templates/chart/deployment.yaml @@ -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/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 bc5d406a9..ec455a304 100644 --- a/plugins/scheduler-k3s/triggers.go +++ b/plugins/scheduler-k3s/triggers.go @@ -605,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 { @@ -778,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 { From 1ca961251db5053958ef5de94a640bb1493a3be9 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 14:51:49 -0400 Subject: [PATCH 099/116] feat: add storage:set, storage:exec, storage:wait, storage:report --global storage:set is the in-place editor for an existing entry; size grows are passed through to a helm upgrade and Kubernetes-incompatible swaps (access-mode, storage-class) are rejected up front. storage:exec spawns a throwaway container that mounts the entry: docker run on docker-local entries, kubectl run with a Pod-spec override on k3s, falling back to bash then sh when no command is supplied. storage:wait blocks until a k3s entry's PVC reports Bound via the storage-status trigger, with a docker-local fast path that just checks the host directory. storage:report --global enumerates every registered entry and the apps that mount it, in text or JSON form. --- plugins/storage/Makefile | 2 +- plugins/storage/commands_entries.go | 229 ++++++++++++++++++ plugins/storage/src/commands/commands.go | 5 +- .../storage/src/subcommands/subcommands.go | 51 ++++ plugins/storage/subcommands.go | 16 +- 5 files changed, 300 insertions(+), 3 deletions(-) diff --git a/plugins/storage/Makefile b/plugins/storage/Makefile index 7a6b61e35..520b4e9a6 100644 --- a/plugins/storage/Makefile +++ b/plugins/storage/Makefile @@ -1,5 +1,5 @@ GOARCH ?= amd64 -SUBCOMMANDS = subcommands/default subcommands/create subcommands/destroy subcommands/ensure-directory subcommands/info subcommands/list subcommands/list-entries subcommands/mount subcommands/report subcommands/unmount +SUBCOMMANDS = subcommands/default subcommands/create subcommands/destroy subcommands/ensure-directory subcommands/exec subcommands/info subcommands/list subcommands/list-entries 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/commands_entries.go b/plugins/storage/commands_entries.go index 4f5c6ff5f..d4d62c1c7 100644 --- a/plugins/storage/commands_entries.go +++ b/plugins/storage/commands_entries.go @@ -179,6 +179,235 @@ func CommandInfo(name string, format string) error { 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 +} + +// CommandExec spawns a temporary container/Pod that mounts the entry +// and runs the given command (or an interactive shell when args is +// empty). It execs into the underlying CLI (docker or kubectl) so +// stdin/stdout/stderr stream naturally. +func CommandExec(name string, image string, args []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 image == "" { + image = "alpine:3" + } + + cmd := args + if len(cmd) == 0 { + cmd = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"} + } + + if entry.Scheduler == SchedulerDockerLocal { + dockerArgs := []string{"run", "--rm", "-it", "-v", fmt.Sprintf("%s:/data", entry.HostPath), image} + dockerArgs = append(dockerArgs, cmd...) + return execStream(common.DockerBin(), dockerArgs) + } + + // k3s: render a Pod-spec override that mounts the PVC, then kubectl run. + namespace := entry.Namespace + if namespace == "" { + namespace = "default" + } + overrides := map[string]any{ + "apiVersion": "v1", + "spec": map[string]any{ + "containers": []map[string]any{ + { + "name": "exec", + "image": image, + "stdin": true, + "tty": true, + "volumeMounts": []map[string]any{{"name": "data", "mountPath": "/data"}}, + }, + }, + "volumes": []map[string]any{ + {"name": "data", "persistentVolumeClaim": map[string]any{"claimName": entry.Name}}, + }, + }, + } + overrideJSON, err := json.Marshal(overrides) + if err != nil { + return err + } + kubectlArgs := []string{ + "run", "dokku-storage-exec-" + entry.Name, + "--rm", "-it", + "--namespace=" + namespace, + "--image=" + image, + "--restart=Never", + "--overrides=" + string(overrideJSON), + "--", + } + kubectlArgs = append(kubectlArgs, cmd...) + return execStream("kubectl", kubectlArgs) +} + +func execStream(bin string, args []string) error { + result, err := common.CallExecCommand(common.ExecCommandInput{ + Command: bin, + Args: args, + StreamStdio: true, + }) + if err != nil { + return err + } + if result.ExitCode != 0 { + return fmt.Errorf("%s exited with %d", bin, result.ExitCode) + } + 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 { diff --git a/plugins/storage/src/commands/commands.go b/plugins/storage/src/commands/commands.go index 0c5ed04a1..68d4edca3 100644 --- a/plugins/storage/src/commands/commands.go +++ b/plugins/storage/src/commands/commands.go @@ -21,12 +21,15 @@ Additional commands:` 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: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 52d9a46bb..9fc49a81b 100644 --- a/plugins/storage/src/subcommands/subcommands.go +++ b/plugins/storage/src/subcommands/subcommands.go @@ -78,6 +78,57 @@ func main() { 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)") + 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(name, *image, cmd) + 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") diff --git a/plugins/storage/subcommands.go b/plugins/storage/subcommands.go index 9b3eb5824..922103af9 100644 --- a/plugins/storage/subcommands.go +++ b/plugins/storage/subcommands.go @@ -22,12 +22,15 @@ Additional commands:` 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: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 @@ -266,6 +269,17 @@ func CommandList(appName string, format string) error { // 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 { From b601a626f5555000bafd4d5c20cc34e30095d14a Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 14:54:15 -0400 Subject: [PATCH 100/116] docs: storage entries and migration guide for #6814 Updates persistent-storage.md to lead with the named storage entry workflow while keeping the legacy colon-form documentation intact, adds a Persistent storage section to the k3s scheduler doc, documents the storage-app-mounts, storage-create, storage-destroy, and storage-status triggers in plugin-triggers, and adds an entry to the 0.38.0 migration guide explaining the install-time migration of legacy mounts and the new DNS-1123 name validation. Bats coverage in tests/unit/storage.bats now exercises storage:create / list-entries / destroy, name validation rejections, multi-entry attachment, the destroy-while-mounted error, and the ensure-directory deprecation warning. --- docs/advanced-usage/persistent-storage.md | 33 ++++--- docs/appendices/0.38.0-migration-guide.md | 1 + docs/deployment/schedulers/k3s.md | 21 +++++ docs/development/plugin-triggers.md | 28 ++++++ tests/unit/storage.bats | 104 ++++++++++++++++++++++ 5 files changed, 177 insertions(+), 10 deletions(-) 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/appendices/0.38.0-migration-guide.md b/docs/appendices/0.38.0-migration-guide.md index dbf297da8..96f0bfbfd 100644 --- a/docs/appendices/0.38.0-migration-guide.md +++ b/docs/appendices/0.38.0-migration-guide.md @@ -22,6 +22,7 @@ - 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 diff --git a/docs/deployment/schedulers/k3s.md b/docs/deployment/schedulers/k3s.md index 33e55126b..b3fa64432 100644 --- a/docs/deployment/schedulers/k3s.md +++ b/docs/deployment/schedulers/k3s.md @@ -685,6 +685,27 @@ 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. diff --git a/docs/development/plugin-triggers.md b/docs/development/plugin-triggers.md index 7fd0f7f29..c0dae2518 100644 --- a/docs/development/plugin-triggers.md +++ b/docs/development/plugin-triggers.md @@ -2920,6 +2920,34 @@ APP="$1" # TODO ``` +### `storage-app-mounts` + +- 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}` + +### `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 + ### `traefik-template-source` - Description: Retrieves an alternative template for the traefik compose config diff --git a/tests/unit/storage.bats b/tests/unit/storage.bats index 9687d0e73..7c4b3ecc4 100644 --- a/tests/unit/storage.bats +++ b/tests/unit/storage.bats @@ -183,3 +183,107 @@ 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 + + # 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: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: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" +} From 89afd09087a01d6d9200ceedc785ac139c4d39f4 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 22:36:00 -0400 Subject: [PATCH 101/116] fix: storage plugin install setup and post-delete permissions apps:destroy was failing in CI because the post-delete trigger called PropertyListWrite, which tried to mkdir /var/lib/dokku/config/storage/ as the dokku user but the parent directory had been created by the install trigger as root. The install trigger now calls PropertySetup("storage") so the per-plugin config root has the correct ownership, and post-delete / post-app-clone / post-app-rename short-circuit when the app has no attachments to begin with so they can't fail on a never-touched property tree. The entry registry and migration flag directories also moved out of config/storage (where they would have collided with the property-store path config/storage/) into a dedicated data/storage-registry tree. --- plugins/storage/entry.go | 13 +++++++++++-- plugins/storage/entry_test.go | 2 +- plugins/storage/migrate.go | 7 ++++--- plugins/storage/triggers.go | 18 +++++++++++++++++- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/plugins/storage/entry.go b/plugins/storage/entry.go index 68ef5d79f..3fae69d66 100644 --- a/plugins/storage/entry.go +++ b/plugins/storage/entry.go @@ -76,10 +76,19 @@ type Entry struct { 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 { - root := common.GetenvWithDefault("DOKKU_LIB_ROOT", "/var/lib/dokku") - return filepath.Join(root, "config", "storage", "entries") + return filepath.Join(RegistryDirectory(), "entries") } // entryPath returns the on-disk path for a named entry. diff --git a/plugins/storage/entry_test.go b/plugins/storage/entry_test.go index 683032008..804245b1f 100644 --- a/plugins/storage/entry_test.go +++ b/plugins/storage/entry_test.go @@ -138,7 +138,7 @@ func TestEntryRoundTrip(t *testing.T) { Expect(loaded.Labels).To(Equal(original.Labels)) Expect(loaded.SchemaVersion).To(Equal(SchemaVersion)) - expectedPath := filepath.Join(root, "config", "storage", "entries", "demo-data.json") + expectedPath := filepath.Join(root, "data", "storage-registry", "entries", "demo-data.json") Expect(expectedPath).To(BeARegularFile()) Expect(DeleteEntry("demo-data")).To(Succeed()) diff --git a/plugins/storage/migrate.go b/plugins/storage/migrate.go index 088ad4d83..3dba56eb0 100644 --- a/plugins/storage/migrate.go +++ b/plugins/storage/migrate.go @@ -14,10 +14,11 @@ import ( // 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. +// 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 { - root := common.GetenvWithDefault("DOKKU_LIB_ROOT", "/var/lib/dokku") - return filepath.Join(root, "config", "storage", ".migrated") + return filepath.Join(RegistryDirectory(), "migrations") } func migrationFlagFile(appName string) string { diff --git a/plugins/storage/triggers.go b/plugins/storage/triggers.go index 389783e42..c8f8b30a6 100644 --- a/plugins/storage/triggers.go +++ b/plugins/storage/triggers.go @@ -14,6 +14,10 @@ import ( // 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 { @@ -55,11 +59,17 @@ func TriggerInstall() error { } // TriggerPostDelete clears any attachment list for an app that's being -// destroyed. Entries are global and survive app deletion. +// destroyed. Entries are global and survive app deletion. Skipped when +// the app has no attachments to begin with so we don't trigger a +// pointless propertyTouch that could fail on installs with no +// property-list state for this plugin yet. func TriggerPostDelete(appName string) error { if appName == "" { return nil } + if !common.PropertyExists(PluginName, appName, AttachmentsProperty) { + return nil + } return common.PropertyListWrite(PluginName, appName, AttachmentsProperty, []string{}) } @@ -69,6 +79,9 @@ 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 @@ -82,6 +95,9 @@ 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 From 6a5d56894426a9513d96a30fc5d27ad4b9364b53 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 22:39:12 -0400 Subject: [PATCH 102/116] fix: remove the per-app property folder on app delete and rename Switches the storage plugin's post-delete and post-app-rename-setup triggers from rewriting the attachment list as empty to removing the per-app property folder outright via PropertyDestroy. This matches the convention other plugins use on app deletion and avoids leaving an empty mounts file behind for an app that no longer exists. --- plugins/storage/triggers.go | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/plugins/storage/triggers.go b/plugins/storage/triggers.go index c8f8b30a6..1d5b00747 100644 --- a/plugins/storage/triggers.go +++ b/plugins/storage/triggers.go @@ -58,19 +58,15 @@ func TriggerInstall() error { return nil } -// TriggerPostDelete clears any attachment list for an app that's being -// destroyed. Entries are global and survive app deletion. Skipped when -// the app has no attachments to begin with so we don't trigger a -// pointless propertyTouch that could fail on installs with no -// property-list state for this plugin yet. +// 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 } - if !common.PropertyExists(PluginName, appName, AttachmentsProperty) { - return nil - } - return common.PropertyListWrite(PluginName, appName, AttachmentsProperty, []string{}) + return common.PropertyDestroy(PluginName, appName) } // TriggerPostAppCloneSetup copies attachments from the source app to the @@ -90,7 +86,7 @@ func TriggerPostAppCloneSetup(oldName string, newName string) error { } // TriggerPostAppRenameSetup moves attachments from the old name to the -// new name and clears the old. +// new name and removes the old per-app property folder. func TriggerPostAppRenameSetup(oldName string, newName string) error { if oldName == "" || newName == "" { return nil @@ -105,7 +101,7 @@ func TriggerPostAppRenameSetup(oldName string, newName string) error { if err := SaveAttachments(newName, attachments); err != nil { return err } - return common.PropertyListWrite(PluginName, oldName, AttachmentsProperty, []string{}) + return common.PropertyDestroy(PluginName, oldName) } // detectDistro returns the Linux distribution name From 3b3bc3dd3983f8cca6f193d6e4fa84f1bd08d79d Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 22:48:49 -0400 Subject: [PATCH 103/116] fix: scope deploy output redirect to git-hook only The previous condition used a bash regex `git-*` that matched git-receive-pack and git-upload-pack, interposing a tee chain on the binary git wire protocol and producing `fatal: protocol error: bad line length character: !` on every push. Restrict the redirect to the pre-receive hook, where tee passthrough is safe because git-receive-pack forwards hook output via the protocol sideband. --- dokku | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dokku b/dokku index 067cc550e..7e98c7174 100755 --- a/dokku +++ b/dokku @@ -188,7 +188,7 @@ execute_dokku_cmd() { set -- "$PLUGIN_CMD" "$@" fi - if [[ $PLUGIN_NAME =~ git-* ]] || [[ "$PLUGIN_NAME" == "ps:rebuild" ]] || [[ "$PLUGIN_NAME" == "ps:restart" ]] || [[ "$PLUGIN_NAME" == "deploy" ]]; then + if [[ "$PLUGIN_NAME" == "git-hook" ]] || [[ "$PLUGIN_NAME" == "ps:rebuild" ]] || [[ "$PLUGIN_NAME" == "ps:restart" ]] || [[ "$PLUGIN_NAME" == "deploy" ]]; then export DOKKU_REDIRECT_OUTPUT=true exec &> >(tee >(tee | logger -i -t "dokku-${DOKKU_PID}")) fi From d75228f165cf2870310478740f08167bababbc2c Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 22:51:50 -0400 Subject: [PATCH 104/116] fix: storage:list reads from attachments, deprecate storage-list trigger storage:list was calling the storage-list plugn trigger which read -v lines from docker-options. After the install-time migration drains those lines into the attachment store, that source is empty for every migrated app and for any app that only ever used storage:create + storage:mount. The fix moves CommandList to call a new in-process ListAppMountEntries helper that reads attachments directly, surfaces the entry name in JSON output via a new entry_name field, and falls back to the entry name as the host token for k3s entries with no host path so the colon form remains well-formed. The storage-list plugn trigger is kept for back-compat with external callers but emits a deprecation warning and now reads from the same attachment-driven source. --- docs/development/plugin-triggers.md | 22 ++--- plugins/storage/attachment_test.go | 128 ++++++++++++++++++++++++++++ plugins/storage/storage.go | 79 ++++++++++++++--- plugins/storage/subcommands.go | 42 +++++---- plugins/storage/triggers.go | 27 +++--- tests/unit/storage.bats | 19 +++++ 6 files changed, 258 insertions(+), 59 deletions(-) diff --git a/docs/development/plugin-triggers.md b/docs/development/plugin-triggers.md index c0dae2518..e2d8ce464 100644 --- a/docs/development/plugin-triggers.md +++ b/docs/development/plugin-triggers.md @@ -2904,21 +2904,17 @@ 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 - -set -eo pipefail; [[ $DOKKU_TRACE ]] && set -x -APP="$1" - -# TODO -``` ### `storage-app-mounts` diff --git a/plugins/storage/attachment_test.go b/plugins/storage/attachment_test.go index d6be3e31b..6c698ff77 100644 --- a/plugins/storage/attachment_test.go +++ b/plugins/storage/attachment_test.go @@ -1,11 +1,39 @@ 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) @@ -31,3 +59,103 @@ func TestAttachmentValidate(t *testing.T) { 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/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 922103af9..96b76800e 100644 --- a/plugins/storage/subcommands.go +++ b/plugins/storage/subcommands.go @@ -1,6 +1,7 @@ package storage import ( + "encoding/json" "errors" "fmt" "os" @@ -226,7 +227,9 @@ func CommandUnmount(input CommandUnmountInput) error { return RemoveAttachment(input.AppName, input.NameOrPath, input.ContainerDir) } -// 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 @@ -236,34 +239,29 @@ 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 } diff --git a/plugins/storage/triggers.go b/plugins/storage/triggers.go index 1d5b00747..1d6cf9279 100644 --- a/plugins/storage/triggers.go +++ b/plugins/storage/triggers.go @@ -125,30 +125,31 @@ 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 } diff --git a/tests/unit/storage.bats b/tests/unit/storage.bats index 7c4b3ecc4..eed154ab3 100644 --- a/tests/unit/storage.bats +++ b/tests/unit/storage.bats @@ -251,11 +251,30 @@ teardown() { 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" From 58042b9330f90a3de0b96ccee190f9ff5159b993 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 23:29:30 -0400 Subject: [PATCH 105/116] feat: delegate storage:exec to scheduler plugins, harden the flow Moves the actual exec out of the storage plugin and into a new scheduler-storage-exec plugn trigger. scheduler-docker-local does docker run with TTY-aware -it/-i selection and --user derived from entry.Chown; scheduler-k3s creates a throwaway Pod via the kubernetes API, waits for it to reach Running with structured error reporting (ImagePullBackOff and friends are surfaced from the container status verbatim, no kubectl involvement), execs the user command via the existing SPDY plumbing in k8s.go, and deletes the Pod on the way out. (Entry).Validate now accepts either an absolute path or a docker named-volume token for docker-local entries so the migration synthesizer's named-volume legacy entries work cleanly. storage:exec gains --as-user for one-off uid overrides, propagates the underlying tool's exit code via os.Exit, and detects TTY/interactive mode from os.Stdin so non-interactive scripted use no longer trips over docker's input-device-is-not-a-tty error. --- docs/development/plugin-triggers.md | 7 + plugins/scheduler-docker-local/Makefile | 2 +- plugins/scheduler-docker-local/go.mod | 7 + plugins/scheduler-docker-local/go.sum | 2 + .../src/triggers/triggers.go | 28 ++- .../scheduler-docker-local/storage_exec.go | 160 ++++++++++++ .../storage_exec_test.go | 118 +++++++++ plugins/scheduler-k3s/Makefile | 2 +- .../scheduler-k3s/src/triggers/triggers.go | 24 ++ plugins/scheduler-k3s/storage_exec.go | 236 ++++++++++++++++++ plugins/scheduler-k3s/storage_exec_test.go | 103 ++++++++ plugins/storage/commands_entries.go | 119 +++++---- plugins/storage/entry.go | 8 +- plugins/storage/entry_test.go | 9 + .../storage/src/subcommands/subcommands.go | 8 +- plugins/storage/subcommands.go | 6 +- tests/unit/storage.bats | 23 ++ 17 files changed, 790 insertions(+), 72 deletions(-) create mode 100644 plugins/scheduler-docker-local/storage_exec.go create mode 100644 plugins/scheduler-docker-local/storage_exec_test.go create mode 100644 plugins/scheduler-k3s/storage_exec.go create mode 100644 plugins/scheduler-k3s/storage_exec_test.go diff --git a/docs/development/plugin-triggers.md b/docs/development/plugin-triggers.md index e2d8ce464..dc2399ace 100644 --- a/docs/development/plugin-triggers.md +++ b/docs/development/plugin-triggers.md @@ -2944,6 +2944,13 @@ DOKKU_SCHEDULER="$1"; APP="$2"; REMOVE_CONTAINERS="$3"; - 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` - Description: Retrieves an alternative template for the traefik compose config 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/go.mod b/plugins/scheduler-docker-local/go.mod index 8db9ad568..8eb6eddd8 100644 --- a/plugins/scheduler-docker-local/go.mod +++ b/plugins/scheduler-docker-local/go.mod @@ -5,13 +5,16 @@ 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 fd1216a63..caf492d5a 100644 --- a/plugins/scheduler-docker-local/go.sum +++ b/plugins/scheduler-docker-local/go.sum @@ -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/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..15938ba02 --- /dev/null +++ b/plugins/scheduler-docker-local/storage_exec.go @@ -0,0 +1,160 @@ +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, + }) + if err != nil { + return err + } + if result.ExitCode != 0 { + os.Exit(result.ExitCode) + } + 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 69b114ba4..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/storage-create triggers/storage-destroy triggers/storage-status +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/src/triggers/triggers.go b/plugins/scheduler-k3s/src/triggers/triggers.go index e9720f7d2..6998f9b1d 100644 --- a/plugins/scheduler-k3s/src/triggers/triggers.go +++ b/plugins/scheduler-k3s/src/triggers/triggers.go @@ -19,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 @@ -168,6 +171,27 @@ func main() { 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_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/storage/commands_entries.go b/plugins/storage/commands_entries.go index d4d62c1c7..71b5bb3e3 100644 --- a/plugins/storage/commands_entries.go +++ b/plugins/storage/commands_entries.go @@ -244,87 +244,80 @@ func CommandSet(input CommandSetInput) error { return nil } -// CommandExec spawns a temporary container/Pod that mounts the entry -// and runs the given command (or an interactive shell when args is -// empty). It execs into the underlying CLI (docker or kubectl) so -// stdin/stdout/stderr stream naturally. -func CommandExec(name string, image string, args []string) error { - if !EntryExists(name) { - return fmt.Errorf("storage entry %q does not exist", name) +// 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(name) + entry, err := LoadEntry(input.Name) if err != nil { return err } + + image := input.Image if image == "" { image = "alpine:3" } - cmd := args - if len(cmd) == 0 { - cmd = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"} + 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...) } - if entry.Scheduler == SchedulerDockerLocal { - dockerArgs := []string{"run", "--rm", "-it", "-v", fmt.Sprintf("%s:/data", entry.HostPath), image} - dockerArgs = append(dockerArgs, cmd...) - return execStream(common.DockerBin(), dockerArgs) - } - - // k3s: render a Pod-spec override that mounts the PVC, then kubectl run. - namespace := entry.Namespace - if namespace == "" { - namespace = "default" - } - overrides := map[string]any{ - "apiVersion": "v1", - "spec": map[string]any{ - "containers": []map[string]any{ - { - "name": "exec", - "image": image, - "stdin": true, - "tty": true, - "volumeMounts": []map[string]any{{"name": "data", "mountPath": "/data"}}, - }, - }, - "volumes": []map[string]any{ - {"name": "data", "persistentVolumeClaim": map[string]any{"claimName": entry.Name}}, - }, - }, - } - overrideJSON, err := json.Marshal(overrides) - if err != nil { - return err - } - kubectlArgs := []string{ - "run", "dokku-storage-exec-" + entry.Name, - "--rm", "-it", - "--namespace=" + namespace, - "--image=" + image, - "--restart=Never", - "--overrides=" + string(overrideJSON), - "--", - } - kubectlArgs = append(kubectlArgs, cmd...) - return execStream("kubectl", kubectlArgs) -} - -func execStream(bin string, args []string) error { - result, err := common.CallExecCommand(common.ExecCommandInput{ - Command: bin, - Args: args, + results, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ + Trigger: "scheduler-storage-exec", + Args: triggerArgs, StreamStdio: true, }) if err != nil { return err } - if result.ExitCode != 0 { - return fmt.Errorf("%s exited with %d", bin, result.ExitCode) + if results.ExitCode != 0 { + os.Exit(results.ExitCode) } 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 +} + // CommandWait blocks until a k3s storage entry's PVC is bound. func CommandWait(name string) error { if !EntryExists(name) { @@ -463,7 +456,7 @@ func ensureDockerLocalPath(entry *Entry) error { } if entry.Chown != "" && entry.Chown != "false" { - chownID, err := resolveChownID(entry.Chown) + chownID, err := ResolveChownID(entry.Chown) if err != nil { return err } diff --git a/plugins/storage/entry.go b/plugins/storage/entry.go index 3fae69d66..470ac3781 100644 --- a/plugins/storage/entry.go +++ b/plugins/storage/entry.go @@ -44,6 +44,10 @@ var ( // 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, @@ -225,8 +229,8 @@ func (e *Entry) Validate() error { if e.HostPath == "" { return fmt.Errorf("storage entry %q (docker-local) is missing host_path", e.Name) } - if !filepath.IsAbs(e.HostPath) { - return fmt.Errorf("storage entry %q host_path must be absolute, got %q", e.Name, e.HostPath) + 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) diff --git a/plugins/storage/entry_test.go b/plugins/storage/entry_test.go index 804245b1f..d317452dc 100644 --- a/plugins/storage/entry_test.go +++ b/plugins/storage/entry_test.go @@ -65,9 +65,18 @@ func TestEntryValidateDockerLocal(t *testing.T) { 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()) diff --git a/plugins/storage/src/subcommands/subcommands.go b/plugins/storage/src/subcommands/subcommands.go index 9fc49a81b..6af83275d 100644 --- a/plugins/storage/src/subcommands/subcommands.go +++ b/plugins/storage/src/subcommands/subcommands.go @@ -113,6 +113,7 @@ func main() { 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 { @@ -124,7 +125,12 @@ func main() { if len(positional) > 1 { cmd = positional[1:] } - err = storage.CommandExec(name, *image, cmd) + err = storage.CommandExec(storage.CommandExecInput{ + Name: name, + Image: *image, + AsUser: *asUser, + Args: cmd, + }) case "wait": args := flag.NewFlagSet("storage:wait", flag.ExitOnError) args.Parse(os.Args[2:]) diff --git a/plugins/storage/subcommands.go b/plugins/storage/subcommands.go index 96b76800e..e3d489f1f 100644 --- a/plugins/storage/subcommands.go +++ b/plugins/storage/subcommands.go @@ -46,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 } @@ -80,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 { diff --git a/tests/unit/storage.bats b/tests/unit/storage.bats index eed154ab3..ad48240ed 100644 --- a/tests/unit/storage.bats +++ b/tests/unit/storage.bats @@ -299,6 +299,29 @@ teardown() { 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:ensure-directory emits deprecation warning" { run /bin/bash -c "dokku storage:ensure-directory $TEST_APP 2>&1" echo "output: $output" From cdbc91048a5cf1c9acbd2df1862b4ed9022a8a86 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 30 Apr 2026 00:01:54 -0400 Subject: [PATCH 106/116] fix: chown the storage registry tree to the dokku user The install trigger created /var/lib/dokku/data/storage-registry and its entries / migrations subdirectories as root with mode 0755, so the dokku user that runs storage:create couldn't write entry JSON files or migration flag files into them. Mirrors what PropertySetup does for config/storage by calling SetPermissions on each registry path. This unblocks the bats coverage that was failing at the first storage:create call. --- plugins/storage/triggers.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugins/storage/triggers.go b/plugins/storage/triggers.go index 1d6cf9279..270b00812 100644 --- a/plugins/storage/triggers.go +++ b/plugins/storage/triggers.go @@ -35,6 +35,21 @@ func TriggerInstall() error { 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) } From e9a53ac82dcab8b0e1c3f4ab7dfb22307e18c889 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 30 Apr 2026 01:10:00 -0400 Subject: [PATCH 107/116] fix: propagate storage:exec exit codes and route legacy mounts to attachments CallExecCommand wraps non-zero exit codes as errors with the code populated on the response, so storage:exec was returning the wrapped error and being collapsed to exit 1 by LogFailWithError before the os.Exit branch ran. Both storage's CommandExec and scheduler-docker-local's TriggerSchedulerStorageExec now check ExitCode before err so the underlying tool's status flows through verbatim. Separately, the legacy host:container colon form of storage:mount used to write straight into docker-options, but storage:list now reads only attachments, so newly mounted legacy-form storage was invisible. CommandMount and CommandUnmount route the colon form through LegacyMountToEntry plus AddAttachment / RemoveAttachment, making storage:list show every mount regardless of form while preserving the existing "Mount path already exists." / "Mount path does not exist." error wording. --- .../scheduler-docker-local/storage_exec.go | 10 +- plugins/storage/commands_entries.go | 11 ++- plugins/storage/subcommands.go | 93 +++++++++++++++---- 3 files changed, 91 insertions(+), 23 deletions(-) diff --git a/plugins/scheduler-docker-local/storage_exec.go b/plugins/scheduler-docker-local/storage_exec.go index 15938ba02..4db397e29 100644 --- a/plugins/scheduler-docker-local/storage_exec.go +++ b/plugins/scheduler-docker-local/storage_exec.go @@ -64,12 +64,16 @@ func TriggerSchedulerStorageExec(scheduler string, input StorageExecInput) error Args: args, StreamStdio: true, }) - if err != nil { - return err - } + // 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 } diff --git a/plugins/storage/commands_entries.go b/plugins/storage/commands_entries.go index 71b5bb3e3..ada6f68e3 100644 --- a/plugins/storage/commands_entries.go +++ b/plugins/storage/commands_entries.go @@ -295,12 +295,17 @@ func CommandExec(input CommandExecInput) error { Args: triggerArgs, StreamStdio: true, }) - if err != nil { - return err - } + // 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 } diff --git a/plugins/storage/subcommands.go b/plugins/storage/subcommands.go index e3d489f1f..f8b857bf7 100644 --- a/plugins/storage/subcommands.go +++ b/plugins/storage/subcommands.go @@ -9,7 +9,6 @@ import ( "strings" "github.com/dokku/dokku/plugins/common" - dockeroptions "github.com/dokku/dokku/plugins/docker-options" ) const ( @@ -151,16 +150,12 @@ func CommandMount(input CommandMountInput) error { return err } - // Legacy colon form: keep writing into docker-options so existing - // users see no behavior change. + // 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, ":") { - if err := VerifyPaths(input.NameOrPath); err != nil { - return err - } - if CheckIfPathExists(input.AppName, input.NameOrPath, MountPhases) { - return errors.New("Mount path already exists.") - } - return dockeroptions.AddDockerOptionToPhases(input.AppName, MountPhases, fmt.Sprintf("-v %s", input.NameOrPath)) + return mountLegacyColon(input.AppName, input.NameOrPath) } // Named-entry form: persist as an attachment. @@ -215,18 +210,82 @@ func CommandUnmount(input CommandUnmountInput) error { } if strings.Contains(input.NameOrPath, ":") { - if err := VerifyPaths(input.NameOrPath); err != nil { - return err - } - if !CheckIfPathExists(input.AppName, input.NameOrPath, MountPhases) { - return errors.New("Mount path does not exist.") - } - return dockeroptions.RemoveDockerOptionFromPhases(input.AppName, MountPhases, fmt.Sprintf("-v %s", 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 + } + parsed := ParseMountPath(mountPath) + if parsed.ContainerPath == "" { + return errors.New("Storage path must be two valid paths divided by colon.") + } + + entry := LegacyMountToEntry(mountPath) + if !EntryExists(entry.Name) { + if err := SaveEntry(entry); err != nil { + return err + } + } + + 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 + } + parsed := ParseMountPath(mountPath) + if parsed.ContainerPath == "" { + return errors.New("Storage path must be two valid paths divided by colon.") + } + + 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. Reads attachments // directly from the storage plugin's own state rather than going through // the deprecated `storage-list` plugn trigger. From 67e4cf04ab7b4fe49cabb470f329dc05afa4a8ae Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 30 Apr 2026 01:18:03 -0400 Subject: [PATCH 108/116] feat: migrate builds plugin to go and track per-build records Adds typed JSON build records under data/builds//.{json,log} keyed on a stable base36 ULID-style DOKKU_BUILD_ID generated for every deploy. The new commands surface that history (builds:list, builds:info, builds:prune) and an operator-configurable retention via builds:set retention. The existing builds:cancel and builds:output now key on the build-id (with safe handling for already-finalized and abandoned records), and the per-build log file replaces journalctl as the durable source of truth for builds:output. --- docs/README.md | 1 + docs/advanced-usage/builds.md | 171 +++++ dokku | 5 +- plugins/builds/.gitignore | 10 + plugins/builds/Makefile | 6 + plugins/builds/builds.go | 446 +++++++++++++ plugins/builds/builds_test.go | 611 ++++++++++++++++++ plugins/builds/commands | 16 - plugins/builds/go.mod | 30 + plugins/builds/go.sum | 57 ++ plugins/builds/internal-functions | 31 - plugins/builds/plugin.toml | 2 +- plugins/builds/report.go | 130 ++++ plugins/builds/set.go | 44 ++ plugins/builds/src/commands/commands.go | 60 ++ plugins/builds/src/subcommands/subcommands.go | 77 +++ plugins/builds/src/triggers/triggers.go | 49 ++ plugins/builds/subcommands.go | 458 +++++++++++++ plugins/builds/subcommands/cancel | 37 -- plugins/builds/subcommands/default | 6 - plugins/builds/subcommands/list | 13 - plugins/builds/subcommands/output | 40 -- plugins/builds/subcommands/report | 15 - plugins/builds/triggers.go | 145 +++++ plugins/common/functions | 69 +- plugins/config/config.go | 2 +- plugins/git/functions | 2 +- plugins/git/internal-functions | 20 +- plugins/git/receive-app | 5 +- tests/unit/builds.bats | 328 ++++++++++ 30 files changed, 2690 insertions(+), 196 deletions(-) create mode 100644 docs/advanced-usage/builds.md create mode 100644 plugins/builds/.gitignore create mode 100644 plugins/builds/Makefile create mode 100644 plugins/builds/builds.go create mode 100644 plugins/builds/builds_test.go delete mode 100755 plugins/builds/commands create mode 100644 plugins/builds/go.mod create mode 100644 plugins/builds/go.sum delete mode 100755 plugins/builds/internal-functions create mode 100644 plugins/builds/report.go create mode 100644 plugins/builds/set.go create mode 100644 plugins/builds/src/commands/commands.go create mode 100644 plugins/builds/src/subcommands/subcommands.go create mode 100644 plugins/builds/src/triggers/triggers.go create mode 100644 plugins/builds/subcommands.go delete mode 100755 plugins/builds/subcommands/cancel delete mode 100755 plugins/builds/subcommands/default delete mode 100755 plugins/builds/subcommands/list delete mode 100755 plugins/builds/subcommands/output delete mode 100755 plugins/builds/subcommands/report create mode 100644 plugins/builds/triggers.go create mode 100644 tests/unit/builds.bats diff --git a/docs/README.md b/docs/README.md index b4e6d5c81..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 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/dokku b/dokku index 7e98c7174..495e7aa9f 100755 --- a/dokku +++ b/dokku @@ -188,9 +188,8 @@ execute_dokku_cmd() { set -- "$PLUGIN_CMD" "$@" fi - if [[ "$PLUGIN_NAME" == "git-hook" ]] || [[ "$PLUGIN_NAME" == "ps:rebuild" ]] || [[ "$PLUGIN_NAME" == "ps:restart" ]] || [[ "$PLUGIN_NAME" == "deploy" ]]; then - export DOKKU_REDIRECT_OUTPUT=true - exec &> >(tee >(tee | logger -i -t "dokku-${DOKKU_PID}")) + 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 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/commands b/plugins/builds/commands deleted file mode 100755 index bbb121222..000000000 --- a/plugins/builds/commands +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -[[ " help builds:help " == *" $1 "* ]] || exit "$DOKKU_NOT_IMPLEMENTED_EXIT" -source "$PLUGIN_AVAILABLE_PATH/builds/internal-functions" -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x - -case "$1" in - help | builds:help) - cmd-builds-help "$@" - ;; - - *) - exit "$DOKKU_NOT_IMPLEMENTED_EXIT" - ;; - -esac 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/internal-functions b/plugins/builds/internal-functions deleted file mode 100755 index 1b56fad60..000000000 --- a/plugins/builds/internal-functions +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x - -fn-builds-help-content() { - declare desc="return logs plugin help content" - cat <, Cancel a running build for an app - builds:list , List all running builds - builds:output , Shows build output - builds:report [] [], Displays a build report for one or more apps -help_content -} - -cmd-builds-help() { - if [[ $1 == "builds:help" ]]; then - echo -e 'Usage: dokku builds[:COMMAND]' - echo '' - echo 'Manage running builds' - echo '' - echo 'Additional commands:' - fn-builds-help-content | sort | column -c2 -t -s, - elif [[ $(ps -o command= $PPID) == *"--all"* ]]; then - fn-builds-help-content - else - cat <= %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/subcommands/cancel b/plugins/builds/subcommands/cancel deleted file mode 100755 index 8472feae6..000000000 --- a/plugins/builds/subcommands/cancel +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x -source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" - -cmd-builds-cancel() { - declare desc="cancel a running build for an app" - declare cmd="builds:cancel" - [[ "$1" == "$cmd" ]] && shift 1 - declare APP="$1" - local APP_DEPLOY_LOCK_FILE PROCESS_ID PROCESS_GROUP_ID - verify_app_name "$APP" - - local APP_DEPLOY_LOCK_FILE PROCESS_ID PROCESS_GROUP_ID - APP_DEPLOY_LOCK_FILE="$DOKKU_LIB_ROOT/data/apps/$APP/.deploy.lock" - if [[ ! -f "$APP_DEPLOY_LOCK_FILE" ]]; then - dokku_log_info1 "App not currently deploying" - return - fi - - PROCESS_ID="$(cat "$APP_DEPLOY_LOCK_FILE")" - if [[ -z "$PROCESS_ID" ]]; then - dokku_log_info1 "No matching app deploy found" - return - fi - - PROCESS_GROUP_ID="$(ps -o pgid= "$PROCESS_ID" || true)" - if [[ -z "$PROCESS_ID" ]]; then - dokku_log_info1 "Unable to find process group id for app deploy" - return - fi - - dokku_log_info1 "Killing app deploy" - kill -quit -- "-${PROCESS_GROUP_ID}" && rm -f "$APP_DEPLOY_LOCK_FILE" -} - -cmd-builds-cancel "$@" diff --git a/plugins/builds/subcommands/default b/plugins/builds/subcommands/default deleted file mode 100755 index f0205d69f..000000000 --- a/plugins/builds/subcommands/default +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x -source "$PLUGIN_AVAILABLE_PATH/builds/internal-functions" - -cmd-builds-help "builds:help" diff --git a/plugins/builds/subcommands/list b/plugins/builds/subcommands/list deleted file mode 100755 index b8bf8b624..000000000 --- a/plugins/builds/subcommands/list +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x -source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" - -cmd-builds-list() { - declare desc="list all running builds" - declare cmd="builds:list" - [[ "$1" == "$cmd" ]] && shift 1 - -} - -cmd-builds-list "$@" diff --git a/plugins/builds/subcommands/output b/plugins/builds/subcommands/output deleted file mode 100755 index ab1453059..000000000 --- a/plugins/builds/subcommands/output +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x -source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" - -cmd-builds-output() { - declare desc="shows build output" - declare cmd="builds:output" - [[ "$1" == "$cmd" ]] && shift 1 - declare APP="$1" DEPLOY_ID="$2" - verify_app_name "$APP" - - if [[ -z "$DEPLOY_ID" ]] || [[ "$DEPLOY_ID" == "current" ]]; then - local APP_DEPLOY_LOCK_FILE PROCESS_ID - APP_DEPLOY_LOCK_FILE="$DOKKU_ROOT/$APP/.deploy.lock" - if [[ ! -f "$APP_DEPLOY_LOCK_FILE" ]]; then - dokku_log_info1 "App not currently deploying" - return - fi - - DEPLOY_ID="$(cat "$APP_DEPLOY_LOCK_FILE")" - if [[ -z "$DEPLOY_ID" ]]; then - dokku_log_info1 "No matching app deploy found" - return - fi - fi - - if [[ -z "$DEPLOY_ID" ]]; then - dokku_log_fail "No deploy id specified" - return 1 - fi - - if [[ -x /usr/bin/systemctl ]] || [[ -x /usr/local/bin/systemctl ]]; then - journalctl -n1000 -f -b -a -o cat "SYSLOG_IDENTIFIER=dokku-${DEPLOY_ID}" - else - grep "dokku-${DEPLOY_ID}" /var/log/syslog - fi -} - -cmd-builds-output "$@" diff --git a/plugins/builds/subcommands/report b/plugins/builds/subcommands/report deleted file mode 100755 index f9a78f2f9..000000000 --- a/plugins/builds/subcommands/report +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -[[ $DOKKU_TRACE ]] && set -x -source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" - -cmd-builds-report() { - declare desc="displays a build report for one or more apps" - declare cmd="builds:report" - [[ "$1" == "$cmd" ]] && shift 1 - declare APP="$1" - verify_app_name "$APP" - -} - -cmd-builds-report "$@" 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/common/functions b/plugins/common/functions index 27102fc27..4d9e2cb15 100755 --- a/plugins/common/functions +++ b/plugins/common/functions @@ -626,14 +626,14 @@ release_and_deploy() { declare desc="main function for releasing and deploying an app" source "$PLUGIN_AVAILABLE_PATH/config/functions" - if [[ "$DOKKU_REDIRECT_OUTPUT" != "true" ]]; then - export DOKKU_REDIRECT_OUTPUT=true - exec &> >(tee >(tee | logger -i -t "dokku-${DOKKU_PID}")) - fi - 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")" @@ -643,20 +643,28 @@ release_and_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() { @@ -852,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" @@ -860,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" } @@ -895,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() { diff --git a/plugins/config/config.go b/plugins/config/config.go index 45cbe9b97..5b898370d 100644 --- a/plugins/config/config.go +++ b/plugins/config/config.go @@ -147,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/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/internal-functions b/plugins/git/internal-functions index 30634839b..eee315f7b 100755 --- a/plugins/git/internal-functions +++ b/plugins/git/internal-functions @@ -52,10 +52,7 @@ cmd-git-from-archive() { dokku_log_fail "Invalid archive type specified, valid archive types include: tar, tar.gz, zip" fi - if [[ "$DOKKU_REDIRECT_OUTPUT" != "true" ]]; then - export DOKKU_REDIRECT_OUTPUT=true - exec &> >(tee >(tee | logger -i -t "dokku-${DOKKU_PID}")) - 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" } @@ -166,10 +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" - if [[ "$DOKKU_REDIRECT_OUTPUT" != "true" ]]; then - export DOKKU_REDIRECT_OUTPUT=true - exec &> >(tee >(tee | logger -i -t "dokku-${DOKKU_PID}")) - fi + dokku_setup_build_capture "$APP" "git:load-image" cat | docker load if ! verify_image "$DOCKER_IMAGE"; then @@ -223,10 +217,7 @@ cmd-git-from-image() { export DOKKU_APPS_FORCE_DELETE=1 fi - if [[ "$DOKKU_REDIRECT_OUTPUT" != "true" ]]; then - export DOKKU_REDIRECT_OUTPUT=true - exec &> >(tee >(tee | logger -i -t "dokku-${DOKKU_PID}")) - 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 @@ -276,10 +267,7 @@ 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)" - if [[ "$DOKKU_REDIRECT_OUTPUT" != "true" ]]; then - export DOKKU_REDIRECT_OUTPUT=true - exec &> >(tee >(tee | logger -i -t "dokku-${DOKKU_PID}")) - fi + 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" diff --git a/plugins/git/receive-app b/plugins/git/receive-app index 78821f835..74316595c 100755 --- a/plugins/git/receive-app +++ b/plugins/git/receive-app @@ -7,10 +7,7 @@ trigger-git-receive-app() { declare desc="builds the app from the local git repository" declare trigger="receive-app" - if [[ "$DOKKU_REDIRECT_OUTPUT" != "true" ]]; then - export DOKKU_REDIRECT_OUTPUT=true - exec &> >(tee >(tee | logger -i -t "dokku-${DOKKU_PID}")) - fi + dokku_setup_build_capture "$1" "git-hook" git_receive_app "$@" return $? diff --git a/tests/unit/builds.bats b/tests/unit/builds.bats new file mode 100644 index 000000000..f3bd1bbb0 --- /dev/null +++ b/tests/unit/builds.bats @@ -0,0 +1,328 @@ +#!/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" +} + +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" +} + +@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" + 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" + + 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" + + 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" + + 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 +} From ae615475057cdbec7e55b1ca37497308f93fd54c Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 30 Apr 2026 01:19:21 -0400 Subject: [PATCH 109/116] fix: pad shfmt-required spaces around arithmetic operator --- tests/unit/builds.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/builds.bats b/tests/unit/builds.bats index f3bd1bbb0..1367c046a 100644 --- a/tests/unit/builds.bats +++ b/tests/unit/builds.bats @@ -267,7 +267,7 @@ EOF 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" + write_finished_record "$TEST_APP" "p$i" "succeeded" "git-hook" $((1000 + i)) "build" sleep 1 done From ea25c5b6e2d2ca4ded5f03a8e7e8e27018da4b8d Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 30 Apr 2026 02:02:02 -0400 Subject: [PATCH 110/116] fix: keep operator stdout when teeing build output to file --- plugins/common/functions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/common/functions b/plugins/common/functions index 4d9e2cb15..a3ed2c9ef 100755 --- a/plugins/common/functions +++ b/plugins/common/functions @@ -881,7 +881,7 @@ dokku_setup_build_capture() { 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}") + exec &> >(tee -a "$LOG" >(logger -i -t "dokku-${DOKKU_BUILD_ID}")) } acquire_app_deploy_lock() { From f0d088bc1d2f2a57f5d5aa3946582a402aaf453d Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 30 Apr 2026 02:37:01 -0400 Subject: [PATCH 111/116] test: cover the legacy -v migration with go and bats tests Adds plugins/storage/migrate_test.go exercising migrateApp and MigrateLegacyMounts: single deploy phase, cross-phase grouping into one attachment, :ro and free-form volume options preserved on the attachment, idempotency on a second pass, the per-app flag-file fast path that skips already-migrated apps, and the name-collision refusal that aborts before draining. tests/unit/storage.bats gains an end-to-end case that stages a -v line via docker-options:add, runs storage:migrate, and asserts the synthesized colon form shows up in storage:list, the legacy- entry shows up in storage:list-entries, and the original -v line is gone from docker-options:report on both phases. To make that test (and operators with restored backups) practical, storage gains a storage:migrate |--all subcommand backed by a new MigrateApp helper that skips the per-app flag file so a re-run actually re-scans. --- plugins/storage/Makefile | 2 +- plugins/storage/commands_entries.go | 38 +++ plugins/storage/migrate.go | 15 + plugins/storage/migrate_test.go | 317 ++++++++++++++++++ plugins/storage/src/commands/commands.go | 1 + .../storage/src/subcommands/subcommands.go | 5 + plugins/storage/subcommands.go | 1 + tests/unit/storage.bats | 68 ++++ 8 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 plugins/storage/migrate_test.go diff --git a/plugins/storage/Makefile b/plugins/storage/Makefile index 520b4e9a6..11829d5ec 100644 --- a/plugins/storage/Makefile +++ b/plugins/storage/Makefile @@ -1,5 +1,5 @@ GOARCH ?= amd64 -SUBCOMMANDS = subcommands/default subcommands/create subcommands/destroy subcommands/ensure-directory subcommands/exec subcommands/info subcommands/list subcommands/list-entries subcommands/mount subcommands/report subcommands/set subcommands/unmount subcommands/wait +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/commands_entries.go b/plugins/storage/commands_entries.go index ada6f68e3..a79d1cdff 100644 --- a/plugins/storage/commands_entries.go +++ b/plugins/storage/commands_entries.go @@ -323,6 +323,44 @@ func stdinModes() (interactive bool, tty bool) { 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) { diff --git a/plugins/storage/migrate.go b/plugins/storage/migrate.go index 3dba56eb0..62fae8d8c 100644 --- a/plugins/storage/migrate.go +++ b/plugins/storage/migrate.go @@ -25,6 +25,21 @@ 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. 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/src/commands/commands.go b/plugins/storage/src/commands/commands.go index 68d4edca3..a026f0709 100644 --- a/plugins/storage/src/commands/commands.go +++ b/plugins/storage/src/commands/commands.go @@ -25,6 +25,7 @@ Additional commands:` 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:set [flags], Update a storage entry in place diff --git a/plugins/storage/src/subcommands/subcommands.go b/plugins/storage/src/subcommands/subcommands.go index 6af83275d..3a5bb8d6b 100644 --- a/plugins/storage/src/subcommands/subcommands.go +++ b/plugins/storage/src/subcommands/subcommands.go @@ -131,6 +131,11 @@ func main() { 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:]) diff --git a/plugins/storage/subcommands.go b/plugins/storage/subcommands.go index f8b857bf7..2e92125ad 100644 --- a/plugins/storage/subcommands.go +++ b/plugins/storage/subcommands.go @@ -26,6 +26,7 @@ Additional commands:` 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:set [flags], Update a storage entry in place diff --git a/tests/unit/storage.bats b/tests/unit/storage.bats index ad48240ed..83af1fc59 100644 --- a/tests/unit/storage.bats +++ b/tests/unit/storage.bats @@ -322,6 +322,74 @@ teardown() { 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" From 4d5a16b7140731ab5c17cc6530633e1bd1207ea8 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 30 Apr 2026 02:58:18 -0400 Subject: [PATCH 112/116] fix: chown test-fixture build records to dokku user --- tests/unit/builds.bats | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/unit/builds.bats b/tests/unit/builds.bats index 1367c046a..366658ccf 100644 --- a/tests/unit/builds.bats +++ b/tests/unit/builds.bats @@ -33,6 +33,7 @@ write_finished_record() { } EOF : >"$dir/$id.log" + chown -R dokku:dokku "$DOKKU_LIB_ROOT/data/builds" } write_running_record() { @@ -53,6 +54,7 @@ write_running_record() { } EOF : >"$dir/$id.log" + chown -R dokku:dokku "$DOKKU_LIB_ROOT/data/builds" } @test "(builds) builds:help" { @@ -200,7 +202,7 @@ EOF echo "output: $output" echo "status: $status" assert_success - assert_output_contains "info001" + assert_output_contains "info001" -1 assert_output_contains "succeeded" assert_output_contains "Log:" @@ -224,6 +226,7 @@ EOF 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" @@ -238,6 +241,7 @@ EOF 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" @@ -255,6 +259,7 @@ EOF @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" From 2210307e42aa04973e678fa6c80a99ad598f5e3f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:54:23 +0000 Subject: [PATCH 113/116] chore(deps): bump traefik in /plugins/traefik-vhosts Bumps traefik from v3.6.14 to v3.6.15. --- updated-dependencies: - dependency-name: traefik dependency-version: v3.6.15 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- plugins/traefik-vhosts/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 8de073c960b4f48bb20c654e7b3b218c7c4dbd67 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 30 Apr 2026 12:03:07 -0400 Subject: [PATCH 114/116] docs: delete docs/deployment/builds-management.md This is duplicated --- docs/deployment/builds-management.md | 30 ---------------------------- 1 file changed, 30 deletions(-) delete mode 100644 docs/deployment/builds-management.md diff --git a/docs/deployment/builds-management.md b/docs/deployment/builds-management.md deleted file mode 100644 index cf3e779dd..000000000 --- a/docs/deployment/builds-management.md +++ /dev/null @@ -1,30 +0,0 @@ -# Build Management - -> New as of 0.19.0 - -``` -builds:cancel # Cancel a running build for an app -builds:list # List all running builds -builds:output # Shows build output -builds:report [] [] # Displays a build report for one or more apps -``` - -## Usage - -### Listing running deploys - -### Viewing the status of a deploy - -### Viewing build output for a deploy - -### Canceling a running deploy - -It can be useful to kill a deploy if that deploy does not appear to be progressing, is impacting other apps through system resource utilization, or if a successful deploy will result in app errors. To do so, the `builds:cancel` command can be used: - -```shell -dokku builds:cancel node-js-app -``` - -This command will send a `QUIT` signal to the Process Group ID of the process handling the deploy, and should terminate all processes within that process tree. Finally, it will unlock the deploy so that a new deploy may be immediately invoked. - -> Warning: This may also result in invalid app state depending upon when the app deploy was killed. From 1a6f01a1ebe379c2d466f62a683e20713c969b13 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 30 Apr 2026 12:04:23 -0400 Subject: [PATCH 115/116] docs: link to build tracking docs in sidebar --- docs/template.html | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/template.html b/docs/template.html index 0f3049701..aa5dd84d9 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 From 0a857b7966df7db971e8c49a0dc9a89df4efe72e Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 30 Apr 2026 12:08:00 -0400 Subject: [PATCH 116/116] docs: add file formats to list --- docs/template.html | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/template.html b/docs/template.html index aa5dd84d9..9747d3bbc 100644 --- a/docs/template.html +++ b/docs/template.html @@ -223,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