github.com/zaquestion/lab@v0.25.1/internal/config/config.go (about) 1 package config 2 3 import ( 4 "bufio" 5 "bytes" 6 "crypto/tls" 7 "fmt" 8 "io" 9 "io/ioutil" 10 "net/http" 11 "net/url" 12 "os" 13 "os/exec" 14 "path" 15 "strings" 16 "syscall" 17 18 "github.com/spf13/afero" 19 "github.com/spf13/viper" 20 gitlab "github.com/xanzy/go-gitlab" 21 "github.com/zaquestion/lab/internal/git" 22 "github.com/zaquestion/lab/internal/logger" 23 "golang.org/x/crypto/ssh/terminal" 24 ) 25 26 // Get internal lab logger instance 27 var log = logger.GetInstance() 28 29 const defaultGitLabHost = "https://gitlab.com" 30 31 // MainConfig represents the loaded config 32 var MainConfig *viper.Viper 33 34 // New prompts the user for the default config values to use with lab, and save 35 // them to the provided confpath (default: ~/.config/lab.hcl) 36 func New(confpath string, r io.Reader) error { 37 var ( 38 reader = bufio.NewReader(r) 39 host, token, loadToken string 40 err error 41 ) 42 43 confpath = path.Join(confpath, "lab.toml") 44 // If core host is set in the environment (LAB_CORE_HOST) we only want 45 // to prompt for the token. We'll use the environments host and place 46 // it in the config. In the event both the host and token are in the 47 // env, this function shouldn't be called in the first place 48 if MainConfig.GetString("core.host") == "" { 49 fmt.Printf("Enter GitLab host (default: %s): ", defaultGitLabHost) 50 host, err = reader.ReadString('\n') 51 host = strings.TrimSpace(host) 52 if err != nil { 53 return err 54 } 55 if host == "" { 56 host = defaultGitLabHost 57 } 58 } else { 59 // Required to correctly write config 60 host = MainConfig.GetString("core.host") 61 } 62 63 MainConfig.Set("core.host", host) 64 65 token, loadToken, err = readPassword(*reader) 66 if err != nil { 67 return err 68 } 69 if token != "" { 70 MainConfig.Set("core.token", token) 71 } else if loadToken != "" { 72 MainConfig.Set("core.load_token", loadToken) 73 } 74 75 if err := MainConfig.WriteConfigAs(confpath); err != nil { 76 return err 77 } 78 fmt.Printf("\nConfig saved to %s\n", confpath) 79 err = MainConfig.ReadInConfig() 80 if err != nil { 81 log.Fatal(err) 82 UserConfigError() 83 } 84 return nil 85 } 86 87 var readPassword = func(reader bufio.Reader) (string, string, error) { 88 var loadToken string 89 90 if strings.TrimSpace(os.Getenv("LAB_CORE_TOKEN")) != "" { 91 return strings.TrimSpace(os.Getenv("LAB_CORE_TOKEN")), "", nil 92 } 93 94 tokenURL, err := url.Parse(MainConfig.GetString("core.host")) 95 if err != nil { 96 return "", "", err 97 } 98 tokenURL.Path = "/-/profile/personal_access_tokens" 99 100 fmt.Printf("Create a token with scope 'api' here: %s\nEnter default GitLab token, or leave blank to provide a command to load the token: ", tokenURL.String()) 101 byteToken, err := terminal.ReadPassword(int(syscall.Stdin)) 102 if err != nil { 103 return "", "", err 104 } 105 if strings.TrimSpace(string(byteToken)) == "" { 106 fmt.Printf("\nEnter command to load the token:") 107 loadToken, err = reader.ReadString('\n') 108 if err != nil { 109 return "", "", err 110 } 111 } 112 113 if strings.TrimSpace(string(byteToken)) == "" && strings.TrimSpace(loadToken) == "" { 114 log.Fatal("Error: No token provided. A token can be created at ", tokenURL.String()) 115 } 116 return strings.TrimSpace(string(byteToken)), strings.TrimSpace(loadToken), nil 117 } 118 119 // CI returns credentials suitable for use within GitLab CI or empty strings if 120 // none found. 121 func CI() (string, string, string) { 122 ciToken := os.Getenv("CI_JOB_TOKEN") 123 if ciToken == "" { 124 return "", "", "" 125 } 126 log.Debugln("Loaded CI_JOB_TOKEN environment variable") 127 ciHost := strings.TrimSuffix(os.Getenv("CI_PROJECT_URL"), os.Getenv("CI_PROJECT_PATH")) 128 if ciHost == "" { 129 return "", "", "" 130 } 131 log.Debugln("Parsed CI_PROJECT_URL environment variable:", ciHost) 132 ciUser := os.Getenv("GITLAB_USER_LOGIN") 133 log.Debugln("Loaded GITLAB_USER_LOGIN environment variable:", ciUser) 134 135 return ciHost, ciUser, ciToken 136 } 137 138 // ConvertHCLtoTOML converts an .hcl file to a .toml file 139 func ConvertHCLtoTOML(oldpath string, newpath string, file string) { 140 oldconfig := oldpath + "/" + file + ".hcl" 141 newconfig := newpath + "/" + file + ".toml" 142 143 if _, err := os.Stat(oldconfig); os.IsNotExist(err) { 144 return 145 } 146 147 if _, err := os.Stat(newconfig); err == nil { 148 return 149 } 150 151 // read in the old config HCL file and write out the new TOML file 152 oldConfig := viper.New() 153 oldConfig.SetConfigName("lab") 154 oldConfig.SetConfigType("hcl") 155 oldConfig.AddConfigPath(oldpath) 156 oldConfig.ReadInConfig() 157 oldConfig.SetConfigType("toml") 158 oldConfig.WriteConfigAs(newconfig) 159 160 // delete the old config HCL file 161 if err := os.Remove(oldconfig); err != nil { 162 fmt.Println("Warning: Could not delete old config file", oldconfig) 163 } 164 165 // HACK 166 // viper HCL parsing is broken and simply translating it to a TOML file 167 // results in a broken toml file. The issue is that there are double 168 // square brackets for each entry where there should be single 169 // brackets. Note: this hack only works because the config file is 170 // simple and doesn't contain deeply embedded config entries. 171 text, err := ioutil.ReadFile(newconfig) 172 if err != nil { 173 log.Fatal(err) 174 } 175 176 text = bytes.Replace(text, []byte("[["), []byte("["), -1) 177 text = bytes.Replace(text, []byte("]]"), []byte("]"), -1) 178 179 if err = ioutil.WriteFile(newconfig, text, 0666); err != nil { 180 fmt.Println(err) 181 os.Exit(1) 182 } 183 // END HACK 184 185 fmt.Println("INFO: Converted old config", oldconfig, "to new config", newconfig) 186 } 187 188 func getUser(host, token string, skipVerify bool) string { 189 user := MainConfig.GetString("core.user") 190 if user != "" { 191 return user 192 } 193 194 httpClient := &http.Client{ 195 Transport: &http.Transport{ 196 TLSClientConfig: &tls.Config{ 197 InsecureSkipVerify: skipVerify, 198 }, 199 }, 200 } 201 lab, _ := gitlab.NewClient(token, gitlab.WithHTTPClient(httpClient), gitlab.WithBaseURL(host+"/api/v4")) 202 u, _, err := lab.Users.CurrentUser() 203 if err != nil { 204 log.Infoln(err) 205 UserConfigError() 206 } 207 208 if strings.TrimSpace(os.Getenv("LAB_CORE_TOKEN")) == "" && strings.TrimSpace(os.Getenv("LAB_CORE_HOST")) == "" { 209 MainConfig.Set("core.user", u.Username) 210 MainConfig.WriteConfig() 211 } 212 213 return u.Username 214 } 215 216 // GetToken returns a token string from the config file. 217 // The token string can be cleartext or returned from a password manager or 218 // encryption utility. 219 func GetToken() string { 220 token := MainConfig.GetString("core.token") 221 if token == "" && MainConfig.GetString("core.load_token") != "" { 222 // args[0] isn't really an arg ;) 223 args := strings.Split(MainConfig.GetString("core.load_token"), " ") 224 _token, err := exec.Command(args[0], args[1:]...).Output() 225 if err != nil { 226 log.Infoln(err) 227 UserConfigError() 228 } 229 token = string(_token) 230 // tools like pass and a simple bash script add a '\n' to 231 // their output which confuses the gitlab WebAPI 232 if token[len(token)-1:] == "\n" { 233 token = strings.TrimSuffix(token, "\n") 234 } 235 } 236 return token 237 } 238 239 // LoadMainConfig loads the main config file and returns a tuple of 240 // host, user, token, ca_file, skipVerify 241 func LoadMainConfig() (string, string, string, string, bool) { 242 // The lab config heirarchy is: 243 // 1. ENV variables (LAB_CORE_TOKEN, LAB_CORE_HOST) 244 // - if specified, core.token and core.host values in 245 // config files are not updated. 246 // 2. "dot" . user specified config 247 // - if specified, lower order config files will not override 248 // the user specified config 249 // 3. .config/lab/lab.toml (global config) 250 // 4. .git/lab/lab.toml or .git/worktrees/<name>/lab/lab.toml 251 // (worktree config) 252 // 253 // Values from the worktree config will override any global config settings. 254 255 // Try to find XDG_CONFIG_HOME which is declared in XDG base directory 256 // specification and use it's location as the config directory 257 confpath := os.Getenv("XDG_CONFIG_HOME") 258 if confpath == "" { 259 home, err := os.UserHomeDir() 260 if err != nil { 261 log.Fatal(err) 262 } 263 confpath = path.Join(home, ".config") 264 } 265 labconfpath := confpath + "/lab" 266 if _, err := os.Stat(labconfpath); os.IsNotExist(err) { 267 os.MkdirAll(labconfpath, 0700) 268 } 269 270 // Convert old hcl files to toml format. 271 // NO NEW FILES SHOULD BE ADDED BELOW. 272 ConvertHCLtoTOML(confpath, labconfpath, "lab") 273 ConvertHCLtoTOML(".", ".", "lab") 274 var labgitDir string 275 gitDir, err := git.Dir() 276 if err == nil { 277 labgitDir = gitDir + "/lab" 278 ConvertHCLtoTOML(gitDir, labgitDir, "lab") 279 ConvertHCLtoTOML(labgitDir, labgitDir, "show_metadata") 280 } 281 282 MainConfig = viper.New() 283 MainConfig.SetConfigName("lab.toml") 284 MainConfig.SetConfigType("toml") 285 // The local path (aka 'dot slash') does not allow for any 286 // overrides from the work tree lab.toml 287 MainConfig.AddConfigPath(".") 288 MainConfig.AddConfigPath(labconfpath) 289 if labgitDir != "" { 290 MainConfig.AddConfigPath(labgitDir) 291 } 292 293 MainConfig.SetEnvPrefix("LAB") 294 MainConfig.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 295 MainConfig.AutomaticEnv() 296 297 if _, ok := MainConfig.ReadInConfig().(viper.ConfigFileNotFoundError); ok { 298 // Create a new config 299 err := New(labconfpath, os.Stdin) 300 if err != nil { 301 log.Fatal(err) 302 } 303 } else { 304 // Config already exists. Merge in .git/lab/lab.toml file 305 _, err := os.Stat(labgitDir + "/lab.toml") 306 if MainConfig.ConfigFileUsed() == labconfpath+"/lab.toml" && !os.IsNotExist(err) { 307 file, err := afero.ReadFile(afero.NewOsFs(), labgitDir+"/lab.toml") 308 if err != nil { 309 log.Fatal(err) 310 } 311 MainConfig.MergeConfig(bytes.NewReader(file)) 312 } 313 } 314 315 // Attempt to auto-configure for GitLab CI. This *MUST* be called 316 // after the initialization of the MainConfig. This will return 317 // the config file's merged config data with the host, user, and 318 // token supplied by GitLab's CI. 319 host, user, token := CI() 320 if host != "" && user != "" && token != "" { 321 return host, user, token, "", false 322 } 323 324 if !MainConfig.IsSet("core.host") { 325 host = defaultGitLabHost 326 } else { 327 host = MainConfig.GetString("core.host") 328 } 329 330 if token = GetToken(); token == "" { 331 UserConfigError() 332 } 333 334 caFile := MainConfig.GetString("tls.ca_file") 335 tlsSkipVerify := MainConfig.GetBool("tls.skip_verify") 336 user = getUser(host, token, tlsSkipVerify) 337 338 return host, user, token, caFile, tlsSkipVerify 339 } 340 341 // default path of worktree lab.toml file 342 var WorktreeConfigName string = "lab" 343 344 // worktreeConfigPath gets the current git config path using the 345 // `git rev-parse` command, which considers the worktree's gitdir path placed 346 // into the .git file. 347 func worktreeConfigPath() string { 348 gitDir, err := git.Dir() 349 if err != nil { 350 log.Fatal(err) 351 } 352 353 return gitDir + "/lab" 354 } 355 356 // LoadConfig loads a config file specified by configpath and configname. 357 // The configname must not have a '.toml' extension. If configpath and/or 358 // configname are unspecified, the worktree defaults will be used. 359 func LoadConfig(configpath string, configname string) *viper.Viper { 360 targetConfig := viper.New() 361 targetConfig.SetConfigType("toml") 362 363 if configpath == "" { 364 configpath = worktreeConfigPath() 365 } 366 if configname == "" { 367 configname = WorktreeConfigName 368 } 369 targetConfig.AddConfigPath(configpath) 370 targetConfig.SetConfigName(configname) 371 if _, ok := targetConfig.ReadInConfig().(viper.ConfigFileNotFoundError); ok { 372 if _, err := os.Stat(configpath); os.IsNotExist(err) { 373 os.MkdirAll(configpath, os.ModePerm) 374 } 375 if err := targetConfig.WriteConfigAs(configpath + "/" + configname + ".toml"); err != nil { 376 log.Fatal(err) 377 } 378 if err := targetConfig.ReadInConfig(); err != nil { 379 log.Fatal(err) 380 } 381 } 382 return targetConfig 383 } 384 385 // WriteConfigEntry writes a value specified by desc and value to the 386 // configfile specified by configpath and configname. If configpath and/or 387 // configname are unspecified, the worktree defaults will be used. 388 func WriteConfigEntry(desc string, value interface{}, configpath string, configname string) { 389 targetConfig := LoadConfig(configpath, configname) 390 targetConfig.Set(desc, value) 391 targetConfig.WriteConfig() 392 } 393 394 // UserConfigError returns a default error message about authentication 395 func UserConfigError() { 396 fmt.Println("Error: User authentication failed. This is likely due to a misconfigured Personal Access Token. Verify the token or token_load config settings before attempting to authenticate.") 397 os.Exit(1) 398 }