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  }