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/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= 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 +}