feat: scope docker-options to specific procfile processes

Adds a `--process` flag (repeatable) to docker-options:add/remove/clear/list and the new docker-options:list subcommand for querying a single process+phase pair. Process scoping is supported only for the deploy phase since build runs once per app and run covers ad-hoc commands and cron tasks where no Procfile process type is available. Storage moves from `$DOKKU_ROOT/$APP/DOCKER_OPTIONS_*` files to property lists under `/var/lib/dokku/config/docker-options/$APP/{processType}.{phase}`, with `_default_` as the sentinel for app-wide options. The install trigger migrates pre-existing DOCKER_OPTIONS_* files into property lists once and renames them to `.migrated`; a global marker makes re-runs strictly no-op. The legacy docker-args-{build,deploy,run} bash triggers are reimplemented in Go alongside a new docker-args-process-deploy trigger that surfaces per-process options to the scheduler. The :report command exposes one dynamic flag per configured `process.deploy` pair (e.g. `--docker-options-deploy.web`) and supports `--format json`. There is no `--global` flag; omitting `--process` keeps the historical default behaviour, since `--global` elsewhere in dokku means "across all apps". Closes #2441.
This commit is contained in:
Jose Diaz-Gonzalez
2026-04-27 18:12:52 -04:00
parent f19227dcd9
commit 9c8d5f54fb
16 changed files with 1034 additions and 385 deletions

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