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 }