mirror of
https://github.com/dokku/dokku.git
synced 2026-09-01 19:49:25 +02:00
Options drained out of the pre-0.38.0 `DOCKER_OPTIONS_<PHASE>` files were copied verbatim rather than re-serialized the way `docker-options:add` stores them, so `docker-options:remove` compared the canonical string it builds against a stored value that could never match it and exited successfully without removing anything. Removal now matches stored options by shell word, and stored options are rewritten into the canonical form once on upgrade, which additionally splits an entry that carried several flags on a single line into one entry per flag so a single flag can be removed and so the readers that match on a flag prefix see one value per entry. The leftover `.migrated` sentinel drain is restored to running ahead of the global short-circuit that had made it unreachable, and the plugin's Go tests are added to the test target that had never run them.
527 lines
16 KiB
Go
527 lines
16 KiB
Go
package dockeroptions
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/dokku/dokku/plugins/common"
|
|
)
|
|
|
|
// 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.
|
|
//
|
|
// The option string is split on flag boundaries via SplitOptionString so a
|
|
// single invocation may carry multiple flags (e.g. `--build-arg X=Y --link a
|
|
// --link b`); each split flag becomes a separate stored option. A misplaced
|
|
// `--process` inside the option content is lifted into the process slice
|
|
// rather than stored as a docker option.
|
|
func CommandAdd(appName string, processes []string, phasesArg string, option string) error {
|
|
if err := common.VerifyAppName(appName); err != nil {
|
|
return err
|
|
}
|
|
|
|
phases, err := parsePhases(phasesArg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
options, extractedProcesses, err := SplitOptionString(option)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(options) == 0 {
|
|
return errors.New("Please specify docker options to add to the phase")
|
|
}
|
|
|
|
processes = dedupeProcesses(append(processes, extractedProcesses...))
|
|
|
|
if err := ValidateProcessFlag(processes, phases); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, processType := range processes {
|
|
WarnIfProcessNotInProcfile(appName, processType)
|
|
}
|
|
|
|
for _, opt := range options {
|
|
if len(processes) == 0 {
|
|
if err := AddDockerOptionToPhases(appName, phases, opt); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if err := AddDockerOptionToProcessPhases(appName, processes, phases, opt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CommandRemove removes a docker option from the specified phases for an app.
|
|
// Process-flag handling matches CommandAdd, including splitting a multi-flag
|
|
// option string and lifting a misplaced `--process` into the process slice.
|
|
func CommandRemove(appName string, processes []string, phasesArg string, option string) error {
|
|
if err := common.VerifyAppName(appName); err != nil {
|
|
return err
|
|
}
|
|
|
|
phases, err := parsePhases(phasesArg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
options, extractedProcesses, err := SplitOptionString(option)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(options) == 0 {
|
|
return errors.New("Please specify docker options to remove from the phase")
|
|
}
|
|
|
|
processes = dedupeProcesses(append(processes, extractedProcesses...))
|
|
|
|
if err := ValidateProcessFlag(processes, phases); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, opt := range options {
|
|
if len(processes) == 0 {
|
|
if err := RemoveDockerOptionFromPhases(appName, phases, opt); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if err := RemoveDockerOptionFromProcessPhases(appName, processes, phases, opt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// dedupeProcesses preserves order while collapsing repeated process names,
|
|
// which can occur when --process is specified both before the app (captured
|
|
// by pflag) and after (lifted by SplitOptionString).
|
|
func dedupeProcesses(processes []string) []string {
|
|
seen := map[string]bool{}
|
|
result := make([]string, 0, len(processes))
|
|
for _, p := range processes {
|
|
if seen[p] {
|
|
continue
|
|
}
|
|
seen[p] = true
|
|
result = append(result, p)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// CommandClear removes all docker options for an app, optionally limited to
|
|
// 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 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
|
|
}
|
|
|
|
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 := common.PropertyDelete("docker-options", appName, propertyKey(DefaultProcessType, phase)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// migratedPropertyKey returns the per-app property name recording that
|
|
// an app's DOCKER_OPTIONS_<PHASE> file was drained into the property
|
|
// store. Surfaces in the property store as `docker-options/<app>/migrated-<phase>`.
|
|
func migratedPropertyKey(phase string) string {
|
|
return "migrated-" + strings.ToLower(phase)
|
|
}
|
|
|
|
// convertLegacyMigratedMarker drains a leftover DOCKER_OPTIONS_<PHASE>.migrated
|
|
// sentinel into the new per-phase property and removes the file. Runs
|
|
// as part of the upgrade-cycle pass; no-op when the file is absent.
|
|
// TODO(post-deprecation): remove this helper and its caller.
|
|
func convertLegacyMigratedMarker(appName, phase string) error {
|
|
migratedPath := filepath.Join(common.AppRoot(appName), "DOCKER_OPTIONS_"+strings.ToUpper(phase)+".migrated")
|
|
if !common.FileExists(migratedPath) {
|
|
return nil
|
|
}
|
|
if err := common.PropertyWrite("docker-options", appName, migratedPropertyKey(phase), "true"); err != nil {
|
|
return err
|
|
}
|
|
return os.Remove(migratedPath)
|
|
}
|
|
|
|
// migrateLegacyDockerOptionsFiles converts pre-properties
|
|
// DOCKER_OPTIONS_* files into property lists. Idempotency comes from
|
|
// two layers: a per-phase per-app `migrated-<phase>` property recording
|
|
// what was drained, and a global `migrated-from-files` short-circuit so
|
|
// the steady-state install path returns immediately.
|
|
//
|
|
// The upgrade-cycle conversion of leftover `.migrated` filesystem
|
|
// sentinels runs BEFORE the global gate so users upgrading from the
|
|
// previous release - who have `migrated-from-files` set AND `.migrated`
|
|
// files on disk - still get those files drained into properties.
|
|
func migrateLegacyDockerOptionsFiles() error {
|
|
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
|
|
}
|
|
|
|
// Upgrade-cycle conversion: drain any `.migrated` sentinels left
|
|
// behind by the previous release into the new per-phase properties.
|
|
// Always runs; cheap when nothing to do.
|
|
for _, appName := range apps {
|
|
for _, phase := range availablePhases {
|
|
if err := convertLegacyMigratedMarker(appName, phase); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
if common.PropertyGet("docker-options", "--global", "migrated-from-files") == "true" {
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
options := canonicalizeOptionLines(lines)
|
|
if len(options) > 0 {
|
|
if err := common.PropertyListWrite("docker-options", appName, propertyKey(DefaultProcessType, phase), options); err != nil {
|
|
return err
|
|
}
|
|
if err := common.PropertyWrite("docker-options", appName, migratedPropertyKey(phase), "true"); err != nil {
|
|
return err
|
|
}
|
|
common.LogInfo1(fmt.Sprintf("Migrated %s to docker-options properties", legacyPath))
|
|
}
|
|
|
|
if err := os.Remove(legacyPath); err != nil {
|
|
return fmt.Errorf("Unable to remove migrated file %s: %s", legacyPath, err.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// canonicalizeOptionLines re-serializes stored option lines into the canonical
|
|
// form `docker-options:add` writes, flattening the per-line results so a line
|
|
// carrying several flags becomes one entry per flag. Order is preserved.
|
|
func canonicalizeOptionLines(lines []string) []string {
|
|
options := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
options = append(options, canonicalOptionsFromLine(line)...)
|
|
}
|
|
return options
|
|
}
|
|
|
|
// traefikLabelMigrationKey gates the one-time pass that repairs docker-option
|
|
// labels whose backticks were stored with a stray leading backslash by an
|
|
// earlier release's option tokenizer (e.g. Traefik rules like Host(\`app\`)).
|
|
const traefikLabelMigrationKey = "migrated-traefik-backticks"
|
|
|
|
// migrateTraefikLabelBackticks repairs stored Traefik label options whose
|
|
// backticks carry a stray backslash (Host(\`app\`) instead of Host(`app`)) so
|
|
// they become valid on the next deploy. Only Traefik --label options (those
|
|
// whose label key begins with "traefik.") are considered, and only when they
|
|
// contain a backslash-escaped backtick, which correct storage never produces.
|
|
// It runs once, guarded by a global property.
|
|
func migrateTraefikLabelBackticks() error {
|
|
if common.PropertyGet("docker-options", "--global", traefikLabelMigrationKey) == "true" {
|
|
return nil
|
|
}
|
|
|
|
apps, err := common.DokkuApps()
|
|
if err != nil {
|
|
if errors.Is(err, common.NoAppsExist) {
|
|
return common.PropertyWrite("docker-options", "--global", traefikLabelMigrationKey, "true")
|
|
}
|
|
return err
|
|
}
|
|
|
|
for _, appName := range apps {
|
|
properties, err := common.PropertyGetAll("docker-options", appName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for key := range properties {
|
|
processType, phase, ok := splitPropertyKey(key)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
options, err := GetDockerOptionsForProcessPhase(appName, processType, phase)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
changed := false
|
|
for i, option := range options {
|
|
if fixed, ok := repairTraefikLabelBackticks(option); ok {
|
|
options[i] = fixed
|
|
changed = true
|
|
}
|
|
}
|
|
|
|
if changed {
|
|
if err := writeDockerOptionsForProcessPhase(appName, processType, phase, options); err != nil {
|
|
return err
|
|
}
|
|
common.LogInfo1(fmt.Sprintf("Repaired docker-options label backticks for %s (%s %s)", appName, processType, phase))
|
|
}
|
|
}
|
|
}
|
|
|
|
return common.PropertyWrite("docker-options", "--global", traefikLabelMigrationKey, "true")
|
|
}
|
|
|
|
// repairTraefikLabelBackticks removes the stray backslash before a backtick in
|
|
// a Traefik --label option (one whose label key begins with "traefik."),
|
|
// returning the repaired option and whether anything changed. Backticks are
|
|
// Traefik's rule syntax, so the repair is scoped to Traefik labels; non-label
|
|
// options, non-Traefik labels, and labels without a backslash-escaped backtick
|
|
// are left untouched so an intentional backslash elsewhere is never rewritten.
|
|
func repairTraefikLabelBackticks(option string) (string, bool) {
|
|
spec, ok := labelSpec(option)
|
|
if !ok || !strings.HasPrefix(spec, "traefik.") || !strings.Contains(option, "\\`") {
|
|
return option, false
|
|
}
|
|
return strings.ReplaceAll(option, "\\`", "`"), true
|
|
}
|
|
|
|
// labelSpec returns the "key=value" portion of a stored --label/-l option with
|
|
// the flag and any surrounding shell-quoting removed so the label key can be
|
|
// inspected. It reports ok=false for options that do not set a docker label.
|
|
func labelSpec(option string) (string, bool) {
|
|
s := strings.TrimLeft(option, " ")
|
|
if len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'' {
|
|
s = s[1 : len(s)-1]
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(s, "--label="):
|
|
s = s[len("--label="):]
|
|
case strings.HasPrefix(s, "--label "):
|
|
s = s[len("--label "):]
|
|
case strings.HasPrefix(s, "-l="):
|
|
s = s[len("-l="):]
|
|
case strings.HasPrefix(s, "-l "):
|
|
s = s[len("-l "):]
|
|
default:
|
|
return "", false
|
|
}
|
|
s = strings.TrimPrefix(s, "'")
|
|
s = strings.TrimPrefix(s, "\"")
|
|
return s, true
|
|
}
|
|
|
|
// canonicalOptionsMigrationKey gates the one-time pass that re-serializes
|
|
// stored docker options into the canonical form. Options drained out of the
|
|
// legacy DOCKER_OPTIONS_<PHASE> flat files by releases before this one were
|
|
// copied verbatim, so they never matched the canonically re-serialized string
|
|
// `docker-options:remove` compares against.
|
|
const canonicalOptionsMigrationKey = "migrated-canonical-options"
|
|
|
|
// migrateNonCanonicalOptions rewrites stored docker options into the canonical
|
|
// form so the whole store uses the same representation `docker-options:add`
|
|
// writes. Two things get repaired: values whose shell metacharacters were never
|
|
// quoted, and lines carrying several flags, which are split into one entry per
|
|
// flag so a single flag can be removed and so the prefix-matching readers
|
|
// (`getRestartPolicy`, `GetSpecifiedDockerOptionsForPhase`) see one value per
|
|
// entry. Canonicalization is idempotent, so options written through the CLI are
|
|
// left untouched. It runs once, guarded by a global property.
|
|
func migrateNonCanonicalOptions() error {
|
|
if common.PropertyGet("docker-options", "--global", canonicalOptionsMigrationKey) == "true" {
|
|
return nil
|
|
}
|
|
|
|
apps, err := common.DokkuApps()
|
|
if err != nil {
|
|
if errors.Is(err, common.NoAppsExist) {
|
|
return common.PropertyWrite("docker-options", "--global", canonicalOptionsMigrationKey, "true")
|
|
}
|
|
return err
|
|
}
|
|
|
|
for _, appName := range apps {
|
|
properties, err := common.PropertyGetAll("docker-options", appName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for key := range properties {
|
|
processType, phase, ok := splitPropertyKey(key)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
options, err := GetDockerOptionsForProcessPhase(appName, processType, phase)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
canonical := canonicalizeOptionLines(options)
|
|
if slices.Equal(canonical, options) {
|
|
continue
|
|
}
|
|
|
|
if err := writeDockerOptionsForProcessPhase(appName, processType, phase, canonical); err != nil {
|
|
return err
|
|
}
|
|
common.LogInfo1(fmt.Sprintf("Rewrote docker-options into canonical form for %s (%s %s)", appName, processType, phase))
|
|
}
|
|
}
|
|
|
|
return common.PropertyWrite("docker-options", "--global", canonicalOptionsMigrationKey, "true")
|
|
}
|