github.com/github/skeema@v1.2.6/util/config.go (about)

     1  package util
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"os"
     7  	"path"
     8  	"path/filepath"
     9  	"regexp"
    10  	"strings"
    11  
    12  	log "github.com/sirupsen/logrus"
    13  	"github.com/skeema/mybase"
    14  	"golang.org/x/crypto/ssh/terminal"
    15  )
    16  
    17  // AddGlobalOptions adds Skeema global options to the supplied mybase.Command.
    18  // Typically cmd should be the top-level Command / Command Suite.
    19  func AddGlobalOptions(cmd *mybase.Command) {
    20  	// Options typically only found in .skeema files -- all hidden by default
    21  	cmd.AddOption(mybase.StringOption("host", 0, "", "Database hostname or IP address").Hidden())
    22  	cmd.AddOption(mybase.StringOption("port", 0, "3306", "Port to use for database host").Hidden())
    23  	cmd.AddOption(mybase.StringOption("socket", 'S', "/tmp/mysql.sock", "Absolute path to Unix socket file used if host is localhost").Hidden())
    24  	cmd.AddOption(mybase.StringOption("schema", 0, "", "Database schema name").Hidden())
    25  	cmd.AddOption(mybase.StringOption("ignore-schema", 0, "", "Ignore schemas that match regex").Hidden())
    26  	cmd.AddOption(mybase.StringOption("ignore-table", 0, "", "Ignore tables that match regex").Hidden())
    27  	cmd.AddOption(mybase.StringOption("default-character-set", 0, "", "Schema-level default character set").Hidden())
    28  	cmd.AddOption(mybase.StringOption("default-collation", 0, "", "Schema-level default collation").Hidden())
    29  	cmd.AddOption(mybase.StringOption("flavor", 0, "", "Database server expressed in format vendor:major.minor, for use in vendor/version specific syntax").Hidden())
    30  
    31  	// Visible global options
    32  	cmd.AddOption(mybase.StringOption("user", 'u', "root", "Username to connect to database host"))
    33  	cmd.AddOption(mybase.StringOption("password", 'p', "", "Password for database user; omit value to prompt from TTY (default no password)").ValueOptional())
    34  	cmd.AddOption(mybase.StringOption("host-wrapper", 'H', "", "External bin to shell out to for host lookup; see manual for template vars"))
    35  	cmd.AddOption(mybase.StringOption("temp-schema", 't', "_skeema_tmp", "Name of temporary schema for intermediate operations, created and dropped each run unless --reuse-temp-schema"))
    36  	cmd.AddOption(mybase.StringOption("connect-options", 'o', "", "Comma-separated session options to set upon connecting to each database instance"))
    37  	cmd.AddOption(mybase.StringOption("workspace", 'w', "TEMP-SCHEMA", `Specifies where to run intermediate operations (valid values: "TEMP-SCHEMA", "DOCKER")`))
    38  	cmd.AddOption(mybase.StringOption("docker-cleanup", 0, "NONE", `With --workspace=docker, specifies how to clean up containers (valid values: "NONE", "STOP", "DESTROY")`))
    39  	cmd.AddOption(mybase.BoolOption("reuse-temp-schema", 0, false, "Do not drop temp-schema when done"))
    40  	cmd.AddOption(mybase.BoolOption("debug", 0, false, "Enable debug logging"))
    41  	cmd.AddOption(mybase.BoolOption("my-cnf", 0, true, "Parse ~/.my.cnf for configuration"))
    42  }
    43  
    44  // AddGlobalConfigFiles takes the mybase.Config generated from the CLI and adds
    45  // global option files as sources.
    46  func AddGlobalConfigFiles(cfg *mybase.Config) {
    47  	globalFilePaths := make([]string, 0, 4)
    48  
    49  	// Avoid using "real" global paths in test logic. Otherwise, if the user
    50  	// running the test happens to have a ~/.my.cnf, ~/.skeema, /etc/skeema, it
    51  	// it would affect the test logic.
    52  	if cfg.IsTest {
    53  		globalFilePaths = append(globalFilePaths, "fake-etc/skeema", "fake-home/.my.cnf")
    54  	} else {
    55  		globalFilePaths = append(globalFilePaths, "/etc/skeema", "/usr/local/etc/skeema")
    56  		home := filepath.Clean(os.Getenv("HOME"))
    57  		if home != "" {
    58  			globalFilePaths = append(globalFilePaths, path.Join(home, ".my.cnf"), path.Join(home, ".skeema"))
    59  		}
    60  	}
    61  
    62  	for _, path := range globalFilePaths {
    63  		f := mybase.NewFile(path)
    64  		if !f.Exists() {
    65  			continue
    66  		}
    67  		if err := f.Read(); err != nil {
    68  			log.Warnf("Ignoring global option file %s due to read error: %s", f.Path(), err)
    69  			continue
    70  		}
    71  		if strings.HasSuffix(path, ".my.cnf") {
    72  			f.IgnoreUnknownOptions = true
    73  			f.IgnoreOptions("host")
    74  			if !cfg.GetBool("my-cnf") {
    75  				continue
    76  			}
    77  		}
    78  		if err := f.Parse(cfg); err != nil {
    79  			log.Warnf("Ignoring global option file %s due to parse error: %s", f.Path(), err)
    80  			continue
    81  		}
    82  		if strings.HasSuffix(path, ".my.cnf") {
    83  			_ = f.UseSection("skeema", "client", "mysql") // safe to ignore error (doesn't matter if section doesn't exist)
    84  		} else if cfg.CLI.Command.HasArg("environment") { // avoid panic on command without environment arg, such as help command!
    85  			_ = f.UseSection(cfg.Get("environment")) // safe to ignore error (doesn't matter if section doesn't exist)
    86  		}
    87  
    88  		cfg.AddSource(f)
    89  	}
    90  }
    91  
    92  // ProcessSpecialGlobalOptions performs special handling of global options with
    93  // unusual semantics -- handling restricted placement of host and schema;
    94  // obtaining a password from MYSQL_PWD or STDIN; enable debug logging.
    95  func ProcessSpecialGlobalOptions(cfg *mybase.Config) error {
    96  	// The host and schema options are special -- most commands only expect
    97  	// to find them when recursively crawling directory configs. So if these
    98  	// options have been set globally (via CLI or a global config file), and
    99  	// the current subcommand hasn't explicitly overridden these options (as
   100  	// init and add-environment do), return an error.
   101  	cmdSuite := cfg.CLI.Command.Root()
   102  	for _, name := range []string{"host", "schema"} {
   103  		if cfg.Changed(name) && cfg.FindOption(name) == cmdSuite.Options()[name] {
   104  			return fmt.Errorf("Option %s cannot be set via %s for this command", name, cfg.Source(name))
   105  		}
   106  	}
   107  
   108  	// Special handling for password option: if not supplied at all, check env
   109  	// var instead. Or if supplied but with no equals sign or value, prompt on
   110  	// STDIN like mysql client does.
   111  	if !cfg.Supplied("password") {
   112  		if val := os.Getenv("MYSQL_PWD"); val != "" {
   113  			cfg.CLI.OptionValues["password"] = val
   114  			cfg.MarkDirty()
   115  		}
   116  	} else if !cfg.SuppliedWithValue("password") {
   117  		var err error
   118  		cfg.CLI.OptionValues["password"], err = PromptPassword()
   119  		cfg.MarkDirty()
   120  		fmt.Println()
   121  		if err != nil {
   122  			return err
   123  		}
   124  	}
   125  
   126  	if cfg.GetBool("debug") {
   127  		log.SetLevel(log.DebugLevel)
   128  	}
   129  
   130  	return nil
   131  }
   132  
   133  // PromptPassword reads a password from STDIN without echoing the typed
   134  // characters. Requires that STDIN is a TTY.
   135  func PromptPassword() (string, error) {
   136  	stdin := int(os.Stdin.Fd())
   137  	if !terminal.IsTerminal(stdin) {
   138  		return "", errors.New("STDIN must be a TTY to read password")
   139  	}
   140  	fmt.Printf("Enter password: ")
   141  	bytePassword, err := terminal.ReadPassword(stdin)
   142  	if err != nil {
   143  		return "", err
   144  	}
   145  	return string(bytePassword), nil
   146  }
   147  
   148  // SplitConnectOptions takes a string containing a comma-separated list of
   149  // connection options (typically obtained from the "connect-options" option)
   150  // and splits them into a map of individual key: value strings. This function
   151  // understands single-quoted values may contain commas, and will properly
   152  // treat them not as delimiters. Single-quoted values may also include escaped
   153  // single quotes, and values in general may contain escaped commas; these are
   154  // all also treated properly.
   155  func SplitConnectOptions(connectOpts string) (map[string]string, error) {
   156  	result := make(map[string]string)
   157  	if len(connectOpts) == 0 {
   158  		return result, nil
   159  	}
   160  	if connectOpts[len(connectOpts)-1] == '\\' {
   161  		return result, fmt.Errorf("Trailing backslash in connect-options \"%s\"", connectOpts)
   162  	}
   163  
   164  	var startToken int
   165  	var name string
   166  	var inQuote, escapeNext bool
   167  	for n, c := range connectOpts + "," {
   168  		if escapeNext {
   169  			escapeNext = false
   170  			continue
   171  		}
   172  		if inQuote && c != '\'' && c != '\\' {
   173  			continue
   174  		}
   175  		switch c {
   176  		case '\'':
   177  			if name == "" {
   178  				return result, fmt.Errorf("Invalid quote character in option name at byte offset %d in connect-options \"%s\"", n, connectOpts)
   179  			}
   180  			inQuote = !inQuote
   181  		case '\\':
   182  			escapeNext = true
   183  		case '=':
   184  			if name == "" {
   185  				name = connectOpts[startToken:n]
   186  				startToken = n + 1
   187  			} else {
   188  				return result, fmt.Errorf("Invalid equals-sign character in option value at byte offset %d in connect-options \"%s\"", n, connectOpts)
   189  			}
   190  		case ',':
   191  			if startToken == n { // comma directly after equals sign, comma, or start of string
   192  				return result, fmt.Errorf("Invalid comma placement in option value at byte offset %d in connect-options \"%s\"", n, connectOpts)
   193  			}
   194  			if name == "" {
   195  				return result, fmt.Errorf("Option %s is missing a value at byte offset %d in connect-options \"%s\"", connectOpts[startToken:n], n, connectOpts)
   196  			}
   197  			if _, already := result[name]; already {
   198  				// Disallow this since it's inherently ordering-dependent, and would
   199  				// further complicate RealConnectOptions logic
   200  				return result, fmt.Errorf("Option %s is set multiple times in connect-options \"%s\"", name, connectOpts)
   201  			}
   202  			result[name] = connectOpts[startToken:n]
   203  			name = ""
   204  			startToken = n + 1
   205  		}
   206  	}
   207  
   208  	if inQuote {
   209  		return result, fmt.Errorf("Unterminated quote in connect-options \"%s\"", connectOpts)
   210  	}
   211  	return result, nil
   212  }
   213  
   214  // RealConnectOptions takes a comma-separated string of connection options,
   215  // strips any Go driver-specific ones, and then returns the new string which
   216  // is now suitable for passing to an external tool.
   217  func RealConnectOptions(connectOpts string) (string, error) {
   218  	// list of lowercased versions of all go-sql-driver/mysql special params
   219  	ignored := map[string]bool{
   220  		"allowallfiles":           true, // banned in Dir.InstanceDefaultParams, listed here for sake of completeness
   221  		"allowcleartextpasswords": true,
   222  		"allownativepasswords":    true,
   223  		"allowoldpasswords":       true,
   224  		"charset":                 true,
   225  		"clientfoundrows":         true, // banned in Dir.InstanceDefaultParams, listed here for sake of completeness
   226  		"collation":               true,
   227  		"columnswithalias":        true, // banned in Dir.InstanceDefaultParams, listed here for sake of completeness
   228  		"interpolateparams":       true, // banned in Dir.InstanceDefaultParams, listed here for sake of completeness
   229  		"loc":                     true, // banned in Dir.InstanceDefaultParams, listed here for sake of completeness
   230  		"maxallowedpacket":        true,
   231  		"multistatements":         true, // banned in Dir.InstanceDefaultParams, listed here for sake of completeness
   232  		"parsetime":               true, // banned in Dir.InstanceDefaultParams, listed here for sake of completeness
   233  		"readtimeout":             true,
   234  		"strict":                  true, // banned in Dir.InstanceDefaultParams, listed here for sake of completeness
   235  		"timeout":                 true,
   236  		"tls":                     true,
   237  		"writetimeout":            true,
   238  	}
   239  
   240  	options, err := SplitConnectOptions(connectOpts)
   241  	if err != nil {
   242  		return "", err
   243  	}
   244  
   245  	// Iterate through the returned map, and remove any driver-specific options.
   246  	// This is done via regular expressions substitution in order to keep the
   247  	// string in its original order.
   248  	for name, value := range options {
   249  		if ignored[strings.ToLower(name)] {
   250  			re, err := regexp.Compile(fmt.Sprintf(`%s=%s(,|$)`, regexp.QuoteMeta(name), regexp.QuoteMeta(value)))
   251  			if err != nil {
   252  				return "", err
   253  			}
   254  			connectOpts = re.ReplaceAllString(connectOpts, "")
   255  		}
   256  	}
   257  	if len(connectOpts) > 0 && connectOpts[len(connectOpts)-1] == ',' {
   258  		connectOpts = connectOpts[0 : len(connectOpts)-1]
   259  	}
   260  	return connectOpts, nil
   261  }