Merge pull request #8516 from dokku/2441-process-scoped-docker-options

Scope docker-options to specific procfile processes
This commit is contained in:
Jose Diaz-Gonzalez
2026-04-29 04:45:23 -04:00
committed by GitHub
18 changed files with 1151 additions and 395 deletions

View File

@@ -4,10 +4,11 @@
> New as of 0.3.17
```
docker-options:add <app> <phase(s)> OPTION # Add Docker option to app for phase (comma-separated phase list)
docker-options:clear <app> [<phase(s)>...] # Clear a docker options from app
docker-options:remove <app> <phase(s)> OPTION # Remove Docker option from app for phase (comma-separated phase list)
docker-options:report [<app>] [<flag>] # Displays a docker options report for one or more apps
docker-options:add [--process PROC...] <app> <phase(s)> OPTION # Add Docker option to app for phase
docker-options:clear [--process PROC...] <app> [<phase(s)>...] # Clear docker options from app
docker-options:list <app> [--process PROC] --phase PHASE # List docker options for one process+phase pair
docker-options:remove [--process PROC...] <app> <phase(s)> OPTION # Remove Docker option from app for phase
docker-options:report [<app>] [<flag>] [--format json|stdout] # Displays a docker options report for one or more apps
```
The `docker-options` plugin allows users to specify custom [container options](https://docs.docker.com/engine/reference/run/) for containers created by Dokku at various phases.
@@ -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.<process>`:
```shell
dokku docker-options:report node-js-app --docker-options-deploy.web
```
A machine-readable JSON view is available via `--format json`:
```shell
dokku docker-options:report node-js-app --format json
```
### Process-Specific Options
> [!IMPORTANT]
> New as of 0.38.0
Docker options can be scoped to specific process types declared in the app's `Procfile` by passing one or more `--process` flags. This is useful when a deploy-phase option (for example a port mapping) makes sense for one process type but would conflict with another - the canonical case being a `web` process that needs `-p 6789:5000` published while the `worker` process must not bind that port.
Process scoping is supported only for the `deploy` phase. The `build` phase runs once per app and the `run` phase covers ad-hoc commands and cron tasks where no Procfile process type is in play; both reject `--process`.
There is no `--global` flag. Omitting `--process` keeps the historical behavior of applying the option to every container in the app. Avoiding a `--global` flag here is intentional: elsewhere in Dokku `--global` means "across all apps" (e.g. `dokku config:set --global`), which would be misleading in this plugin where the scope is always one app.
#### Setting process-specific options
```shell
# Add a port mapping only to the web process
dokku docker-options:add --process web node-js-app deploy "-p 6789:5000"
# Add a GPU mount only to the worker process
dokku docker-options:add --process worker node-js-app deploy "--gpus all"
```
Multiple `--process` flags can be combined to apply the same option to several process types in one call:
```shell
dokku docker-options:add --process web --process api node-js-app deploy "-v /shared:/shared"
```
If `--process` names a process type that is not currently declared in the app's `Procfile`, the command succeeds but emits a warning. This allows configuring options ahead of a deploy that adds the new process type.
The `_default_` value is reserved internally and cannot be passed to `--process`.
#### Removing and clearing process-specific options
```shell
# Remove a single option from one process
dokku docker-options:remove --process web node-js-app deploy "-p 6789:5000"
# Clear every option for a process+phase
dokku docker-options:clear --process worker node-js-app deploy
```
Without `--process`, `:remove` and `:clear` operate on the default scope only - per-process lists are left untouched.
#### Listing options for a process and phase
The `docker-options:list` command prints the options stored for a single process+phase pair, one option per line. Omitting `--process` lists the default scope.
```shell
dokku docker-options:list node-js-app --process web --phase deploy
dokku docker-options:list node-js-app --phase deploy
```

View File

@@ -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>`. `$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:

11
plugins/docker-options/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
/commands
/subcommands/*
/triggers/*
/triggers
/install
/post-*
/report
/docker-args-build
/docker-args-deploy
/docker-args-process-deploy
/docker-args-run

View File

@@ -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

View File

@@ -1 +0,0 @@
docker-args-deploy

View File

@@ -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 "$@"

View File

@@ -1 +0,0 @@
docker-args-deploy

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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))
}

View File

@@ -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
}

View File

@@ -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 ""
}

View File

@@ -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 ]")

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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" <<EOF
web: python3 -u web.py
worker: python3 -u worker.py
EOF
}

View File

@@ -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