github.com/wtrep/tgf@v1.18.8/config.go (about)

     1  package main
     2  
     3  import (
     4  	"crypto/md5"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"io/ioutil"
     9  	"os"
    10  	"os/exec"
    11  	"os/user"
    12  	"path"
    13  	"path/filepath"
    14  	"reflect"
    15  	"regexp"
    16  	"strings"
    17  	"time"
    18  
    19  	"github.com/aws/aws-sdk-go/service/ssm"
    20  	"github.com/blang/semver"
    21  	"github.com/coveo/gotemplate/collections"
    22  	"github.com/gruntwork-io/terragrunt/aws_helper"
    23  	"github.com/hashicorp/go-getter"
    24  	yaml "gopkg.in/yaml.v2"
    25  )
    26  
    27  const (
    28  	// ssm configuration
    29  	defaultSSMParameterFolder     = "/default/tgf"
    30  	ssmParameterFolderEnvVariable = "TGF_SSM_PATH"
    31  
    32  	// ssm configuration used to fetch configs from a remote location
    33  	remoteDefaultConfigPath       = "TGFConfig"
    34  	remoteConfigLocationParameter = "config-location"
    35  	remoteConfigPathsParameter    = "config-paths"
    36  
    37  	// configuration files
    38  	configFile     = ".tgf.config"
    39  	userConfigFile = "tgf.user.config"
    40  
    41  	tagSeparator = "-"
    42  )
    43  
    44  // TGFConfig contains the resulting configuration that will be applied
    45  type TGFConfig struct {
    46  	Image                   string            `yaml:"docker-image,omitempty" json:"docker-image,omitempty" hcl:"docker-image,omitempty"`
    47  	ImageVersion            *string           `yaml:"docker-image-version,omitempty" json:"docker-image-version,omitempty" hcl:"docker-image-version,omitempty"`
    48  	ImageTag                *string           `yaml:"docker-image-tag,omitempty" json:"docker-image-tag,omitempty" hcl:"docker-image-tag,omitempty"`
    49  	ImageBuild              string            `yaml:"docker-image-build,omitempty" json:"docker-image-build,omitempty" hcl:"docker-image-build,omitempty"`
    50  	ImageBuildFolder        string            `yaml:"docker-image-build-folder,omitempty" json:"docker-image-build-folder,omitempty" hcl:"docker-image-build-folder,omitempty"`
    51  	ImageBuildTag           string            `yaml:"docker-image-build-tag,omitempty" json:"docker-image-build-tag,omitempty" hcl:"docker-image-build-tag,omitempty"`
    52  	LogLevel                string            `yaml:"logging-level,omitempty" json:"logging-level,omitempty" hcl:"logging-level,omitempty"`
    53  	EntryPoint              string            `yaml:"entry-point,omitempty" json:"entry-point,omitempty" hcl:"entry-point,omitempty"`
    54  	Refresh                 time.Duration     `yaml:"docker-refresh,omitempty" json:"docker-refresh,omitempty" hcl:"docker-refresh,omitempty"`
    55  	DockerOptions           []string          `yaml:"docker-options,omitempty" json:"docker-options,omitempty" hcl:"docker-options,omitempty"`
    56  	RecommendedImageVersion string            `yaml:"recommended-image-version,omitempty" json:"recommended-image-version,omitempty" hcl:"recommended-image-version,omitempty"`
    57  	RequiredVersionRange    string            `yaml:"required-image-version,omitempty" json:"required-image-version,omitempty" hcl:"required-image-version,omitempty"`
    58  	RecommendedTGFVersion   string            `yaml:"tgf-recommended-version,omitempty" json:"tgf-recommended-version,omitempty" hcl:"tgf-recommended-version,omitempty"`
    59  	Environment             map[string]string `yaml:"environment,omitempty" json:"environment,omitempty" hcl:"environment,omitempty"`
    60  	RunBefore               string            `yaml:"run-before,omitempty" json:"run-before,omitempty" hcl:"run-before,omitempty"`
    61  	RunAfter                string            `yaml:"run-after,omitempty" json:"run-after,omitempty" hcl:"run-after,omitempty"`
    62  	Aliases                 map[string]string `yaml:"alias,omitempty" json:"alias,omitempty" hcl:"alias,omitempty"`
    63  
    64  	runBeforeCommands, runAfterCommands []string
    65  	imageBuildConfigs                   []TGFConfigBuild // List of config built from previous build configs
    66  }
    67  
    68  // TGFConfigBuild contains an entry specifying how to customize the current docker image
    69  type TGFConfigBuild struct {
    70  	Instructions string
    71  	Folder       string
    72  	Tag          string
    73  	source       string
    74  }
    75  
    76  func (cb TGFConfigBuild) hash() string {
    77  	h := md5.New()
    78  	io.WriteString(h, filepath.Base(filepath.Dir(cb.source)))
    79  	io.WriteString(h, cb.Instructions)
    80  	if cb.Folder != "" {
    81  		filepath.Walk(cb.Dir(), func(path string, info os.FileInfo, err error) error {
    82  			if info == nil || info.IsDir() || err != nil {
    83  				return nil
    84  			}
    85  			if !strings.Contains(path, dockerfilePattern) {
    86  				io.WriteString(h, fmt.Sprintf("%v", info.ModTime()))
    87  			}
    88  			return nil
    89  		})
    90  	}
    91  	return fmt.Sprintf("%x", h.Sum(nil))
    92  }
    93  
    94  // Dir returns the folder name relative to the source
    95  func (cb TGFConfigBuild) Dir() string {
    96  	if cb.Folder == "" {
    97  		return filepath.Dir(cb.source)
    98  	}
    99  	if filepath.IsAbs(cb.Folder) {
   100  		return cb.Folder
   101  	}
   102  	return must(filepath.Abs(filepath.Join(filepath.Dir(cb.source), cb.Folder))).(string)
   103  }
   104  
   105  // GetTag returns the tag name that should be added to the image
   106  func (cb TGFConfigBuild) GetTag() string {
   107  	tag := filepath.Base(filepath.Dir(cb.source))
   108  	if cb.Tag != "" {
   109  		tag = cb.Tag
   110  	}
   111  	tagRegex := regexp.MustCompile(`[^a-zA-Z0-9\._-]`)
   112  	return tagRegex.ReplaceAllString(tag, "")
   113  }
   114  
   115  // InitConfig returns a properly initialized TGF configuration struct
   116  func InitConfig() *TGFConfig {
   117  	return &TGFConfig{Image: "coveo/tgf",
   118  		Refresh:           1 * time.Hour,
   119  		EntryPoint:        "terragrunt",
   120  		LogLevel:          "notice",
   121  		Environment:       make(map[string]string),
   122  		imageBuildConfigs: []TGFConfigBuild{},
   123  	}
   124  }
   125  
   126  func (config TGFConfig) String() string {
   127  	bytes, err := yaml.Marshal(config)
   128  	if err != nil {
   129  		return fmt.Sprintf("Error parsing TGFConfig: %v", err)
   130  	}
   131  	return string(bytes)
   132  }
   133  
   134  // InitAWS tries to open an AWS session and init AWS environment variable on success
   135  func (config *TGFConfig) InitAWS(profile string) error {
   136  	_, err := aws_helper.InitAwsSession(profile)
   137  	if err != nil {
   138  		return err
   139  	}
   140  
   141  	for _, s := range os.Environ() {
   142  		if strings.HasPrefix(s, "AWS_") {
   143  			split := strings.SplitN(s, "=", 2)
   144  			if len(split) < 2 {
   145  				continue
   146  			}
   147  			config.Environment[split[0]] = split[1]
   148  		}
   149  	}
   150  	return nil
   151  }
   152  
   153  // SetDefaultValues sets the uninitialized values from the config files and the parameter store
   154  // Priorities (Higher overwrites lower values):
   155  // 1. SSM Parameter Config
   156  // 2. Secrets Manager Config (If exists, will not check SSM)
   157  // 3. tgf.user.config
   158  // 4. .tgf.config
   159  func (config *TGFConfig) SetDefaultValues() {
   160  	config.setDefaultValues(getSSMParameterFolder())
   161  }
   162  
   163  func (config *TGFConfig) setDefaultValues(ssmParameterFolder string) {
   164  	type configData struct {
   165  		Name   string
   166  		Raw    string
   167  		Config *TGFConfig
   168  	}
   169  	configsData := []configData{}
   170  
   171  	// Fetch SSM configs
   172  	if awsConfigExist() {
   173  		if err := config.InitAWS(""); err != nil {
   174  			printError("Unable to authentify to AWS: %v\nPararameter store is ignored\n", err)
   175  		} else {
   176  			parameters := must(aws_helper.GetSSMParametersByPath(ssmParameterFolder, "")).([]*ssm.Parameter)
   177  			parameterValues := extractMapFromParameters(ssmParameterFolder, parameters)
   178  
   179  			for _, configFile := range findRemoteConfigFiles(parameterValues) {
   180  				configsData = append(configsData, configData{Name: "RemoteConfigFile", Raw: configFile})
   181  			}
   182  
   183  			// Only fetch SSM parameters if no ConfigFile was found
   184  			if len(configsData) == 0 {
   185  				ssmConfig := parseSsmConfig(parameterValues)
   186  				if ssmConfig != "" {
   187  					configsData = append(configsData, configData{Name: "AWS/ParametersStore", Raw: ssmConfig})
   188  				}
   189  			}
   190  		}
   191  	}
   192  
   193  	// Fetch file configs
   194  	for _, configFile := range findConfigFiles(must(os.Getwd()).(string)) {
   195  		debugPrint("# Reading configuration from %s\n", configFile)
   196  		bytes, err := ioutil.ReadFile(configFile)
   197  
   198  		if err != nil {
   199  			fmt.Fprintln(os.Stderr, errorString("Error while loading configuration file %s\n%v", configFile, err))
   200  			continue
   201  		}
   202  		configsData = append(configsData, configData{Name: configFile, Raw: string(bytes)})
   203  	}
   204  
   205  	// Parse/Unmarshal configs
   206  	for i := range configsData {
   207  		configData := &configsData[i]
   208  		if err := collections.ConvertData(configData.Raw, config); err != nil {
   209  			fmt.Fprintln(os.Stderr, errorString("Error while loading configuration from %s\nConfiguration file must be valid YAML, JSON or HCL\n%v", configData.Name, err))
   210  		}
   211  		collections.ConvertData(configData.Raw, &configData.Config)
   212  	}
   213  
   214  	// Special case for image build configs and run before/after, we must build a list of instructions from all configs
   215  	for i := range configsData {
   216  		configData := &configsData[i]
   217  		if configData.Config.ImageBuild != "" {
   218  			config.imageBuildConfigs = append([]TGFConfigBuild{TGFConfigBuild{
   219  				Instructions: configData.Config.ImageBuild,
   220  				Folder:       configData.Config.ImageBuildFolder,
   221  				Tag:          configData.Config.ImageBuildTag,
   222  				source:       configData.Name,
   223  			}}, config.imageBuildConfigs...)
   224  		}
   225  		if configData.Config.RunBefore != "" {
   226  			config.runBeforeCommands = append(config.runBeforeCommands, configData.Config.RunBefore)
   227  		}
   228  		if configData.Config.RunAfter != "" {
   229  			config.runAfterCommands = append(config.runAfterCommands, configData.Config.RunAfter)
   230  		}
   231  	}
   232  	// We reverse the execution of before scripts to ensure that more specific commands are executed last
   233  	config.runBeforeCommands = collections.AsList(config.runBeforeCommands).Reverse().Strings()
   234  }
   235  
   236  var reVersion = regexp.MustCompile(`(?P<version>\d+\.\d+(?:\.\d+){0,1})`)
   237  
   238  // https://regex101.com/r/ZKt4OP/5
   239  var reImage = regexp.MustCompile(`^(?P<image>.*?)(?::(?:` + reVersion.String() + `(?:(?P<sep>[\.-])(?P<spec>.+))?|(?P<fix>.+)))?$`)
   240  
   241  // Validate ensure that the current version is compliant with the setting (mainly those in the parameter store1)
   242  func (config *TGFConfig) Validate() (errors []error) {
   243  	if strings.Contains(config.Image, ":") {
   244  		errors = append(errors, ConfigWarning(fmt.Sprintf("Image should not contain the version: %s", config.Image)))
   245  	}
   246  
   247  	if config.ImageVersion != nil && strings.ContainsAny(*config.ImageVersion, ":-") {
   248  		errors = append(errors, ConfigWarning(fmt.Sprintf("Image version parameter should not contain the image name nor the specialized version: %s", *config.ImageVersion)))
   249  	}
   250  
   251  	if config.ImageTag != nil && strings.ContainsAny(*config.ImageTag, ":") {
   252  		errors = append(errors, ConfigWarning(fmt.Sprintf("Image tag parameter should not contain the image name: %s", *config.ImageTag)))
   253  	}
   254  
   255  	if config.RecommendedTGFVersion != "" {
   256  		if valid, err := CheckVersionRange(version, config.RecommendedTGFVersion); err != nil {
   257  			errors = append(errors, fmt.Errorf("Unable to check recommended tgf version %s vs %s: %v", version, config.RecommendedTGFVersion, err))
   258  		} else if !valid {
   259  			errors = append(errors, ConfigWarning(fmt.Sprintf("TGF v%s does not meet the recommended version range %s", version, config.RecommendedTGFVersion)))
   260  		}
   261  	}
   262  
   263  	if config.RequiredVersionRange != "" && config.ImageVersion != nil && *config.ImageVersion != "" && reVersion.MatchString(*config.ImageVersion) {
   264  		if valid, err := CheckVersionRange(*config.ImageVersion, config.RequiredVersionRange); err != nil {
   265  			errors = append(errors, fmt.Errorf("Unable to check recommended image version %s vs %s: %v", *config.ImageVersion, config.RequiredVersionRange, err))
   266  			return
   267  		} else if !valid {
   268  			errors = append(errors, VersionMistmatchError(fmt.Sprintf("Image %s does not meet the required version range %s", config.GetImageName(), config.RequiredVersionRange)))
   269  			return
   270  		}
   271  	}
   272  
   273  	if config.RecommendedImageVersion != "" && config.ImageVersion != nil && *config.ImageVersion != "" && reVersion.MatchString(*config.ImageVersion) {
   274  		if valid, err := CheckVersionRange(*config.ImageVersion, config.RecommendedImageVersion); err != nil {
   275  			errors = append(errors, fmt.Errorf("Unable to check recommended image version %s vs %s: %v", *config.ImageVersion, config.RecommendedImageVersion, err))
   276  		} else if !valid {
   277  			errors = append(errors, ConfigWarning(fmt.Sprintf("Image %s does not meet the recommended version range %s", config.GetImageName(), config.RecommendedImageVersion)))
   278  		}
   279  	}
   280  
   281  	return
   282  }
   283  
   284  // GetImageName returns the actual image name
   285  func (config *TGFConfig) GetImageName() string {
   286  	var suffix string
   287  	if config.ImageVersion != nil {
   288  		suffix += *config.ImageVersion
   289  	}
   290  	shouldAddTag := config.ImageVersion == nil || *config.ImageVersion == "" || reVersion.MatchString(*config.ImageVersion)
   291  	if config.ImageTag != nil && shouldAddTag {
   292  		if suffix != "" && *config.ImageTag != "" {
   293  			suffix += tagSeparator
   294  		}
   295  		suffix += *config.ImageTag
   296  	}
   297  	if len(suffix) > 1 {
   298  		return fmt.Sprintf("%s:%s", config.Image, suffix)
   299  	}
   300  	return config.Image
   301  }
   302  
   303  // ParseAliases will parse the original argument list and replace aliases only in the first argument.
   304  func (config *TGFConfig) ParseAliases(args []string) []string {
   305  	if len(args) > 0 {
   306  		if replace := String(config.Aliases[args[0]]); replace != "" {
   307  			var result collections.StringArray
   308  			replace, quoted := replace.Protect()
   309  			result = replace.Fields()
   310  			if len(quoted) > 0 {
   311  				for i := range result {
   312  					result[i] = result[i].RestoreProtected(quoted).Trim(`"`)
   313  				}
   314  			}
   315  			return append(result.Strings(), args[1:]...)
   316  		}
   317  	}
   318  	return nil
   319  }
   320  
   321  func extractMapFromParameters(ssmParameterFolder string, parameters []*ssm.Parameter) map[string]string {
   322  	values := make(map[string]string)
   323  	for _, parameter := range parameters {
   324  		key := strings.TrimLeft(strings.Replace(*parameter.Name, ssmParameterFolder, "", 1), "/")
   325  		values[key] = *parameter.Value
   326  	}
   327  	return values
   328  }
   329  
   330  func findRemoteConfigFiles(parameterValues map[string]string) []string {
   331  	configLocation, configLocationOk := parameterValues[remoteConfigLocationParameter]
   332  	if !configLocationOk || configLocation == "" {
   333  		return []string{}
   334  	}
   335  
   336  	if !strings.HasSuffix(configLocation, "/") {
   337  		configLocation = configLocation + "/"
   338  	}
   339  
   340  	configPaths := []string{remoteDefaultConfigPath}
   341  	if configPathString, configPathsOk := parameterValues[remoteConfigPathsParameter]; configPathsOk && configPathString != "" {
   342  		configPaths = strings.Split(configPathString, ":")
   343  	}
   344  
   345  	tempDir := must(ioutil.TempDir("", "tgf-config-files")).(string)
   346  	defer os.RemoveAll(tempDir)
   347  
   348  	configs := []string{}
   349  	for _, configPath := range configPaths {
   350  		fullConfigPath := configLocation + configPath
   351  		destConfigPath := path.Join(tempDir, configPath)
   352  		source := must(getter.Detect(fullConfigPath, must(os.Getwd()).(string), getter.Detectors)).(string)
   353  
   354  		err := getter.Get(destConfigPath, source)
   355  		if err == nil {
   356  			_, err = os.Stat(destConfigPath)
   357  			if os.IsNotExist(err) {
   358  				err = errors.New("Config file was not found at the source")
   359  			}
   360  		}
   361  
   362  		if err != nil {
   363  			printWarning("Error fetching config at %s: %v", source, err)
   364  			continue
   365  		}
   366  
   367  		if content, err := ioutil.ReadFile(destConfigPath); err != nil {
   368  			printWarning("Error reading fetched config file %s: %v", configPath, err)
   369  		} else {
   370  			contentString := string(content)
   371  			if contentString != "" {
   372  				configs = append(configs, contentString)
   373  			}
   374  		}
   375  	}
   376  
   377  	return configs
   378  }
   379  
   380  func parseSsmConfig(parameterValues map[string]string) string {
   381  	ssmConfig := ""
   382  	for key, value := range parameterValues {
   383  		isDict := strings.HasPrefix(value, "{") && strings.HasSuffix(value, "}")
   384  		isList := strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]")
   385  		if !isDict && !isList {
   386  			value = fmt.Sprintf("\"%s\"", value)
   387  		}
   388  		ssmConfig += fmt.Sprintf("%s: %s\n", key, value)
   389  	}
   390  	return ssmConfig
   391  }
   392  
   393  // Check if there is an AWS configuration available.
   394  //
   395  // We call this function before trying to init an AWS session. This avoid trying to init a session in a non AWS context
   396  // and having to wait for metadata resolution or generating an error.
   397  func awsConfigExist() bool {
   398  	if os.Getenv("AWS_PROFILE")+os.Getenv("AWS_ACCESS_KEY_ID")+os.Getenv("AWS_CONFIG_FILE") != "" {
   399  		// If any AWS identification variable is defined, we consider that we are in an AWS environment.
   400  		return true
   401  	}
   402  
   403  	if _, err := exec.LookPath("aws"); err == nil {
   404  		// If aws program is installed, we also consider that we are  in an AWS environment.
   405  		return true
   406  	}
   407  
   408  	// Otherwise, we check if the current user has a folder named .aws defined under its home directory.
   409  	currentUser, err := user.Current()
   410  	if err != nil {
   411  		return false
   412  	}
   413  	awsFolder, err := os.Stat(filepath.Join(currentUser.HomeDir, ".aws"))
   414  	if err != nil {
   415  		return false
   416  	}
   417  	return awsFolder.IsDir()
   418  }
   419  
   420  // Return the list of configuration files found from the current working directory up to the root folder
   421  func findConfigFiles(folder string) (result []string) {
   422  	configFiles := []string{userConfigFile, configFile}
   423  	if disableUserConfig {
   424  		configFiles = []string{configFile}
   425  	}
   426  	for _, file := range configFiles {
   427  		file = filepath.Join(folder, file)
   428  		if _, err := os.Stat(file); !os.IsNotExist(err) {
   429  			result = append(result, file)
   430  		}
   431  	}
   432  
   433  	if parent := filepath.Dir(folder); parent != folder {
   434  		result = append(findConfigFiles(parent), result...)
   435  	}
   436  
   437  	return
   438  }
   439  
   440  func getTgfConfigFields() []string {
   441  	fields := []string{}
   442  	classType := reflect.ValueOf(TGFConfig{}).Type()
   443  	for i := 0; i < classType.NumField(); i++ {
   444  		tagValue := classType.Field(i).Tag.Get("yaml")
   445  		if tagValue != "" {
   446  			fields = append(fields, strings.Replace(tagValue, ",omitempty", "", -1))
   447  		}
   448  	}
   449  	return fields
   450  }
   451  
   452  func getSSMParameterFolder() string {
   453  	if value, ok := os.LookupEnv(ssmParameterFolderEnvVariable); ok {
   454  		return value
   455  	}
   456  	return defaultSSMParameterFolder
   457  }
   458  
   459  // CheckVersionRange compare a version with a range of values
   460  // Check https://github.com/blang/semver/blob/master/README.md for more information
   461  func CheckVersionRange(version, compare string) (bool, error) {
   462  	if strings.Count(version, ".") == 1 {
   463  		version = version + ".9999" // Patch is irrelevant if major and minor are OK
   464  	}
   465  	v, err := semver.Make(version)
   466  	if err != nil {
   467  		return false, err
   468  	}
   469  
   470  	comp, err := semver.ParseRange(compare)
   471  	if err != nil {
   472  		return false, err
   473  	}
   474  
   475  	return comp(v), nil
   476  }
   477  
   478  // ConfigWarning is used to represent messages that should not be considered as critical error
   479  type ConfigWarning string
   480  
   481  func (e ConfigWarning) Error() string {
   482  	return string(e)
   483  }
   484  
   485  // VersionMistmatchError is used to describe an out of range version
   486  type VersionMistmatchError string
   487  
   488  func (e VersionMistmatchError) Error() string {
   489  	return string(e)
   490  }