mirror of
https://github.com/dokku/dokku.git
synced 2026-07-11 04:51:57 +02:00
Multi-flag inputs (e.g. `--build-arg X=Y --link a --link b`) used to be stored as a single line, which bypassed the per-line filter that drops `--link` and similar flags for dockerfile-based builders. Each `--flag [value]` group is now stored as its own entry, and a `--process` typed after the app name is lifted into the subcommand flag instead of being stored as a docker option.
355 lines
11 KiB
Go
355 lines
11 KiB
Go
package dockeroptions
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/dokku/dokku/plugins/common"
|
|
"mvdan.cc/sh/v3/shell"
|
|
)
|
|
|
|
// 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_"
|
|
|
|
// SplitOptionString shell-tokenizes input, groups tokens on flag boundaries,
|
|
// and returns one re-serialized option per group. A docker-options subcommand
|
|
// flag (currently just --process) that lands inside the option content -
|
|
// because the user typed it after the app name, where pflag's
|
|
// SetInterspersed(false) hands it back as positional - is lifted into the
|
|
// returned processes slice rather than stored as a docker option. The caller
|
|
// merges those processes with whatever pflag already captured. Empty or
|
|
// whitespace-only input returns empty slices.
|
|
func SplitOptionString(input string) (options []string, processes []string, err error) {
|
|
if strings.TrimSpace(input) == "" {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
fields, err := shell.Fields(input, func(string) string { return "" })
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("Unable to parse docker option: %s", err.Error())
|
|
}
|
|
|
|
var current []string
|
|
flush := func() error {
|
|
if len(current) == 0 {
|
|
return nil
|
|
}
|
|
head := current[0]
|
|
if head == "--process" {
|
|
if len(current) < 2 {
|
|
return fmt.Errorf("--process requires a value")
|
|
}
|
|
if len(current) > 2 {
|
|
return fmt.Errorf("--process accepts a single value, got %d", len(current)-1)
|
|
}
|
|
processes = append(processes, current[1])
|
|
current = nil
|
|
return nil
|
|
}
|
|
if strings.HasPrefix(head, "--process=") {
|
|
if len(current) > 1 {
|
|
return fmt.Errorf("--process=value cannot be followed by additional tokens")
|
|
}
|
|
processes = append(processes, head[len("--process="):])
|
|
current = nil
|
|
return nil
|
|
}
|
|
options = append(options, joinShellTokens(current))
|
|
current = nil
|
|
return nil
|
|
}
|
|
|
|
for _, tok := range fields {
|
|
if isFlagToken(tok) && len(current) > 0 {
|
|
if err := flush(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
current = append(current, tok)
|
|
}
|
|
if err := flush(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return options, processes, nil
|
|
}
|
|
|
|
// isFlagToken reports whether tok looks like a CLI flag (long or short) rather
|
|
// than a value. Treating any token that begins with `-` and has more than one
|
|
// character as a flag matches docker's flag conventions and avoids the need
|
|
// for a per-flag whitelist.
|
|
func isFlagToken(tok string) bool {
|
|
return len(tok) > 1 && tok[0] == '-'
|
|
}
|
|
|
|
// joinShellTokens joins tokens into a single string suitable for storage,
|
|
// shell-quoting any token that contains characters the bash scheduler's
|
|
// `eval` re-tokenization would interpret. The stored line round-trips through
|
|
// `eval set -- "$line"` back to the original token slice.
|
|
func joinShellTokens(tokens []string) string {
|
|
parts := make([]string, len(tokens))
|
|
for i, tok := range tokens {
|
|
parts[i] = quoteShellArg(tok)
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// quoteShellArg returns s wrapped in single quotes when it contains characters
|
|
// the shell would otherwise interpret (whitespace, quotes, expansion sigils,
|
|
// globs, redirections, etc.). Embedded single quotes are escaped with the
|
|
// standard `'\''` close-escape-open sequence. Tokens free of such characters
|
|
// are returned verbatim so the stored representation stays human-readable for
|
|
// the common case.
|
|
func quoteShellArg(s string) string {
|
|
if s == "" {
|
|
return "''"
|
|
}
|
|
if !needsShellQuoting(s) {
|
|
return s
|
|
}
|
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
|
}
|
|
|
|
func needsShellQuoting(s string) bool {
|
|
for _, r := range s {
|
|
switch r {
|
|
case ' ', '\t', '\n', '"', '\'', '$', '`', '\\',
|
|
'*', '?', '[', ']', '<', '>', '|', '&', ';',
|
|
'(', ')', '{', '}', '!', '#', '~':
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func propertyKey(processType, phase string) string {
|
|
if processType == "" {
|
|
processType = DefaultProcessType
|
|
}
|
|
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 {
|
|
return SetDockerOptionForProcessPhases(appName, []string{DefaultProcessType}, phases, name, value)
|
|
}
|
|
|
|
// 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 := []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 := writeDockerOptionsForProcessPhase(appName, processType, phase, newOptions); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddDockerOptionToPhases adds an option to the default scope for the specified phases.
|
|
func AddDockerOptionToPhases(appName string, phases []string, option string) error {
|
|
return AddDockerOptionToProcessPhases(appName, []string{DefaultProcessType}, phases, option)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
options = append(options, option)
|
|
sort.Strings(options)
|
|
if err := writeDockerOptionsForProcessPhase(appName, processType, phase, options); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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 (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)
|
|
if err != nil {
|
|
return foundOptions, err
|
|
}
|
|
|
|
for _, option := range options {
|
|
for _, desiredOption := range desiredOptions {
|
|
if option == desiredOption {
|
|
foundOptions[desiredOption] = []string{}
|
|
break
|
|
}
|
|
|
|
if strings.HasPrefix(option, fmt.Sprintf("%s=", desiredOption)) {
|
|
if _, ok := foundOptions[desiredOption]; !ok {
|
|
foundOptions[desiredOption] = []string{}
|
|
}
|
|
|
|
parts := strings.SplitN(option, "=", 2)
|
|
if len(parts) != 2 {
|
|
common.LogWarn(fmt.Sprintf("Invalid docker option found for %s: %s", appName, option))
|
|
continue
|
|
}
|
|
|
|
foundOptions[desiredOption] = append(foundOptions[desiredOption], parts[1])
|
|
break
|
|
}
|
|
|
|
if strings.HasPrefix(option, fmt.Sprintf("%s ", desiredOption)) {
|
|
if _, ok := foundOptions[desiredOption]; !ok {
|
|
foundOptions[desiredOption] = []string{}
|
|
}
|
|
|
|
parts := strings.SplitN(option, " ", 2)
|
|
if len(parts) != 2 {
|
|
common.LogWarn(fmt.Sprintf("Invalid docker option found for %s: %s", appName, option))
|
|
continue
|
|
}
|
|
|
|
foundOptions[desiredOption] = append(foundOptions[desiredOption], parts[1])
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|