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/migration_test.go b/plugins/docker-options/migration_test.go new file mode 100644 index 000000000..d3a1595fe --- /dev/null +++ b/plugins/docker-options/migration_test.go @@ -0,0 +1,148 @@ +package dockeroptions + +import ( + "os" + "os/user" + "path/filepath" + "testing" + + "github.com/dokku/dokku/plugins/common" +) + +// setupMigrationEnv points the dokku env at temporary directories and tells the +// permission helpers to chown files to the current user (a no-op) so the test +// works without root. +func setupMigrationEnv(t *testing.T) (dokkuRoot string) { + t.Helper() + + libRoot := t.TempDir() + dokkuRoot = t.TempDir() + + t.Setenv("DOKKU_LIB_ROOT", libRoot) + t.Setenv("DOKKU_ROOT", dokkuRoot) + t.Setenv("PLUGIN_PATH", filepath.Join(libRoot, "plugins")) + + current, err := user.Current() + if err != nil { + t.Fatalf("user.Current: %v", err) + } + group, err := user.LookupGroupId(current.Gid) + if err != nil { + t.Fatalf("user.LookupGroupId: %v", err) + } + t.Setenv("DOKKU_SYSTEM_USER", current.Username) + t.Setenv("DOKKU_SYSTEM_GROUP", group.Name) + + return dokkuRoot +} + +func writeLegacyDockerOptionsFile(t *testing.T, dokkuRoot, app, phase, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dokkuRoot, app), 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + path := filepath.Join(dokkuRoot, app, "DOCKER_OPTIONS_"+phase) + if err := os.WriteFile(path, []byte(contents), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } +} + +func TestMigrateLegacyDockerOptionsFiles_MigratesAndIsIdempotent(t *testing.T) { + dokkuRoot := setupMigrationEnv(t) + + writeLegacyDockerOptionsFile(t, dokkuRoot, "alpha", "DEPLOY", "-v /var/log:/log\n# a comment\n\n--restart=on-failure:5\n") + writeLegacyDockerOptionsFile(t, dokkuRoot, "alpha", "BUILD", "--build-arg FOO=bar\n") + writeLegacyDockerOptionsFile(t, dokkuRoot, "beta", "DEPLOY", "-p 8080:5000\n") + + if err := migrateLegacyDockerOptionsFiles(); err != nil { + t.Fatalf("first migration: %v", err) + } + + deploy, err := common.PropertyListGet("docker-options", "alpha", "_default_.deploy") + if err != nil { + t.Fatalf("PropertyListGet alpha deploy: %v", err) + } + wantDeploy := []string{"-v /var/log:/log", "--restart=on-failure:5"} + if !equalStrings(deploy, wantDeploy) { + t.Errorf("alpha deploy = %v, want %v", deploy, wantDeploy) + } + + build, err := common.PropertyListGet("docker-options", "alpha", "_default_.build") + if err != nil { + t.Fatalf("PropertyListGet alpha build: %v", err) + } + wantBuild := []string{"--build-arg FOO=bar"} + if !equalStrings(build, wantBuild) { + t.Errorf("alpha build = %v, want %v", build, wantBuild) + } + + betaDeploy, err := common.PropertyListGet("docker-options", "beta", "_default_.deploy") + if err != nil { + t.Fatalf("PropertyListGet beta deploy: %v", err) + } + wantBetaDeploy := []string{"-p 8080:5000"} + if !equalStrings(betaDeploy, wantBetaDeploy) { + t.Errorf("beta deploy = %v, want %v", betaDeploy, wantBetaDeploy) + } + + if !common.PropertyExists("docker-options", "--global", "migrated-from-files") { + t.Errorf("migrated-from-files marker not set after first migration") + } + + for _, app := range []string{"alpha", "beta"} { + for _, phase := range []string{"BUILD", "DEPLOY", "RUN"} { + legacy := filepath.Join(dokkuRoot, app, "DOCKER_OPTIONS_"+phase) + migrated := legacy + ".migrated" + if app == "alpha" && (phase == "BUILD" || phase == "DEPLOY") || app == "beta" && phase == "DEPLOY" { + if _, err := os.Stat(migrated); err != nil { + t.Errorf("expected %s to exist: %v", migrated, err) + } + if _, err := os.Stat(legacy); !os.IsNotExist(err) { + t.Errorf("expected %s to be gone, got err=%v", legacy, err) + } + } else { + if _, err := os.Stat(legacy); !os.IsNotExist(err) { + t.Errorf("did not expect %s to exist, got err=%v", legacy, err) + } + } + } + } + + sneakyPath := filepath.Join(dokkuRoot, "alpha", "DOCKER_OPTIONS_DEPLOY") + sneakyContents := "-v /tmp/sneaky:/sneaky\n" + if err := os.WriteFile(sneakyPath, []byte(sneakyContents), 0644); err != nil { + t.Fatalf("re-create legacy file: %v", err) + } + + if err := migrateLegacyDockerOptionsFiles(); err != nil { + t.Fatalf("second migration: %v", err) + } + + deployAfter, err := common.PropertyListGet("docker-options", "alpha", "_default_.deploy") + if err != nil { + t.Fatalf("PropertyListGet alpha deploy (post re-run): %v", err) + } + if !equalStrings(deployAfter, wantDeploy) { + t.Errorf("alpha deploy after rerun = %v, want %v (idempotency violated)", deployAfter, wantDeploy) + } + + gotSneaky, err := os.ReadFile(sneakyPath) + if err != nil { + t.Fatalf("expected sneaky legacy file untouched, got err=%v", err) + } + if string(gotSneaky) != sneakyContents { + t.Errorf("sneaky legacy file modified: %q", gotSneaky) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/plugins/docker-options/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..43f33bd69 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,15 @@ 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) + 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..f7f7f289f --- /dev/null +++ b/tests/unit/docker-options-2.bats @@ -0,0 +1,266 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + global_setup + create_app +} + +teardown() { + destroy_app + global_teardown +} + +@test "(docker-options:add) --process scopes option to one process type" { + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:list $TEST_APP --process web --phase deploy" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "-p 8080:5000" + + run /bin/bash -c "dokku docker-options:list $TEST_APP --phase deploy" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "-p 8080:5000" 0 +} + +@test "(docker-options:add) multiple --process flags add to each process" { + run /bin/bash -c "dokku docker-options:add --process web --process worker $TEST_APP deploy '-v /shared:/shared'" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:list $TEST_APP --process web --phase deploy" + echo "output: $output" + assert_output "-v /shared:/shared" + + run /bin/bash -c "dokku docker-options:list $TEST_APP --process worker --phase deploy" + echo "output: $output" + assert_output "-v /shared:/shared" +} + +@test "(docker-options:add) without --process keeps default-scope behaviour" { + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy '-v /tmp/shared:/shared'" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:list $TEST_APP --phase deploy" + echo "output: $output" + assert_output_contains "-v /tmp/shared:/shared" + + run /bin/bash -c "dokku docker-options:report $TEST_APP --docker-options-deploy" + echo "output: $output" + assert_output_contains "-v /tmp/shared:/shared" +} + +@test "(docker-options:add) rejects --process for non-deploy phases" { + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP build '--shm-size 256m'" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "deploy phase" + + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP run '--shm-size 256m'" + echo "output: $output" + echo "status: $status" + assert_failure + + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP build,deploy '--shm-size 256m'" + echo "output: $output" + echo "status: $status" + assert_failure +} + +@test "(docker-options:add) rejects reserved _default_ process name" { + run /bin/bash -c "dokku docker-options:add --process _default_ $TEST_APP deploy '-v /tmp:/tmp'" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "_default_" +} + +@test "(docker-options:add) warns on process types missing from Procfile" { + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:add --process nonexistent $TEST_APP deploy '-v /tmp:/tmp'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "nonexistent" +} + +@test "(docker-options:remove) --process removes from a single process" { + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:add --process worker $TEST_APP deploy '-p 9000:9000'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:remove --process web $TEST_APP deploy '-p 8080:5000'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:list $TEST_APP --process web --phase deploy" + echo "output: $output" + assert_output "" + + run /bin/bash -c "dokku docker-options:list $TEST_APP --process worker --phase deploy" + echo "output: $output" + assert_output "-p 9000:9000" +} + +@test "(docker-options:clear) --process clears only that process" { + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:add --process worker $TEST_APP deploy '-p 9000:9000'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:clear --process web $TEST_APP deploy" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:list $TEST_APP --process web --phase deploy" + echo "output: $output" + assert_output "" + + run /bin/bash -c "dokku docker-options:list $TEST_APP --process worker --phase deploy" + echo "output: $output" + assert_output "-p 9000:9000" +} + +@test "(docker-options:list) --phase is required" { + run /bin/bash -c "dokku docker-options:list $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "phase" +} + +@test "(docker-options:report) exposes dynamic per-process keys" { + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:report $TEST_APP --docker-options-deploy.web" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "-p 8080:5000" + + run /bin/bash -c "dokku docker-options:report $TEST_APP" + echo "output: $output" + assert_success + assert_output_contains "Docker options deploy web" + assert_output_contains "-p 8080:5000" +} + +@test "(docker-options:report) --format json carries process-scoped options" { + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy '-v /logs:/logs'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:report $TEST_APP --format json" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains '"docker-options-deploy.web"' + assert_output_contains "-p 8080:5000" + assert_output_contains '"docker-options-deploy"' + assert_output_contains "-v /logs:/logs" +} + +@test "(docker-options) clone copies default and per-process options" { + local CLONE_APP="${TEST_APP}-clone" + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + assert_success + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy '-v /logs:/logs'" + assert_success + + run /bin/bash -c "dokku apps:clone --skip-deploy $TEST_APP $CLONE_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:list $CLONE_APP --process web --phase deploy" + echo "output: $output" + assert_output "-p 8080:5000" + + run /bin/bash -c "dokku docker-options:list $CLONE_APP --phase deploy" + echo "output: $output" + assert_output_contains "-v /logs:/logs" + + dokku --force apps:destroy "$CLONE_APP" || true +} + +@test "(docker-options) rename moves default and per-process options" { + local RENAMED_APP="${TEST_APP}-renamed" + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + assert_success + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy '-v /logs:/logs'" + assert_success + + run /bin/bash -c "dokku apps:rename $TEST_APP $RENAMED_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:list $RENAMED_APP --process web --phase deploy" + assert_output "-p 8080:5000" + + run /bin/bash -c "dokku docker-options:list $RENAMED_APP --phase deploy" + assert_output_contains "-v /logs:/logs" + + [[ ! -d "/var/lib/dokku/config/docker-options/$TEST_APP" ]] + + TEST_APP="$RENAMED_APP" +} + +@test "(docker-options) deploy with web-only port mapping does not break worker" { + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + echo "output: $output" + assert_success + + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP web_worker_callback + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "App container failed to start" 0 + assert_output_contains "all ports are allocated" 0 + + run /bin/bash -c "dokku ps:report $TEST_APP --ps-status-web-1" + echo "output: $output" + assert_output_contains "running" + + run /bin/bash -c "dokku ps:report $TEST_APP --ps-status-worker-1" + echo "output: $output" + assert_output_contains "running" +} + +web_worker_callback() { + local APP="$1" + local APP_REPO_DIR="$2" + cat >"$APP_REPO_DIR/Procfile" <> $DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" - echo "output: $output" - echo "status: $status" - assert_success - run /bin/bash -c "echo '# comment' >> $DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy \"-v /tmp\"" echo "output: $output" echo "status: $status" assert_success @@ -295,11 +291,7 @@ teardown() { echo "output: $output" echo "status: $status" assert_success - run /bin/bash -c "echo '-v /tmp' >> $DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" - echo "output: $output" - echo "status: $status" - assert_success - run /bin/bash -c "echo '# comment' >> $DOKKU_ROOT/$TEST_APP/DOCKER_OPTIONS_DEPLOY" + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy \"-v /tmp\"" echo "output: $output" echo "status: $status" assert_success